using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; 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.Interfaces.Runtime; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using LanguageExt; using Microsoft.EntityFrameworkCore; using static LanguageExt.Prelude; namespace ErsatzTV.Application.Streaming.Queries { public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; private readonly IArtistRepository _artistRepository; private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly ILocalFileSystem _localFileSystem; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IRuntimeInfo _runtimeInfo; private readonly ISongVideoGenerator _songVideoGenerator; public GetPlayoutItemProcessByChannelNumberHandler( IDbContextFactory dbContextFactory, IFFmpegProcessService ffmpegProcessService, ILocalFileSystem localFileSystem, IPlexPathReplacementService plexPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService, IEmbyPathReplacementService embyPathReplacementService, IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, IRuntimeInfo runtimeInfo, ISongVideoGenerator songVideoGenerator) : base(dbContextFactory) { _ffmpegProcessService = ffmpegProcessService; _localFileSystem = localFileSystem; _plexPathReplacementService = plexPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService; _embyPathReplacementService = embyPathReplacementService; _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; _runtimeInfo = runtimeInfo; _songVideoGenerator = songVideoGenerator; } protected override async Task> GetProcess( TvContext dbContext, GetPlayoutItemProcessByChannelNumber request, Channel channel, string ffmpegPath) { DateTimeOffset now = request.Now; Either maybePlayoutItem = await dbContext.PlayoutItems .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).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).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 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) .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); } return await maybePlayoutItem.Match( async playoutItemWithPath => { 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, maybeGlobalWatermark, ffmpegPath); } bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegSaveReports) .Map(result => result.IfNone(false)); Process process = await _ffmpegProcessService.ForPlayoutItem( ffmpegPath, saveReports, channel, videoVersion, audioVersion, videoPath, audioPath, playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.FinishOffset, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now, maybeGlobalWatermark, channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice, request.HlsRealtime, playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.InPoint, playoutItemWithPath.PlayoutItem.OutPoint); var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset); return Right(result); }, async error => { var offlineTranscodeMessage = $"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"; Option maybeDuration = await Optional(channel.FFmpegProfile.Transcode) .Where(transcode => transcode) .Match( _ => 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), () => Option.None.AsTask()); DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now); switch (error) { case UnableToLocatePlayoutItem: if (channel.FFmpegProfile.Transcode) { Process errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, "Channel is Offline", request.HlsRealtime); return new PlayoutItemProcessModel(errorProcess, finish); } else { var message = $"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}"; return BaseError.New(message); } case PlayoutItemDoesNotExistOnDisk: if (channel.FFmpegProfile.Transcode) { Process errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, error.Value, request.HlsRealtime); return new PlayoutItemProcessModel(errorProcess, finish); } else { var message = $"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}"; return BaseError.New(message); } default: if (channel.FFmpegProfile.Transcode) { Process errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, channel, maybeDuration, "Channel is Offline", request.HlsRealtime); return new PlayoutItemProcessModel(errorProcess, finish); } else { var message = $"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}"; return BaseError.New(message); } } }); } 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 Optional(channel.FFmpegProfile.Transcode) .Where(transcode => transcode) .Match( _ => 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), () => Option.None.AsTask()); 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 }; 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); } }