using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Streaming; public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly IArtistRepository _artistRepository; private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly ISongVideoGenerator _songVideoGenerator; private readonly ITelevisionRepository _televisionRepository; public GetPlayoutItemProcessByChannelNumberHandler( IDbContextFactory dbContextFactory, IFFmpegProcessService ffmpegProcessService, ILocalFileSystem localFileSystem, IPlexPathReplacementService plexPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService, IEmbyPathReplacementService embyPathReplacementService, IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, ISongVideoGenerator songVideoGenerator, IMusicVideoCreditsGenerator musicVideoCreditsGenerator, ILogger logger) : base(dbContextFactory) { _ffmpegProcessService = ffmpegProcessService; _localFileSystem = localFileSystem; _plexPathReplacementService = plexPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService; _embyPathReplacementService = embyPathReplacementService; _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; _songVideoGenerator = songVideoGenerator; _musicVideoCreditsGenerator = musicVideoCreditsGenerator; _logger = logger; } protected override async Task> GetProcess( TvContext dbContext, GetPlayoutItemProcessByChannelNumber request, Channel channel, string ffmpegPath, string ffprobePath, CancellationToken cancellationToken) { DateTimeOffset now = request.Now; Either maybePlayoutItem = await dbContext.PlayoutItems .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Episode).EpisodeMetadata) .ThenInclude(em => em.Subtitles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Episode).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Episode).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Movie).MovieMetadata) .ThenInclude(mm => mm.Subtitles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Movie).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Movie).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) .ThenInclude(mvm => mvm.Subtitles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as MusicVideo).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as MusicVideo).MediaVersions) .ThenInclude(mv => mv.Streams) .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(ovm => ovm.Subtitles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(ov => ov.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(ov => ov.Streams) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Song).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Song).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Song).SongMetadata) .ThenInclude(sm => sm.Artwork) .Include(i => i.Watermark) .ForChannelAndTime(channel.Id, now) .Map(o => o.ToEither(new UnableToLocatePlayoutItem())) .BindT(ValidatePlayoutItemPath); if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem)) { maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now); } foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq()) { MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); string videoPath = playoutItemWithPath.Path; MediaVersion videoVersion = version; string audioPath = playoutItemWithPath.Path; MediaVersion audioVersion = version; Option maybeGlobalWatermark = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegGlobalWatermarkId) .BindT( watermarkId => dbContext.ChannelWatermarks .SelectOneAsync(w => w.Id, w => w.Id == watermarkId)); if (playoutItemWithPath.PlayoutItem.MediaItem is Song song) { (videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo( song, channel, Optional(playoutItemWithPath.PlayoutItem.Watermark), maybeGlobalWatermark, ffmpegPath, ffprobePath, cancellationToken); } bool saveReports = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegSaveReports) .Map(result => result.IfNone(false)); List subtitles = await GetSubtitles(playoutItemWithPath, channel); Command process = await _ffmpegProcessService.ForPlayoutItem( ffmpegPath, ffprobePath, saveReports, channel, videoVersion, audioVersion, videoPath, audioPath, subtitles, playoutItemWithPath.PlayoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, playoutItemWithPath.PlayoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode, playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.FinishOffset, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now, Optional(playoutItemWithPath.PlayoutItem.Watermark), maybeGlobalWatermark, channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice, request.HlsRealtime, playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.InPoint, playoutItemWithPath.PlayoutItem.OutPoint, request.PtsOffset, request.TargetFramerate, playoutItemWithPath.PlayoutItem.DisableWatermarks); var result = new PlayoutItemProcessModel( process, playoutItemWithPath.PlayoutItem.FinishOffset - (request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now), playoutItemWithPath.PlayoutItem.FinishOffset); return Right(result); } foreach (BaseError error in maybePlayoutItem.LeftToSeq()) { Option maybeDuration = await dbContext.PlayoutItems .Filter(pi => pi.Playout.ChannelId == channel.Id) .Filter(pi => pi.Start > now.UtcDateTime) .OrderBy(pi => pi.Start) .FirstOrDefaultAsync(cancellationToken) .Map(Optional) .MapT(pi => pi.StartOffset - now); DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now); _logger.LogWarning( "Error locating playout item {@Error}. Will display error from {Start} to {Finish}", error, now, finish); switch (error) { case UnableToLocatePlayoutItem: Command offlineProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, "Channel is Offline", request.HlsRealtime, request.PtsOffset, channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice); return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish); case PlayoutItemDoesNotExistOnDisk: Command doesNotExistProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, error.Value, request.HlsRealtime, request.PtsOffset, channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice); return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish); default: Command errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, "Channel is Offline", request.HlsRealtime, request.PtsOffset, channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice); return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish); } } return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}"); } private async Task> GetSubtitles( PlayoutItemWithPath playoutItemWithPath, Channel channel) { List allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch { Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? new List()) .IfNoneAsync(new List()), Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? new List()) .IfNoneAsync(new List()), MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel), OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? new List()) .IfNoneAsync(new List()), _ => new List() }; bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; if (isMediaServer) { string mediaItemFolder = Path.GetDirectoryName(playoutItemWithPath.Path); allSubtitles = allSubtitles.Map>( subtitle => { if (subtitle.SubtitleKind == SubtitleKind.Sidecar) { // need to prepend path with movie/episode folder if (!string.IsNullOrWhiteSpace(mediaItemFolder)) { subtitle.Path = Path.Combine(mediaItemFolder, subtitle.Path); // skip subtitles that don't exist if (!File.Exists(subtitle.Path)) { return None; } } } return subtitle; }) .Somes() .ToList(); } return allSubtitles; } private async Task> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel) { var subtitles = new List(); 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())); } return subtitles; } private async Task> CheckForFallbackFiller( TvContext dbContext, Channel channel, DateTimeOffset now) { // check for channel fallback Option maybeFallback = await dbContext.FillerPresets .SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId); // then check for global fallback if (maybeFallback.IsNone) { maybeFallback = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegGlobalFallbackFillerId) .BindT(fillerId => dbContext.FillerPresets.SelectOneAsync(w => w.Id, w => w.Id == fillerId)); } foreach (FillerPreset fallbackPreset in maybeFallback) { // turn this into a playout item var collectionKey = CollectionKey.ForFillerPreset(fallbackPreset); List items = await MediaItemsForCollection.Collect( _mediaCollectionRepository, _televisionRepository, _artistRepository, collectionKey); // TODO: shuffle? does it really matter since we loop anyway MediaItem item = items[new Random().Next(items.Count)]; Option maybeDuration = await dbContext.PlayoutItems .Filter(pi => pi.Playout.ChannelId == channel.Id) .Filter(pi => pi.Start > now.UtcDateTime) .OrderBy(pi => pi.Start) .FirstOrDefaultAsync() .Map(Optional) .MapT(pi => pi.StartOffset - now); MediaVersion version = item.GetHeadVersion(); version.MediaFiles = await dbContext.MediaFiles .AsNoTracking() .Filter(mf => mf.MediaVersionId == version.Id) .ToListAsync(); version.Streams = await dbContext.MediaStreams .AsNoTracking() .Filter(ms => ms.MediaVersionId == version.Id) .ToListAsync(); DateTimeOffset finish = maybeDuration.Match( // next playout item exists // loop until it starts now.Add, // no next playout item exists // loop for 5 minutes if less than 30s, otherwise play full item () => version.Duration < TimeSpan.FromSeconds(30) ? now.AddMinutes(5) : now.Add(version.Duration)); var playoutItem = new PlayoutItem { MediaItem = item, MediaItemId = item.Id, Start = now.UtcDateTime, Finish = finish.UtcDateTime, FillerKind = FillerKind.Fallback, InPoint = TimeSpan.Zero, OutPoint = version.Duration, DisableWatermarks = !fallbackPreset.AllowWatermarks }; return await ValidatePlayoutItemPath(playoutItem); } return new UnableToLocatePlayoutItem(); } private async Task> ValidatePlayoutItemPath(PlayoutItem playoutItem) { string path = await GetPlayoutItemPath(playoutItem); if (_localFileSystem.FileExists(path)) { return new PlayoutItemWithPath(playoutItem, path); } return new PlayoutItemDoesNotExistOnDisk(path); } private async Task GetPlayoutItemPath(PlayoutItem playoutItem) { MediaVersion version = playoutItem.MediaItem.GetHeadVersion(); MediaFile file = version.MediaFiles.Head(); string path = file.Path; return playoutItem.MediaItem switch { PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath( plexMovie.LibraryPathId, path), PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath( plexEpisode.LibraryPathId, path), JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath( jellyfinMovie.LibraryPathId, path), JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath( jellyfinEpisode.LibraryPathId, path), EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath( embyMovie.LibraryPathId, path), EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath( embyEpisode.LibraryPathId, path), _ => path }; } private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path); }