generate music video credits (#832)
This commit is contained in:
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Fix Jellyfin show library paging
|
- Fix Jellyfin show library paging
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add basic music video credits subtitle generation
|
||||||
|
- This can be enabled in channel settings
|
||||||
|
|
||||||
## [0.6.0-beta] - 2022-06-01
|
## [0.6.0-beta] - 2022-06-01
|
||||||
### Fixed
|
### Fixed
|
||||||
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
|
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ public record ChannelViewModel(
|
|||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
int PlayoutCount,
|
int PlayoutCount,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode);
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ public record CreateChannel
|
|||||||
int? WatermarkId,
|
int? WatermarkId,
|
||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||||
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
|
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||||
@@ -36,6 +36,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
await FFmpegProfileMustExist(dbContext, request),
|
await FFmpegProfileMustExist(dbContext, request),
|
||||||
ValidatePreferredAudioLanguage(request),
|
ValidatePreferredAudioLanguage(request),
|
||||||
ValidatePreferredSubtitleLanguage(request),
|
ValidatePreferredSubtitleLanguage(request),
|
||||||
|
ValidateSubtitleAndMusicCredits(request),
|
||||||
await WatermarkMustExist(dbContext, request),
|
await WatermarkMustExist(dbContext, request),
|
||||||
await FillerPresetMustExist(dbContext, request))
|
await FillerPresetMustExist(dbContext, request))
|
||||||
.Apply(
|
.Apply(
|
||||||
@@ -45,6 +46,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
ffmpegProfileId,
|
ffmpegProfileId,
|
||||||
preferredAudioLanguageCode,
|
preferredAudioLanguageCode,
|
||||||
preferredSubtitleLanguageCode,
|
preferredSubtitleLanguageCode,
|
||||||
|
_,
|
||||||
watermarkId,
|
watermarkId,
|
||||||
fillerPresetId) =>
|
fillerPresetId) =>
|
||||||
{
|
{
|
||||||
@@ -72,7 +74,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
Artwork = artwork,
|
Artwork = artwork,
|
||||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||||
SubtitleMode = request.SubtitleMode
|
SubtitleMode = request.SubtitleMode,
|
||||||
|
MusicVideoCreditsMode = request.MusicVideoCreditsMode
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (int id in watermarkId)
|
foreach (int id in watermarkId)
|
||||||
@@ -106,6 +109,17 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||||
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
|
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
|
||||||
|
|
||||||
|
private static Validation<BaseError, string> ValidateSubtitleAndMusicCredits(CreateChannel createChannel)
|
||||||
|
{
|
||||||
|
if (createChannel.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.None &&
|
||||||
|
createChannel.SubtitleMode == ChannelSubtitleMode.None)
|
||||||
|
{
|
||||||
|
return BaseError.New("Subtitles are required for music video credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
CreateChannel createChannel)
|
CreateChannel createChannel)
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ public record UpdateChannel
|
|||||||
int? WatermarkId,
|
int? WatermarkId,
|
||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||||
c.SubtitleMode = update.SubtitleMode;
|
c.SubtitleMode = update.SubtitleMode;
|
||||||
|
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||||
c.Artwork ??= new List<Artwork>();
|
c.Artwork ??= new List<Artwork>();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||||
@@ -92,8 +93,9 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||||
await ValidateNumber(dbContext, request),
|
await ValidateNumber(dbContext, request),
|
||||||
ValidatePreferredAudioLanguage(request))
|
ValidatePreferredAudioLanguage(request),
|
||||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
ValidateSubtitleAndMusicCredits(request))
|
||||||
|
.Apply((channelToUpdate, _, _, _, _) => channelToUpdate);
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
@@ -135,4 +137,15 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
||||||
|
|
||||||
|
private static Validation<BaseError, string> ValidateSubtitleAndMusicCredits(UpdateChannel updateChannel)
|
||||||
|
{
|
||||||
|
if (updateChannel.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.None &&
|
||||||
|
updateChannel.SubtitleMode == ChannelSubtitleMode.None)
|
||||||
|
{
|
||||||
|
return BaseError.New("Subtitles are required for music video credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ internal static class Mapper
|
|||||||
channel.FallbackFillerId,
|
channel.FallbackFillerId,
|
||||||
channel.Playouts?.Count ?? 0,
|
channel.Playouts?.Count ?? 0,
|
||||||
channel.PreferredSubtitleLanguageCode,
|
channel.PreferredSubtitleLanguageCode,
|
||||||
channel.SubtitleMode);
|
channel.SubtitleMode,
|
||||||
|
channel.MusicVideoCreditsMode);
|
||||||
|
|
||||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||||
new(
|
new(
|
||||||
|
|||||||
+38
-11
@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||||
|
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
|
||||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||||
private readonly ITelevisionRepository _televisionRepository;
|
private readonly ITelevisionRepository _televisionRepository;
|
||||||
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
ITelevisionRepository televisionRepository,
|
ITelevisionRepository televisionRepository,
|
||||||
IArtistRepository artistRepository,
|
IArtistRepository artistRepository,
|
||||||
ISongVideoGenerator songVideoGenerator,
|
ISongVideoGenerator songVideoGenerator,
|
||||||
|
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
|
||||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||||
: base(dbContextFactory)
|
: base(dbContextFactory)
|
||||||
{
|
{
|
||||||
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
_televisionRepository = televisionRepository;
|
_televisionRepository = televisionRepository;
|
||||||
_artistRepository = artistRepository;
|
_artistRepository = artistRepository;
|
||||||
_songVideoGenerator = songVideoGenerator;
|
_songVideoGenerator = songVideoGenerator;
|
||||||
|
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||||
.ThenInclude(mv => mv.Streams)
|
.ThenInclude(mv => mv.Streams)
|
||||||
.Include(i => i.MediaItem)
|
.Include(i => i.MediaItem)
|
||||||
|
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||||
|
.ThenInclude(mv => mv.ArtistMetadata)
|
||||||
|
.Include(i => i.MediaItem)
|
||||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||||
.ThenInclude(ovm => ovm.Subtitles)
|
.ThenInclude(ovm => ovm.Subtitles)
|
||||||
.Include(i => i.MediaItem)
|
.Include(i => i.MediaItem)
|
||||||
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||||
.Map(result => result.IfNone(false));
|
.Map(result => result.IfNone(false));
|
||||||
|
|
||||||
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
|
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
|
||||||
|
|
||||||
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
@@ -256,22 +262,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
|
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
|
private async Task<List<Subtitle>> GetSubtitles(
|
||||||
|
PlayoutItemWithPath playoutItemWithPath,
|
||||||
|
Channel channel)
|
||||||
{
|
{
|
||||||
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||||
{
|
{
|
||||||
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||||
.IfNone(new List<Subtitle>()),
|
.IfNoneAsync(new List<Subtitle>()),
|
||||||
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||||
.IfNone(new List<Subtitle>()),
|
.IfNoneAsync(new List<Subtitle>()),
|
||||||
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
|
||||||
|
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||||
.IfNone(new List<Subtitle>()),
|
.IfNoneAsync(new List<Subtitle>()),
|
||||||
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
|
||||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
|
||||||
.IfNone(new List<Subtitle>()),
|
|
||||||
_ => new List<Subtitle>()
|
_ => new List<Subtitle>()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -309,6 +315,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
return allSubtitles;
|
return allSubtitles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
|
||||||
|
{
|
||||||
|
var subtitles = new List<Subtitle>();
|
||||||
|
|
||||||
|
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
|
||||||
|
if (musicVideoCredits)
|
||||||
|
{
|
||||||
|
subtitles.AddRange(
|
||||||
|
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subtitles.AddRange(
|
||||||
|
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||||
|
.Map(mm => mm.Subtitles)
|
||||||
|
.IfNoneAsync(new List<Subtitle>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitles;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
|
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
Channel channel,
|
Channel channel,
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ public class Channel
|
|||||||
public string PreferredAudioLanguageCode { get; set; }
|
public string PreferredAudioLanguageCode { get; set; }
|
||||||
public string PreferredSubtitleLanguageCode { get; set; }
|
public string PreferredSubtitleLanguageCode { get; set; }
|
||||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||||
|
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
public enum ChannelMusicVideoCreditsMode
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
GenerateSubtitles = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.FFmpeg;
|
||||||
|
|
||||||
|
public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator
|
||||||
|
{
|
||||||
|
private readonly ITempFilePool _tempFilePool;
|
||||||
|
|
||||||
|
public MusicVideoCreditsGenerator(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
|
||||||
|
|
||||||
|
public async Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile)
|
||||||
|
{
|
||||||
|
const int HORIZONTAL_MARGIN_PERCENT = 3;
|
||||||
|
const int VERTICAL_MARGIN_PERCENT = 5;
|
||||||
|
|
||||||
|
var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0);
|
||||||
|
|
||||||
|
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
|
||||||
|
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
|
||||||
|
|
||||||
|
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
|
||||||
|
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
|
||||||
|
var verticalMargin =
|
||||||
|
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height);
|
||||||
|
|
||||||
|
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
string artist = string.Empty;
|
||||||
|
foreach (ArtistMetadata artistMetadata in Optional(metadata.MusicVideo?.Artist?.ArtistMetadata).Flatten())
|
||||||
|
{
|
||||||
|
artist = artistMetadata.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(artist))
|
||||||
|
{
|
||||||
|
sb.Append(artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||||
|
{
|
||||||
|
sb.Append($"\\N\"{metadata.Title}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||||
|
{
|
||||||
|
sb.Append($"\\N{metadata.Album}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string subtitles = await new SubtitleBuilder(_tempFilePool)
|
||||||
|
.WithResolution(ffmpegProfile.Resolution)
|
||||||
|
.WithFontName("OPTIKabel-Heavy")
|
||||||
|
.WithFontSize(fontSize)
|
||||||
|
.WithPrimaryColor("&HFFFFFF")
|
||||||
|
.WithOutlineColor("&H444444")
|
||||||
|
.WithAlignment(0)
|
||||||
|
.WithMarginRight(rightMargin)
|
||||||
|
.WithMarginLeft(leftMargin)
|
||||||
|
.WithMarginV(verticalMargin)
|
||||||
|
.WithBorderStyle(1)
|
||||||
|
.WithShadow(3)
|
||||||
|
.WithFormattedContent(sb.ToString())
|
||||||
|
.WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16))
|
||||||
|
.BuildFile();
|
||||||
|
|
||||||
|
return new Subtitle
|
||||||
|
{
|
||||||
|
Codec = "ass", Default = true, Forced = true, IsExtracted = false, SubtitleKind = SubtitleKind.Sidecar,
|
||||||
|
Path = subtitles, SDH = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public class SubtitleBuilder
|
|||||||
private Option<int> _alignment;
|
private Option<int> _alignment;
|
||||||
private Option<int> _borderStyle;
|
private Option<int> _borderStyle;
|
||||||
private string _content;
|
private string _content;
|
||||||
|
private Option<TimeSpan> _end;
|
||||||
private Option<string> _fontName;
|
private Option<string> _fontName;
|
||||||
private Option<int> _fontSize;
|
private Option<int> _fontSize;
|
||||||
private int _marginLeft;
|
private int _marginLeft;
|
||||||
@@ -18,6 +19,7 @@ public class SubtitleBuilder
|
|||||||
private Option<string> _primaryColor;
|
private Option<string> _primaryColor;
|
||||||
private Option<IDisplaySize> _resolution = None;
|
private Option<IDisplaySize> _resolution = None;
|
||||||
private Option<int> _shadow;
|
private Option<int> _shadow;
|
||||||
|
private Option<TimeSpan> _start;
|
||||||
|
|
||||||
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
|
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
|
||||||
|
|
||||||
@@ -93,6 +95,13 @@ public class SubtitleBuilder
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SubtitleBuilder WithStartEnd(TimeSpan start, TimeSpan end)
|
||||||
|
{
|
||||||
|
_start = start;
|
||||||
|
_end = end;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> BuildFile()
|
public async Task<string> BuildFile()
|
||||||
{
|
{
|
||||||
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
|
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
|
||||||
@@ -116,15 +125,22 @@ public class SubtitleBuilder
|
|||||||
sb.AppendLine(
|
sb.AppendLine(
|
||||||
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
|
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
|
||||||
|
|
||||||
|
var start = "0:00:00.00";
|
||||||
|
foreach (TimeSpan startTime in _start)
|
||||||
|
{
|
||||||
|
start = $"{(int)startTime.TotalHours:00}:{startTime.ToString(@"mm\:ss\.ff")}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = "99:99:99.99";
|
||||||
|
foreach (TimeSpan endTime in _end)
|
||||||
|
{
|
||||||
|
end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}";
|
||||||
|
}
|
||||||
|
|
||||||
sb.AppendLine("[Events]");
|
sb.AppendLine("[Events]");
|
||||||
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
|
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
|
||||||
sb.AppendLine(
|
sb.AppendLine(
|
||||||
$"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
|
@$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{{\fad(1200,1200)}}{_content}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_content))
|
|
||||||
{
|
|
||||||
sb.AppendLine(_content);
|
|
||||||
}
|
|
||||||
|
|
||||||
await File.WriteAllTextAsync(fileName, sb.ToString());
|
await File.WriteAllTextAsync(fileName, sb.ToString());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
|
||||||
|
public interface IMusicVideoCreditsGenerator
|
||||||
|
{
|
||||||
|
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
|
||||||
|
}
|
||||||
Generated
+4294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
public partial class Add_ChannelMusicVideoCreditsMode : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MusicVideoCreditsMode",
|
||||||
|
table: "Channel",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MusicVideoCreditsMode",
|
||||||
|
table: "Channel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,6 +233,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasDefaultValue("ErsatzTV");
|
.HasDefaultValue("ErsatzTV");
|
||||||
|
|
||||||
|
b.Property<int>("MusicVideoCreditsMode")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
@using ErsatzTV.Core.Domain.Filler
|
@using ErsatzTV.Core.Domain.Filler
|
||||||
@using ErsatzTV.Application.Channels
|
@using ErsatzTV.Application.Channels
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject NavigationManager _navigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject ILogger<ChannelEditor> _logger
|
@inject ILogger<ChannelEditor> Logger
|
||||||
@inject ISnackbar _snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IMediator _mediator
|
@inject IMediator Mediator
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||||
<div style="max-width: 400px;">
|
<div style="max-width: 400px;">
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
|
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
|
||||||
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
|
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
<MudSelect Class="mt-3" Label="Music Video Credits Mode" @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)">
|
||||||
|
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||||
<MudItem xs="6">
|
<MudItem xs="6">
|
||||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||||
@@ -144,13 +148,13 @@
|
|||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
await LoadFFmpegProfiles(_cts.Token);
|
await LoadFFmpegProfiles(_cts.Token);
|
||||||
_availableCultures = await _mediator.Send(new GetAllLanguageCodes(), _cts.Token);
|
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token);
|
||||||
await LoadWatermarks(_cts.Token);
|
await LoadWatermarks(_cts.Token);
|
||||||
await LoadFillerPresets(_cts.Token);
|
await LoadFillerPresets(_cts.Token);
|
||||||
|
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelById(Id.Value), _cts.Token);
|
Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelById(Id.Value), _cts.Token);
|
||||||
maybeChannel.Match(
|
maybeChannel.Match(
|
||||||
channelViewModel =>
|
channelViewModel =>
|
||||||
{
|
{
|
||||||
@@ -167,15 +171,16 @@
|
|||||||
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
|
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
|
||||||
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
|
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
|
||||||
_model.SubtitleMode = channelViewModel.SubtitleMode;
|
_model.SubtitleMode = channelViewModel.SubtitleMode;
|
||||||
|
_model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode;
|
||||||
},
|
},
|
||||||
() => _navigationManager.NavigateTo("404"));
|
() => NavigationManager.NavigateTo("404"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
FFmpegSettingsViewModel ffmpegSettings = await _mediator.Send(new GetFFmpegSettings(), _cts.Token);
|
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), _cts.Token);
|
||||||
|
|
||||||
// TODO: command for new channel
|
// TODO: command for new channel
|
||||||
IEnumerable<int> channelNumbers = await _mediator.Send(new GetAllChannels(), _cts.Token)
|
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels(), _cts.Token)
|
||||||
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
|
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
|
||||||
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
|
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
|
||||||
_model.Number = (maxNumber + 1).ToString();
|
_model.Number = (maxNumber + 1).ToString();
|
||||||
@@ -195,13 +200,13 @@
|
|||||||
private bool IsEdit => Id.HasValue;
|
private bool IsEdit => Id.HasValue;
|
||||||
|
|
||||||
private async Task LoadFFmpegProfiles(CancellationToken cancellationToken) =>
|
private async Task LoadFFmpegProfiles(CancellationToken cancellationToken) =>
|
||||||
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
|
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
|
||||||
|
|
||||||
private async Task LoadWatermarks(CancellationToken cancellationToken) =>
|
private async Task LoadWatermarks(CancellationToken cancellationToken) =>
|
||||||
_watermarks = await _mediator.Send(new GetAllWatermarks(), cancellationToken);
|
_watermarks = await Mediator.Send(new GetAllWatermarks(), cancellationToken);
|
||||||
|
|
||||||
private async Task LoadFillerPresets(CancellationToken cancellationToken) =>
|
private async Task LoadFillerPresets(CancellationToken cancellationToken) =>
|
||||||
_fillerPresets = await _mediator.Send(new GetAllFillerPresets(), cancellationToken)
|
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), cancellationToken)
|
||||||
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList());
|
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList());
|
||||||
|
|
||||||
private async Task HandleSubmitAsync()
|
private async Task HandleSubmitAsync()
|
||||||
@@ -210,16 +215,16 @@
|
|||||||
if (_editContext.Validate())
|
if (_editContext.Validate())
|
||||||
{
|
{
|
||||||
Seq<BaseError> errorMessage = IsEdit ?
|
Seq<BaseError> errorMessage = IsEdit ?
|
||||||
(await _mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() :
|
(await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() :
|
||||||
(await _mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
(await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
||||||
|
|
||||||
errorMessage.HeadOrNone().Match(
|
errorMessage.HeadOrNone().Match(
|
||||||
error =>
|
error =>
|
||||||
{
|
{
|
||||||
_snackbar.Add(error.Value, Severity.Error);
|
Snackbar.Add(error.Value, Severity.Error);
|
||||||
_logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
||||||
},
|
},
|
||||||
() => _navigationManager.NavigateTo("/channels"));
|
() => NavigationManager.NavigateTo("/channels"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +233,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Either<BaseError, string> maybeCacheFileName =
|
Either<BaseError, string> maybeCacheFileName =
|
||||||
await _mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo), _cts.Token);
|
await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo), _cts.Token);
|
||||||
maybeCacheFileName.Match(
|
maybeCacheFileName.Match(
|
||||||
relativeFileName =>
|
relativeFileName =>
|
||||||
{
|
{
|
||||||
@@ -237,21 +242,19 @@
|
|||||||
},
|
},
|
||||||
error =>
|
error =>
|
||||||
{
|
{
|
||||||
Console.WriteLine($"error saving {error}");
|
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||||
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||||
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (IOException ex)
|
catch (IOException)
|
||||||
{
|
{
|
||||||
Console.WriteLine(ex);
|
Snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
||||||
_snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
Logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
||||||
_logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
Snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
||||||
_logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
Logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ public class ResourceExtractorService : IHostedService
|
|||||||
await ExtractResource(assembly, "song_background_2.png", cancellationToken);
|
await ExtractResource(assembly, "song_background_2.png", cancellationToken);
|
||||||
await ExtractResource(assembly, "song_background_3.png", cancellationToken);
|
await ExtractResource(assembly, "song_background_3.png", cancellationToken);
|
||||||
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
|
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
|
||||||
await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken);
|
|
||||||
await ExtractResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
|
await ExtractFontResource(assembly, "Roboto-Regular.ttf", cancellationToken);
|
||||||
|
await ExtractFontResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
private async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
||||||
if (resource != null)
|
if (resource != null)
|
||||||
@@ -35,4 +36,21 @@ public class ResourceExtractorService : IHostedService
|
|||||||
await resource.CopyToAsync(fs, cancellationToken);
|
await resource.CopyToAsync(fs, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task ExtractFontResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
||||||
|
if (resource != null)
|
||||||
|
{
|
||||||
|
await using FileStream fs = File.Create(
|
||||||
|
Path.Combine(FileSystemLayout.ResourcesCacheFolder, name));
|
||||||
|
await resource.CopyToAsync(fs, cancellationToken);
|
||||||
|
|
||||||
|
resource.Position = 0;
|
||||||
|
|
||||||
|
await using FileStream fontCacheFileStream = File.Create(
|
||||||
|
Path.Combine(FileSystemLayout.FontsCacheFolder, name));
|
||||||
|
await resource.CopyToAsync(fontCacheFileStream, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ public class Startup
|
|||||||
services.AddScoped<FFmpegProcessService>();
|
services.AddScoped<FFmpegProcessService>();
|
||||||
|
|
||||||
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
|
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
|
||||||
|
services.AddScoped<IMusicVideoCreditsGenerator, MusicVideoCreditsGenerator>();
|
||||||
services.AddScoped<HlsSessionWorker>();
|
services.AddScoped<HlsSessionWorker>();
|
||||||
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
|
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
|
||||||
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
|
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class ChannelEditViewModel
|
|||||||
public int? FallbackFillerId { get; set; }
|
public int? FallbackFillerId { get; set; }
|
||||||
public string PreferredSubtitleLanguageCode { get; set; }
|
public string PreferredSubtitleLanguageCode { get; set; }
|
||||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||||
|
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||||
|
|
||||||
public UpdateChannel ToUpdate() =>
|
public UpdateChannel ToUpdate() =>
|
||||||
new(
|
new(
|
||||||
@@ -33,7 +34,8 @@ public class ChannelEditViewModel
|
|||||||
WatermarkId,
|
WatermarkId,
|
||||||
FallbackFillerId,
|
FallbackFillerId,
|
||||||
PreferredSubtitleLanguageCode,
|
PreferredSubtitleLanguageCode,
|
||||||
SubtitleMode);
|
SubtitleMode,
|
||||||
|
MusicVideoCreditsMode);
|
||||||
|
|
||||||
public CreateChannel ToCreate() =>
|
public CreateChannel ToCreate() =>
|
||||||
new(
|
new(
|
||||||
@@ -48,5 +50,6 @@ public class ChannelEditViewModel
|
|||||||
WatermarkId,
|
WatermarkId,
|
||||||
FallbackFillerId,
|
FallbackFillerId,
|
||||||
PreferredSubtitleLanguageCode,
|
PreferredSubtitleLanguageCode,
|
||||||
SubtitleMode);
|
SubtitleMode,
|
||||||
|
MusicVideoCreditsMode);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user