diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index 4d626bd52..92713aa13 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; -using System.CommandLine.Parsing; using System.Globalization; using System.IO.Abstractions; using System.Runtime.InteropServices; @@ -7,33 +5,22 @@ using System.Text; using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Extensions; -using ErsatzTV.Core.FFmpeg; -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.FFmpeg.State; +using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; +using ErsatzTV.Infrastructure.Scheduling; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using CommandResult = CliWrap.CommandResult; using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; -using WatermarkLocation = ErsatzTV.FFmpeg.State.WatermarkLocation; namespace ErsatzTV.Application.Playouts; public partial class SyncNextPlayoutHandler( + IPlayoutItemConverter playoutItemConverter, IFileSystem fileSystem, ILocalFileSystem localFileSystem, - IPlexPathReplacementService plexPathReplacementService, - IJellyfinPathReplacementService jellyfinPathReplacementService, - IEmbyPathReplacementService embyPathReplacementService, - ICustomStreamSelector customStreamSelector, - IFFmpegStreamSelector ffmpegStreamSelector, - IWatermarkSelector watermarkSelector, IDbContextFactory dbContextFactory, ILogger logger) : IRequestHandler @@ -211,11 +198,42 @@ public partial class SyncNextPlayoutHandler( logger.LogDebug("Located {Count} local playout items", playoutItems.Count); + // fill in all gaps + List newPlayoutItems = []; + Option lastFinish = Option.None; + playoutItems.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset)); + foreach (var playoutItem in playoutItems) + { + foreach (var finish in lastFinish) + { + if (finish < playoutItem.StartOffset) + { + newPlayoutItems.Add( + new DynamicPlayoutItem + { + Start = finish.UtcDateTime, + Finish = playoutItem.Start + }); + } + } + + lastFinish = playoutItem.FinishOffset; + newPlayoutItems.Add(playoutItem); + } + playoutItems = newPlayoutItems; + Option maybeGlobalWatermark = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) .BindT(watermarkId => dbContext.ChannelWatermarks .SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken)); + Option maybeChannelForArtwork = await dbContext.Channels + .AsNoTracking() + .Include(c => c.Watermark) + .Include(c => c.Artwork) + .SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken) + .Map(Optional); + foreach (IGrouping group in playoutItems.GroupBy(pi => pi.StartOffset.Date) .Where(g => g.Any())) { @@ -229,404 +247,21 @@ public partial class SyncNextPlayoutHandler( var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; foreach (PlayoutItem playoutItem in group) { - if (playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && - playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo && - playoutItem.MediaItem is not RemoteStream && playoutItem.MediaItem is not Image) - { - continue; - } - - MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); - - playoutItem.Start += playoutOffset; - playoutItem.Finish += playoutOffset; - - var nextPlayoutItem = new Core.Next.PlayoutItem - { - Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture), - Start = playoutItem.StartOffset, - Finish = playoutItem.FinishOffset - }; - - Option maybeSource = await SourceForItem(playoutItem, cancellationToken); - if (maybeSource.IsNone) - { - continue; - } - - foreach (Core.Next.Source source in maybeSource) - { - if (playoutItem.InPoint > TimeSpan.Zero) - { - source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; - } - - var duration = playoutItem.MediaItem.GetDurationForPlayout(); - if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) - { - source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; - } - - nextPlayoutItem.Source = source; - } - - // if no audio streams, use lavfi to insert silence - if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) - { - var videoSource = nextPlayoutItem.Source; - - nextPlayoutItem.Source = null; - nextPlayoutItem.Tracks = new Core.Next.PlayoutItemTracks - { - Audio = new Core.Next.TrackSelection - { - Source = - new Core.Next.Source - { - SourceType = Core.Next.SourceType.Lavfi, - Params = "anullsrc=channel_layout=stereo:sample_rate=48000" - } - }, - Video = new Core.Next.TrackSelection - { - Source = videoSource - } - }; - } - - maybeChannel = await dbContext.Channels - .AsNoTracking() - .Include(c => c.Watermark) - .Include(c => c.Artwork) - .SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken); - foreach (Channel channel in maybeChannel) - { - var audioVersion = new MediaItemAudioVersion(playoutItem.MediaItem, headVersion); - await SelectTracks( - channel, - playoutItem, - audioVersion, - nextPlayoutItem, - playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, - playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle, - playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, - playoutItem.SubtitleMode ?? channel.SubtitleMode, - cancellationToken); - await SelectWatermark( - maybeGlobalWatermark, - channel, - playoutItem, - nextPlayoutItem); - } - - playout.Items.Add(nextPlayoutItem); - } - - await fileSystem.File.WriteAllTextAsync(fileName, Core.Next.Serialize.ToJson(playout), cancellationToken); - } - } - - private async Task SelectTracks( - Channel channel, - PlayoutItem playoutItem, - MediaItemAudioVersion audioVersion, - Core.Next.PlayoutItem nextPlayoutItem, - string preferredAudioLanguage, - string preferredAudioTitle, - string preferredSubtitleLanguage, - ChannelSubtitleMode subtitleMode, - CancellationToken cancellationToken) - { - List allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); - - Option maybeAudioStream = Option.None; - Option maybeSubtitle = Option.None; - - if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) - { - StreamSelectorResult result = await customStreamSelector.SelectStreams( - channel, - nextPlayoutItem.Start, - audioVersion, - allSubtitles); - maybeAudioStream = result.AudioStream; - maybeSubtitle = result.Subtitle; - } - - if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) - { - maybeAudioStream = - await ffmpegStreamSelector.SelectAudioStream( - audioVersion, - channel.StreamingMode, - channel, - preferredAudioLanguage, - preferredAudioTitle, - shouldLogMessages: false, - cancellationToken); - - maybeSubtitle = - await ffmpegStreamSelector.SelectSubtitleStream( - allSubtitles.ToImmutableList(), - channel, - preferredSubtitleLanguage, - subtitleMode, - shouldLogMessages: false, + Option maybeNextPlayoutItem = await playoutItemConverter.ToNext( + maybeChannelForArtwork, + maybeGlobalWatermark, + playoutOffset, + playoutItem, cancellationToken); - } - foreach (MediaStream audioStream in maybeAudioStream) - { - if (nextPlayoutItem.Tracks?.Audio?.StreamIndex is null) - { - nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); - nextPlayoutItem.Tracks.Audio ??= new Core.Next.TrackSelection(); - nextPlayoutItem.Tracks.Audio.StreamIndex = audioStream.Index; - } - } - - foreach (Subtitle subtitle in maybeSubtitle) - { - if (subtitle.SubtitleKind is SubtitleKind.Embedded) - { - if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) - { - nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); - nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); - nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; - } - } - else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) - { - nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); - nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); - nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source - { - SourceType = Core.Next.SourceType.Local, - Path = subtitle.Path, - }; - } - } - else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) - { - if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) + foreach (var nextPlayoutItem in maybeNextPlayoutItem) { - nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); - nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); - nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source - { - SourceType = Core.Next.SourceType.Http, - Uri = subtitle.Path, - KeepAlive = false, - Reconnect = true - }; + playout.Items.Add(nextPlayoutItem); } } - } - } - - private async Task SelectWatermark( - Option maybeGlobalWatermark, - Channel channel, - PlayoutItem playoutItem, - Core.Next.PlayoutItem nextPlayoutItem) - { - List watermarks = watermarkSelector.SelectWatermarks( - maybeGlobalWatermark, - channel, - playoutItem, - playoutItem.StartOffset, - shouldLogMessages: false); - - // single, permanent or intermittent watermarks are supported - if (watermarks.Count == 1 && watermarks.All(wm => - wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) - { - foreach (WatermarkOptions watermarkOptions in watermarks) - { - if (nextPlayoutItem.Watermark is null) - { - Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch - { - WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter, - WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight, - WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft, - WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center, - WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight, - WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft, - WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter, - WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight, - _ => Core.Next.WatermarkLocation.TopLeft, - }; - - nextPlayoutItem.Watermark = new Core.Next.Watermark - { - Location = location, - HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, - VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, - OpacityPercent = watermarkOptions.Watermark.Opacity, - StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), - WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent, - }; - - if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) - { - nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent; - } - - if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource - { - SourceType = Core.Next.SourceType.Http, - Uri = watermarkOptions.ImagePath, - }; - } - else - { - nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource - { - SourceType = Core.Next.SourceType.Local, - Path = watermarkOptions.ImagePath, - }; - } - - if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) - { - nextPlayoutItem.Watermark.Timing = new Core.Next.Timing - { - TimingType = Core.Next.TimingType.Periodic, - Clock = Core.Next.PeriodicClock.Wall, - FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, - HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, - }; - } - } - } - } - } - - private async Task> SourceForItem( - PlayoutItem playoutItem, - CancellationToken cancellationToken) - { - if (playoutItem.MediaItem is RemoteStream remoteStream) - { - if (!string.IsNullOrWhiteSpace(remoteStream.Url)) - { - if (remoteStream.Url.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase)) - { - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Rtsp, - Uri = remoteStream.Url - }; - } - - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Http, - Uri = remoteStream.Url, - IsLive = remoteStream.IsLive, - KeepAlive = true, - Reconnect = true - }; - } - - if (!string.IsNullOrWhiteSpace(remoteStream.Script)) - { - var split = CommandLineParser.SplitCommandLine(remoteStream.Script).ToList(); - if (split.Count > 0) - { - var source = new Core.Next.Source - { - SourceType = Core.Next.SourceType.Script, - Command = split.Head(), - IsLive = remoteStream.IsLive - }; - - if (split.Count > 1) - { - source.Args = split.Tail().ToList(); - } - - return source; - } - } - - return Option.None; - } - - string path = await playoutItem.MediaItem.GetLocalPath( - plexPathReplacementService, - jellyfinPathReplacementService, - embyPathReplacementService, - cancellationToken, - log: false); - - // check filesystem first - if (fileSystem.File.Exists(path)) - { - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Local, - Path = path, - }; - } - - MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); - int mediaSourceId = playoutItem.MediaItem.LibraryPath.Library.MediaSourceId; - if (file is PlexMediaFile pmf) - { - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/plex/{mediaSourceId}/{pmf.Key}", - KeepAlive = false, - Reconnect = true - }; - } - - Option jellyfinItemId = playoutItem.MediaItem switch - { - JellyfinEpisode e => e.ItemId, - JellyfinMovie m => m.ItemId, - _ => None - }; - foreach (string itemId in jellyfinItemId) - { - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}", - KeepAlive = false, - Reconnect = true - }; - } - - // attempt to remotely stream emby - Option embyItemId = playoutItem.MediaItem switch - { - EmbyEpisode e => e.ItemId, - EmbyMovie m => m.ItemId, - _ => None - }; - - foreach (string itemId in embyItemId) - { - return new Core.Next.Source - { - SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}", - KeepAlive = false, - Reconnect = true - }; + await fileSystem.File.WriteAllTextAsync(fileName, Core.Next.Serialize.ToJson(playout), cancellationToken); } - - return Option.None; } private void CleanOldVersions( @@ -688,60 +323,4 @@ public partial class SyncNextPlayoutHandler( } } } - - private static async Task> GetSubtitles( - MediaItem mediaItem, - int playoutItemId, - TimeSpan playoutItemInPoint) - { - List allSubtitles = mediaItem switch - { - Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles ?? []) - .IfNoneAsync([]), - Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles ?? []) - .IfNoneAsync([]), - MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), - OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles ?? []) - .IfNoneAsync([]), - _ => [] - }; - - bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or - JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; - - if (isMediaServer) - { - // closed captions are currently unsupported - allSubtitles.RemoveAll(s => s.Codec == "eia_608"); - } - - // TODO: external image subtitles - allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded); - - return allSubtitles; - } - - private static List GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) - { - string seekToMs = playoutItemInPoint > TimeSpan.Zero - ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" - : string.Empty; - - return - [ - new Subtitle - { - Codec = "ass", - Default = true, - Forced = true, - IsExtracted = false, - SubtitleKind = SubtitleKind.Generated, - Path = $"http://localhost:{Settings.StreamingPort}/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", - SDH = false - } - ]; - } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index b96f99894..e8ce39dfc 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -1,25 +1,18 @@ -using System.IO.Abstractions; -using CliWrap; -using Dapper; +using CliWrap; using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; -using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.FFmpeg; -using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; -using ErsatzTV.Core.Interfaces.Jellyfin; -using ErsatzTV.Core.Interfaces.Plex; -using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; -using ErsatzTV.Core.Scheduling; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; +using ErsatzTV.Infrastructure.Scheduling; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -27,58 +20,35 @@ namespace ErsatzTV.Application.Streaming; public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { - private static readonly Random FallbackRandom = new(); - - private readonly IArtistRepository _artistRepository; - private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider; private readonly IFFmpegProcessService _ffmpegProcessService; - private readonly IFileSystem _fileSystem; - private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly ILogger _logger; - private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; private readonly IWatermarkSelector _watermarkSelector; private readonly IGraphicsElementSelector _graphicsElementSelector; - private readonly IDecoSelector _decoSelector; - private readonly IPlexPathReplacementService _plexPathReplacementService; + private readonly IDynamicPlayoutItemService _dynamicPlayoutItemService; private readonly ISongVideoGenerator _songVideoGenerator; - private readonly ITelevisionRepository _televisionRepository; private readonly bool _isDebugNoSync; public GetPlayoutItemProcessByChannelNumberHandler( IDbContextFactory dbContextFactory, IFFmpegProcessService ffmpegProcessService, - IFileSystem fileSystem, IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider, - IPlexPathReplacementService plexPathReplacementService, - IJellyfinPathReplacementService jellyfinPathReplacementService, - IEmbyPathReplacementService embyPathReplacementService, - IMediaCollectionRepository mediaCollectionRepository, - ITelevisionRepository televisionRepository, - IArtistRepository artistRepository, ISongVideoGenerator songVideoGenerator, IMusicVideoCreditsGenerator musicVideoCreditsGenerator, IWatermarkSelector watermarkSelector, IGraphicsElementSelector graphicsElementSelector, - IDecoSelector decoSelector, + IDynamicPlayoutItemService dynamicPlayoutItemService, ILogger logger) : base(dbContextFactory) { _ffmpegProcessService = ffmpegProcessService; - _fileSystem = fileSystem; _externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider; - _plexPathReplacementService = plexPathReplacementService; - _jellyfinPathReplacementService = jellyfinPathReplacementService; - _embyPathReplacementService = embyPathReplacementService; - _mediaCollectionRepository = mediaCollectionRepository; - _televisionRepository = televisionRepository; - _artistRepository = artistRepository; _songVideoGenerator = songVideoGenerator; _musicVideoCreditsGenerator = musicVideoCreditsGenerator; _watermarkSelector = watermarkSelector; _graphicsElementSelector = graphicsElementSelector; - _decoSelector = decoSelector; + _dynamicPlayoutItemService = dynamicPlayoutItemService; _logger = logger; #if DEBUG_NO_SYNC @@ -211,7 +181,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< .Include(i => i.Watermarks) .ForChannelAndTime(channel.MirrorSourceChannelId ?? channel.Id, now) .Map(o => o.ToEither(new UnableToLocatePlayoutItem())) - .BindT(item => ValidatePlayoutItemPath(dbContext, item, cancellationToken)); + .BindT(item => _dynamicPlayoutItemService.ValidatePlayoutItemPath(dbContext, item, cancellationToken)); if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem)) { @@ -224,38 +194,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem)) { - Option maybePlayout = await dbContext.Playouts - .AsNoTracking() - - // get playout deco - .Include(p => p.Deco) - .ThenInclude(d => d.DecoWatermarks) - .ThenInclude(d => d.Watermark) - .Include(p => p.Deco) - .ThenInclude(d => d.DecoGraphicsElements) - .ThenInclude(d => d.GraphicsElement) - - // get playout templates (and deco templates/decos) - .Include(p => p.Templates) - .ThenInclude(t => t.DecoTemplate) - .ThenInclude(t => t.Items) - .ThenInclude(i => i.Deco) - .ThenInclude(d => d.DecoWatermarks) - .ThenInclude(d => d.Watermark) - .SelectOneAsync( - p => p.ChannelId, - p => p.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id), - cancellationToken); - - foreach (Playout playout in maybePlayout) - { - maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, playout, now, cancellationToken); - } - - if (maybePlayout.IsNone) - { - maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, null, now, cancellationToken); - } + maybePlayoutItem = await _dynamicPlayoutItemService.CheckForFallbackFiller( + dbContext, + channel, + now, + cancellationToken); } foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq()) @@ -679,280 +622,4 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< return subtitles; } - - private async Task> CheckForFallbackFiller( - TvContext dbContext, - Channel channel, - Playout playout, - DateTimeOffset now, - CancellationToken cancellationToken) - { - Option maybeFallback = Option.None; - - DeadAirFallbackResult decoDeadAirFallback = GetDecoDeadAirFallback(playout, now); - switch (decoDeadAirFallback) - { - case CustomDeadAirFallback custom: - maybeFallback = new FillerPreset - { - // always allow watermarks here - // deco settings will disable watermarks if appropriate - AllowWatermarks = true, - - CollectionType = custom.CollectionType, - CollectionId = custom.CollectionId, - MediaItemId = custom.MediaItemId, - MultiCollectionId = custom.MultiCollectionId, - SmartCollectionId = custom.SmartCollectionId - }; - break; - case DisableDeadAirFallback: - // do nothing - break; - case InheritDeadAirFallback: - // check for channel fallback - maybeFallback = await dbContext.FillerPresets - .SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId, cancellationToken); - - // then check for global fallback - if (maybeFallback.IsNone) - { - maybeFallback = await dbContext.ConfigElements - .GetValue(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken) - .BindT(fillerId => dbContext.FillerPresets.SelectOneAsync( - w => w.Id, - w => w.Id == fillerId, - cancellationToken)); - } - - break; - } - - - 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, - cancellationToken); - - // ignore the fallback filler preset if it has no items - if (items.Count == 0) - { - break; - } - - // get a random item - MediaItem item = items[FallbackRandom.Next(items.Count)]; - - Option maybeDuration = await dbContext.PlayoutItems - .Filter(pi => pi.Playout.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id)) - .Filter(pi => pi.Start > now.UtcDateTime) - .OrderBy(pi => pi.Start) - .FirstOrDefaultAsync(cancellationToken) - .Map(Optional) - .MapT(pi => pi.StartOffset - now); - - MediaVersion version = item.GetHeadVersion(); - - version.MediaFiles = await dbContext.MediaFiles - .AsNoTracking() - .Filter(mf => mf.MediaVersionId == version.Id) - .ToListAsync(cancellationToken); - - version.Streams = await dbContext.MediaStreams - .AsNoTracking() - .Filter(ms => ms.MediaVersionId == version.Id) - .ToListAsync(cancellationToken); - - // always play min(duration to next item, version.Duration) - TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration); - if (version.Duration < duration) - { - duration = version.Duration; - } - - DateTimeOffset finish = now.Add(duration); - - var playoutItem = new PlayoutItem - { - MediaItem = item, - MediaItemId = item.Id, - Start = now.UtcDateTime, - Finish = finish.UtcDateTime, - FillerKind = FillerKind.Fallback, - InPoint = TimeSpan.Zero, - OutPoint = duration, - DisableWatermarks = !fallbackPreset.AllowWatermarks, - Watermarks = [], - PlayoutItemWatermarks = [], - GraphicsElements = [], - PlayoutItemGraphicsElements = [] - }; - - return await ValidatePlayoutItemPath(dbContext, playoutItem, cancellationToken); - } - - return new UnableToLocatePlayoutItem(); - } - - private async Task> ValidatePlayoutItemPath( - TvContext dbContext, - PlayoutItem playoutItem, - CancellationToken cancellationToken) - { - string path = await playoutItem.MediaItem.GetLocalPath( - _plexPathReplacementService, - _jellyfinPathReplacementService, - _embyPathReplacementService, - cancellationToken); - - if (_isDebugNoSync) - { - // pretend it exists so we get a nice error message - return new PlayoutItemWithPath(playoutItem, path); - } - - // check filesystem first - if (_fileSystem.File.Exists(path)) - { - if (playoutItem.MediaItem is RemoteStream remoteStream) - { - path = !string.IsNullOrWhiteSpace(remoteStream.Url) - ? remoteStream.Url - : $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; - } - - return new PlayoutItemWithPath(playoutItem, path); - } - - // attempt to remotely stream plex - MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); - switch (file) - { - case PlexMediaFile pmf: - Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( - @"SELECT PMS.Id FROM PlexMediaSource PMS - INNER JOIN Library L on PMS.Id = L.MediaSourceId - INNER JOIN LibraryPath LP on L.Id = LP.LibraryId - WHERE LP.Id = @LibraryPathId", - new { playoutItem.MediaItem.LibraryPathId }) - .Map(Optional); - - foreach (int plexMediaSourceId in maybeId) - { - _logger.LogDebug( - "Attempting to stream Plex file {PlexFileName} using key {PlexKey}", - pmf.Path, - pmf.Key); - - return new PlayoutItemWithPath( - playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); - } - - break; - } - - // attempt to remotely stream jellyfin - Option jellyfinItemId = playoutItem.MediaItem switch - { - JellyfinEpisode e => e.ItemId, - JellyfinMovie m => m.ItemId, - _ => None - }; - - foreach (string itemId in jellyfinItemId) - { - return new PlayoutItemWithPath( - playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}"); - } - - // attempt to remotely stream emby - Option embyItemId = playoutItem.MediaItem switch - { - EmbyEpisode e => e.ItemId, - EmbyMovie m => m.ItemId, - _ => None - }; - - foreach (string itemId in embyItemId) - { - return new PlayoutItemWithPath( - playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}"); - } - - return new PlayoutItemDoesNotExistOnDisk(path); - } - - private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) - { - DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now); - - // first, check deco template / active deco - foreach (Deco templateDeco in decoEntries.TemplateDeco) - { - switch (templateDeco.DeadAirFallbackMode) - { - case DecoMode.Override: - _logger.LogDebug("Dead air fallback will come from template deco (override)"); - return new CustomDeadAirFallback( - templateDeco.DeadAirFallbackCollectionType, - templateDeco.DeadAirFallbackCollectionId, - templateDeco.DeadAirFallbackMediaItemId, - templateDeco.DeadAirFallbackMultiCollectionId, - templateDeco.DeadAirFallbackSmartCollectionId); - case DecoMode.Disable: - _logger.LogDebug("Dead air fallback is disabled by template deco"); - return new DisableDeadAirFallback(); - case DecoMode.Inherit: - _logger.LogDebug("Dead air fallback will inherit from playout deco"); - break; - } - } - - // second, check playout deco - foreach (Deco playoutDeco in decoEntries.PlayoutDeco) - { - switch (playoutDeco.DeadAirFallbackMode) - { - case DecoMode.Override: - _logger.LogDebug("Dead air fallback will come from playout deco (override)"); - return new CustomDeadAirFallback( - playoutDeco.DeadAirFallbackCollectionType, - playoutDeco.DeadAirFallbackCollectionId, - playoutDeco.DeadAirFallbackMediaItemId, - playoutDeco.DeadAirFallbackMultiCollectionId, - playoutDeco.DeadAirFallbackSmartCollectionId); - case DecoMode.Disable: - _logger.LogDebug("Dead air fallback is disabled by playout deco"); - return new DisableDeadAirFallback(); - case DecoMode.Inherit: - _logger.LogDebug("Dead air fallback will inherit from channel and/or global setting"); - break; - } - } - - return new InheritDeadAirFallback(); - } - - private abstract record DeadAirFallbackResult; - - private sealed record InheritDeadAirFallback : DeadAirFallbackResult; - - private sealed record DisableDeadAirFallback : DeadAirFallbackResult; - - private sealed record CustomDeadAirFallback( - CollectionType CollectionType, - int? CollectionId, - int? MediaItemId, - int? MultiCollectionId, - int? SmartCollectionId) : DeadAirFallbackResult; } diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutItemConverter.cs b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutItemConverter.cs new file mode 100644 index 000000000..5792ea5fe --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutItemConverter.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Scheduling; + +public interface IPlayoutItemConverter +{ + Task> ToNext( + string channelNumber, + PlayoutItem playoutItem, + CancellationToken cancellationToken); + + Task> ToNext( + Option maybeChannel, + Option maybeGlobalWatermark, + TimeSpan playoutOffset, + PlayoutItem playoutItem, + CancellationToken cancellationToken); +} diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 0a2ef79ff..ed7709edc 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -105,6 +105,25 @@ namespace ErsatzTV.Core.Next /// /// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback /// HTTP. + /// + /// A placeholder source resolved at playback time by fetching a `PlayoutItem` JSON document + /// over HTTP(S). The returned item replaces this one; its `start` is forced to the current + /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because + /// `start` advances each tick, the resolver is re-hit while the transcode position remains + /// inside the placeholder window, allowing a sequence of distinct items to be returned for a + /// single placeholder. The resolved item's `source` may not itself be `dynamic`. + /// + /// The channel automatically injects the following request headers (in addition to any + /// configured via `headers`), all formatted per RFC 3339 for timestamps: + /// + /// - `X-Etv-Channel` — the channel number this request is for. + /// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to + /// coalesce or cache decisions across the multiple calls that occur while transcoding stays + /// inside one placeholder window. + /// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced + /// onto the resolved item. + /// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that + /// extends beyond this (it will be clamped if it does). /// public partial class Source { @@ -137,6 +156,9 @@ namespace ErsatzTV.Core.Next /// /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}} + /// expansion. /// [JsonProperty("headers")] public List Headers { get; set; } @@ -161,6 +183,8 @@ namespace ErsatzTV.Core.Next /// /// Socket timeout in microseconds. + /// + /// Request timeout in microseconds. Defaults to 10 seconds. /// [JsonProperty("timeout_us")] public long? TimeoutUs { get; set; } @@ -169,12 +193,18 @@ namespace ErsatzTV.Core.Next /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}". /// /// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream". + /// + /// URI template for the resolver endpoint, e.g. + /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON + /// `PlayoutItem`. /// [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] public string Uri { get; set; } /// /// Custom User-Agent string. + /// + /// Custom User-Agent string. Supports {{TEMPLATE}} expansion. /// [JsonProperty("user_agent")] public string UserAgent { get; set; } @@ -339,6 +369,25 @@ namespace ErsatzTV.Core.Next /// /// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback /// HTTP. + /// + /// A placeholder source resolved at playback time by fetching a `PlayoutItem` JSON document + /// over HTTP(S). The returned item replaces this one; its `start` is forced to the current + /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because + /// `start` advances each tick, the resolver is re-hit while the transcode position remains + /// inside the placeholder window, allowing a sequence of distinct items to be returned for a + /// single placeholder. The resolved item's `source` may not itself be `dynamic`. + /// + /// The channel automatically injects the following request headers (in addition to any + /// configured via `headers`), all formatted per RFC 3339 for timestamps: + /// + /// - `X-Etv-Channel` — the channel number this request is for. + /// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to + /// coalesce or cache decisions across the multiple calls that occur while transcoding stays + /// inside one placeholder window. + /// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced + /// onto the resolved item. + /// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that + /// extends beyond this (it will be clamped if it does). /// public partial class PlayoutItemSource { @@ -371,6 +420,9 @@ namespace ErsatzTV.Core.Next /// /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}} + /// expansion. /// [JsonProperty("headers")] public List Headers { get; set; } @@ -395,6 +447,8 @@ namespace ErsatzTV.Core.Next /// /// Socket timeout in microseconds. + /// + /// Request timeout in microseconds. Defaults to 10 seconds. /// [JsonProperty("timeout_us")] public long? TimeoutUs { get; set; } @@ -403,12 +457,18 @@ namespace ErsatzTV.Core.Next /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}". /// /// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream". + /// + /// URI template for the resolver endpoint, e.g. + /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON + /// `PlayoutItem`. /// [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] public string Uri { get; set; } /// /// Custom User-Agent string. + /// + /// Custom User-Agent string. Supports {{TEMPLATE}} expansion. /// [JsonProperty("user_agent")] public string UserAgent { get; set; } @@ -487,7 +547,7 @@ namespace ErsatzTV.Core.Next public TimingType TimingType { get; set; } } - public enum SourceType { Http, Lavfi, Local, Rtsp, Script }; + public enum SourceType { Dynamic, Http, Lavfi, Local, Rtsp, Script }; /// /// Anchor position within the primary content frame. @@ -518,7 +578,7 @@ namespace ErsatzTV.Core.Next public static string ToJson(this Playout self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings); } - internal static class Converter + public static class Converter { public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { @@ -546,6 +606,8 @@ namespace ErsatzTV.Core.Next var value = serializer.Deserialize(reader); switch (value) { + case "dynamic": + return SourceType.Dynamic; case "http": return SourceType.Http; case "lavfi": @@ -570,6 +632,9 @@ namespace ErsatzTV.Core.Next var value = (SourceType)untypedValue; switch (value) { + case SourceType.Dynamic: + serializer.Serialize(writer, "dynamic"); + return; case SourceType.Http: serializer.Serialize(writer, "http"); return; diff --git a/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItem.cs b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItem.cs new file mode 100644 index 000000000..fcf3ec1a4 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItem.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public sealed class DynamicPlayoutItem : PlayoutItem; diff --git a/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs new file mode 100644 index 000000000..5c6b1e696 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs @@ -0,0 +1,365 @@ +using System.IO.Abstractions; +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Domain.Scheduling; +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.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public class DynamicPlayoutItemService( + IFileSystem fileSystem, + IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository, + IArtistRepository artistRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + IDecoSelector decoSelector) : IDynamicPlayoutItemService +{ + private static readonly Random FallbackRandom = new(); + +#pragma warning disable CA1805 +#if DEBUG_NO_SYNC + private static readonly bool IsDebugNoSync = true; +#else + private static readonly bool IsDebugNoSync = false; +#endif +#pragma warning restore CA1805 + + public async Task> CheckForFallbackFiller( + TvContext dbContext, + Channel channel, + DateTimeOffset now, + CancellationToken cancellationToken) + { + Either result = new UnableToLocatePlayoutItem(); + + Option maybePlayout = await dbContext.Playouts + .AsNoTracking() + + // get playout deco + .Include(p => p.Deco) + .ThenInclude(d => d.DecoWatermarks) + .ThenInclude(d => d.Watermark) + .Include(p => p.Deco) + .ThenInclude(d => d.DecoGraphicsElements) + .ThenInclude(d => d.GraphicsElement) + + // get playout templates (and deco templates/decos) + .Include(p => p.Templates) + .ThenInclude(t => t.DecoTemplate) + .ThenInclude(t => t.Items) + .ThenInclude(i => i.Deco) + .ThenInclude(d => d.DecoWatermarks) + .ThenInclude(d => d.Watermark) + .SelectOneAsync( + p => p.ChannelId, + p => p.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id), + cancellationToken); + + foreach (Playout playout in maybePlayout) + { + result = await CheckForFallbackFiller( + dbContext, + channel, + playout, + now, + cancellationToken); + } + + if (maybePlayout.IsNone) + { + result = await CheckForFallbackFiller( + dbContext, + channel, + null, + now, + cancellationToken); + } + + return result; + } + + private async Task> CheckForFallbackFiller( + TvContext dbContext, + Channel channel, + Playout playout, + DateTimeOffset now, + CancellationToken cancellationToken) + { + Option maybeFallback = Option.None; + + DeadAirFallbackResult decoDeadAirFallback = GetDecoDeadAirFallback(playout, now); + switch (decoDeadAirFallback) + { + case CustomDeadAirFallback custom: + maybeFallback = new FillerPreset + { + // always allow watermarks here + // deco settings will disable watermarks if appropriate + AllowWatermarks = true, + + CollectionType = custom.CollectionType, + CollectionId = custom.CollectionId, + MediaItemId = custom.MediaItemId, + MultiCollectionId = custom.MultiCollectionId, + SmartCollectionId = custom.SmartCollectionId + }; + break; + case DisableDeadAirFallback: + // do nothing + break; + case InheritDeadAirFallback: + // check for channel fallback + maybeFallback = await dbContext.FillerPresets + .SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId, cancellationToken); + + // then check for global fallback + if (maybeFallback.IsNone) + { + maybeFallback = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken) + .BindT(fillerId => dbContext.FillerPresets.SelectOneAsync( + w => w.Id, + w => w.Id == fillerId, + cancellationToken)); + } + + break; + } + + + 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, + cancellationToken); + + // ignore the fallback filler preset if it has no items + if (items.Count == 0) + { + break; + } + + // get a random item + MediaItem item = items[FallbackRandom.Next(items.Count)]; + + Option maybeDuration = await dbContext.PlayoutItems + .Filter(pi => pi.Playout.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id)) + .Filter(pi => pi.Start > now.UtcDateTime) + .OrderBy(pi => pi.Start) + .FirstOrDefaultAsync(cancellationToken) + .Map(Optional) + .MapT(pi => pi.StartOffset - now); + + MediaVersion version = item.GetHeadVersion(); + + version.MediaFiles = await dbContext.MediaFiles + .AsNoTracking() + .Filter(mf => mf.MediaVersionId == version.Id) + .ToListAsync(cancellationToken); + + version.Streams = await dbContext.MediaStreams + .AsNoTracking() + .Filter(ms => ms.MediaVersionId == version.Id) + .ToListAsync(cancellationToken); + + // always play min(duration to next item, version.Duration) + TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration); + if (version.Duration < duration) + { + duration = version.Duration; + } + + DateTimeOffset finish = now.Add(duration); + + var playoutItem = new PlayoutItem + { + MediaItem = item, + MediaItemId = item.Id, + Start = now.UtcDateTime, + Finish = finish.UtcDateTime, + FillerKind = FillerKind.Fallback, + InPoint = TimeSpan.Zero, + OutPoint = duration, + DisableWatermarks = !fallbackPreset.AllowWatermarks, + Watermarks = [], + PlayoutItemWatermarks = [], + GraphicsElements = [], + PlayoutItemGraphicsElements = [] + }; + + return await ValidatePlayoutItemPath(dbContext, playoutItem, cancellationToken); + } + + return new UnableToLocatePlayoutItem(); + } + + public async Task> ValidatePlayoutItemPath( + TvContext dbContext, + PlayoutItem playoutItem, + CancellationToken cancellationToken) + { + string path = await playoutItem.MediaItem.GetLocalPath( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + cancellationToken); + + if (IsDebugNoSync) + { + // pretend it exists so we get a nice error message + return new PlayoutItemWithPath(playoutItem, path); + } + + // check filesystem first + if (fileSystem.File.Exists(path)) + { + if (playoutItem.MediaItem is RemoteStream remoteStream) + { + path = !string.IsNullOrWhiteSpace(remoteStream.Url) + ? remoteStream.Url + : $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; + } + + return new PlayoutItemWithPath(playoutItem, path); + } + + // attempt to remotely stream plex + MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); + switch (file) + { + case PlexMediaFile pmf: + Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( + @"SELECT PMS.Id FROM PlexMediaSource PMS + INNER JOIN Library L on PMS.Id = L.MediaSourceId + INNER JOIN LibraryPath LP on L.Id = LP.LibraryId + WHERE LP.Id = @LibraryPathId", + new { playoutItem.MediaItem.LibraryPathId }) + .Map(Optional); + + foreach (int plexMediaSourceId in maybeId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); + } + + break; + } + + // attempt to remotely stream jellyfin + Option jellyfinItemId = playoutItem.MediaItem switch + { + JellyfinEpisode e => e.ItemId, + JellyfinMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in jellyfinItemId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}"); + } + + // attempt to remotely stream emby + Option embyItemId = playoutItem.MediaItem switch + { + EmbyEpisode e => e.ItemId, + EmbyMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in embyItemId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}"); + } + + return new PlayoutItemDoesNotExistOnDisk(path); + } + + private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) + { + DecoEntries decoEntries = decoSelector.GetDecoEntries(playout, now); + + // first, check deco template / active deco + foreach (Deco templateDeco in decoEntries.TemplateDeco) + { + switch (templateDeco.DeadAirFallbackMode) + { + case DecoMode.Override: + //_logger.LogDebug("Dead air fallback will come from template deco (override)"); + return new CustomDeadAirFallback( + templateDeco.DeadAirFallbackCollectionType, + templateDeco.DeadAirFallbackCollectionId, + templateDeco.DeadAirFallbackMediaItemId, + templateDeco.DeadAirFallbackMultiCollectionId, + templateDeco.DeadAirFallbackSmartCollectionId); + case DecoMode.Disable: + //_logger.LogDebug("Dead air fallback is disabled by template deco"); + return new DisableDeadAirFallback(); + case DecoMode.Inherit: + //_logger.LogDebug("Dead air fallback will inherit from playout deco"); + break; + } + } + + // second, check playout deco + foreach (Deco playoutDeco in decoEntries.PlayoutDeco) + { + switch (playoutDeco.DeadAirFallbackMode) + { + case DecoMode.Override: + //_logger.LogDebug("Dead air fallback will come from playout deco (override)"); + return new CustomDeadAirFallback( + playoutDeco.DeadAirFallbackCollectionType, + playoutDeco.DeadAirFallbackCollectionId, + playoutDeco.DeadAirFallbackMediaItemId, + playoutDeco.DeadAirFallbackMultiCollectionId, + playoutDeco.DeadAirFallbackSmartCollectionId); + case DecoMode.Disable: + //_logger.LogDebug("Dead air fallback is disabled by playout deco"); + return new DisableDeadAirFallback(); + case DecoMode.Inherit: + //_logger.LogDebug("Dead air fallback will inherit from channel and/or global setting"); + break; + } + } + + return new InheritDeadAirFallback(); + } + + private abstract record DeadAirFallbackResult; + + private sealed record InheritDeadAirFallback : DeadAirFallbackResult; + + private sealed record DisableDeadAirFallback : DeadAirFallbackResult; + + private sealed record CustomDeadAirFallback( + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId) : DeadAirFallbackResult; +} diff --git a/ErsatzTV.Infrastructure/Scheduling/IDynamicPlayoutItemService.cs b/ErsatzTV.Infrastructure/Scheduling/IDynamicPlayoutItemService.cs new file mode 100644 index 000000000..332086cf5 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/IDynamicPlayoutItemService.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public interface IDynamicPlayoutItemService +{ + Task> CheckForFallbackFiller( + TvContext dbContext, + Channel channel, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task> ValidatePlayoutItemPath( + TvContext dbContext, + PlayoutItem playoutItem, + CancellationToken cancellationToken); +} diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs new file mode 100644 index 000000000..1d7c429ba --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -0,0 +1,541 @@ +using System.Collections.Immutable; +using System.CommandLine.Parsing; +using System.Globalization; +using System.IO.Abstractions; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.FFmpeg.State; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public class PlayoutItemConverter( + IFileSystem fileSystem, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + ICustomStreamSelector customStreamSelector, + IFFmpegStreamSelector ffmpegStreamSelector, + IWatermarkSelector watermarkSelector, + IDbContextFactory dbContextFactory) : IPlayoutItemConverter +{ + public async Task> ToNext( + string channelNumber, + PlayoutItem playoutItem, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + TimeSpan playoutOffset = TimeSpan.Zero; + + Option maybeChannel = await dbContext.Channels + .AsNoTracking() + .Include(c => c.MirrorSourceChannel) + .Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null) + .SelectOneAsync( + c => c.Number == channelNumber, + c => c.Number == channelNumber, + cancellationToken); + foreach (Channel channel in maybeChannel) + { + playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; + } + + Option maybeGlobalWatermark = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) + .BindT(watermarkId => dbContext.ChannelWatermarks + .SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken)); + + Option maybeChannelForArtwork = await dbContext.Channels + .AsNoTracking() + .Include(c => c.Watermark) + .Include(c => c.Artwork) + .SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken) + .Map(Optional); + + return await ToNext( + maybeChannelForArtwork, + maybeGlobalWatermark, + playoutOffset, + playoutItem, + cancellationToken); + } + + public async Task> ToNext( + Option maybeChannel, + Option maybeGlobalWatermark, + TimeSpan playoutOffset, + PlayoutItem playoutItem, + CancellationToken cancellationToken) + { + if (playoutItem is not DynamicPlayoutItem && + playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && + playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo && + playoutItem.MediaItem is not RemoteStream && playoutItem.MediaItem is not Image) + { + return Option.None; + } + + playoutItem.Start += playoutOffset; + playoutItem.Finish += playoutOffset; + + var nextPlayoutItem = new Core.Next.PlayoutItem + { + Id = playoutItem is DynamicPlayoutItem ? Guid.NewGuid().ToString() : playoutItem.Id.ToString(CultureInfo.InvariantCulture), + Start = playoutItem.StartOffset, + Finish = playoutItem.FinishOffset + }; + + Option maybeSource = await SourceForItem(playoutItem, cancellationToken); + if (maybeSource.IsNone) + { + return Option.None; + } + + foreach (Core.Next.Source source in maybeSource) + { + if (playoutItem is not DynamicPlayoutItem) + { + if (playoutItem.InPoint > TimeSpan.Zero) + { + source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; + } + + var duration = playoutItem.MediaItem.GetDurationForPlayout(); + if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) + { + source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; + } + } + + nextPlayoutItem.Source = source; + } + + if (playoutItem is not DynamicPlayoutItem) + { + // if no audio streams, use lavfi to insert silence + MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); + if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) + { + var videoSource = nextPlayoutItem.Source; + + nextPlayoutItem.Source = null; + nextPlayoutItem.Tracks = new Core.Next.PlayoutItemTracks + { + Audio = new Core.Next.TrackSelection + { + Source = + new Core.Next.Source + { + SourceType = Core.Next.SourceType.Lavfi, + Params = "anullsrc=channel_layout=stereo:sample_rate=48000" + } + }, + Video = new Core.Next.TrackSelection + { + Source = videoSource + } + }; + } + + foreach (Channel channel in maybeChannel) + { + var audioVersion = new MediaItemAudioVersion(playoutItem.MediaItem, headVersion); + await SelectTracks( + channel, + playoutItem, + audioVersion, + nextPlayoutItem, + playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, + playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle, + playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, + playoutItem.SubtitleMode ?? channel.SubtitleMode, + cancellationToken); + await SelectWatermark( + maybeGlobalWatermark, + channel, + playoutItem, + nextPlayoutItem); + } + } + + return nextPlayoutItem; + } + + private async Task> SourceForItem( + PlayoutItem playoutItem, + CancellationToken cancellationToken) + { + if (playoutItem is DynamicPlayoutItem) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Dynamic, + Uri = $"http://localhost:{Settings.StreamingPort}/media/fallback" + }; + } + + if (playoutItem.MediaItem is RemoteStream remoteStream) + { + if (!string.IsNullOrWhiteSpace(remoteStream.Url)) + { + if (remoteStream.Url.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase)) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Rtsp, + Uri = remoteStream.Url + }; + } + + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = remoteStream.Url, + IsLive = remoteStream.IsLive, + KeepAlive = true, + Reconnect = true + }; + } + + if (!string.IsNullOrWhiteSpace(remoteStream.Script)) + { + var split = CommandLineParser.SplitCommandLine(remoteStream.Script).ToList(); + if (split.Count > 0) + { + var source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Script, + Command = split.Head(), + IsLive = remoteStream.IsLive + }; + + if (split.Count > 1) + { + source.Args = split.Tail().ToList(); + } + + return source; + } + } + + return Option.None; + } + + string path = await playoutItem.MediaItem.GetLocalPath( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + cancellationToken, + log: false); + + // check filesystem first + if (fileSystem.File.Exists(path)) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Local, + Path = path, + }; + } + + MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); + int mediaSourceId = playoutItem.MediaItem.LibraryPath.Library.MediaSourceId; + if (file is PlexMediaFile pmf) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/plex/{mediaSourceId}/{pmf.Key}", + KeepAlive = false, + Reconnect = true + }; + } + + Option jellyfinItemId = playoutItem.MediaItem switch + { + JellyfinEpisode e => e.ItemId, + JellyfinMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in jellyfinItemId) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}", + KeepAlive = false, + Reconnect = true + }; + } + + // attempt to remotely stream emby + Option embyItemId = playoutItem.MediaItem switch + { + EmbyEpisode e => e.ItemId, + EmbyMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in embyItemId) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}", + KeepAlive = false, + Reconnect = true + }; + } + + return Option.None; + } + + private async Task SelectTracks( + Channel channel, + PlayoutItem playoutItem, + MediaItemAudioVersion audioVersion, + Core.Next.PlayoutItem nextPlayoutItem, + string preferredAudioLanguage, + string preferredAudioTitle, + string preferredSubtitleLanguage, + ChannelSubtitleMode subtitleMode, + CancellationToken cancellationToken) + { + List allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); + + Option maybeAudioStream = Option.None; + Option maybeSubtitle = Option.None; + + if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) + { + StreamSelectorResult result = await customStreamSelector.SelectStreams( + channel, + nextPlayoutItem.Start, + audioVersion, + allSubtitles); + maybeAudioStream = result.AudioStream; + maybeSubtitle = result.Subtitle; + } + + if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) + { + maybeAudioStream = + await ffmpegStreamSelector.SelectAudioStream( + audioVersion, + channel.StreamingMode, + channel, + preferredAudioLanguage, + preferredAudioTitle, + shouldLogMessages: false, + cancellationToken); + + maybeSubtitle = + await ffmpegStreamSelector.SelectSubtitleStream( + allSubtitles.ToImmutableList(), + channel, + preferredSubtitleLanguage, + subtitleMode, + shouldLogMessages: false, + cancellationToken); + } + + foreach (MediaStream audioStream in maybeAudioStream) + { + if (nextPlayoutItem.Tracks?.Audio?.StreamIndex is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Audio ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Audio.StreamIndex = audioStream.Index; + } + } + + foreach (Subtitle subtitle in maybeSubtitle) + { + if (subtitle.SubtitleKind is SubtitleKind.Embedded) + { + if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; + } + } + else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Local, + Path = subtitle.Path, + }; + } + } + else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) + { + if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = subtitle.Path, + KeepAlive = false, + Reconnect = true + }; + } + } + } + } + + private async Task SelectWatermark( + Option maybeGlobalWatermark, + Channel channel, + PlayoutItem playoutItem, + Core.Next.PlayoutItem nextPlayoutItem) + { + List watermarks = watermarkSelector.SelectWatermarks( + maybeGlobalWatermark, + channel, + playoutItem, + playoutItem.StartOffset, + shouldLogMessages: false); + + // single, permanent or intermittent watermarks are supported + if (watermarks.Count == 1 && watermarks.All(wm => + wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) + { + foreach (WatermarkOptions watermarkOptions in watermarks) + { + if (nextPlayoutItem.Watermark is null) + { + Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch + { + WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter, + WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight, + WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft, + WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center, + WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight, + WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft, + WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter, + WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight, + _ => Core.Next.WatermarkLocation.TopLeft, + }; + + nextPlayoutItem.Watermark = new Core.Next.Watermark + { + Location = location, + HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, + VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, + OpacityPercent = watermarkOptions.Watermark.Opacity, + StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), + WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent, + }; + + if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) + { + nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent; + } + + if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource + { + SourceType = Core.Next.SourceType.Http, + Uri = watermarkOptions.ImagePath, + }; + } + else + { + nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource + { + SourceType = Core.Next.SourceType.Local, + Path = watermarkOptions.ImagePath, + }; + } + + if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) + { + nextPlayoutItem.Watermark.Timing = new Core.Next.Timing + { + TimingType = Core.Next.TimingType.Periodic, + Clock = Core.Next.PeriodicClock.Wall, + FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, + HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, + }; + } + } + } + } + } + + private static async Task> GetSubtitles( + MediaItem mediaItem, + int playoutItemId, + TimeSpan playoutItemInPoint) + { + List allSubtitles = mediaItem switch + { + Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() + .Map(mm => mm.Subtitles ?? []) + .IfNoneAsync([]), + Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() + .Map(mm => mm.Subtitles ?? []) + .IfNoneAsync([]), + MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), + OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() + .Map(mm => mm.Subtitles ?? []) + .IfNoneAsync([]), + _ => [] + }; + + bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or + JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; + + if (isMediaServer) + { + // closed captions are currently unsupported + allSubtitles.RemoveAll(s => s.Codec == "eia_608"); + } + + // TODO: external image subtitles + allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded); + + return allSubtitles; + } + + private static List GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) + { + string seekToMs = playoutItemInPoint > TimeSpan.Zero + ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" + : string.Empty; + + return + [ + new Subtitle + { + Codec = "ass", + Default = true, + Forced = true, + IsExtracted = false, + SubtitleKind = SubtitleKind.Generated, + Path = $"http://localhost:{Settings.StreamingPort}/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", + SDH = false + } + ]; + } +} diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 18927814f..a8f3134ad 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -11,12 +11,18 @@ using ErsatzTV.Application.Subtitles.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Extensions; using ErsatzTV.FFmpeg; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Scheduling; using Flurl; using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; namespace ErsatzTV.Controllers; @@ -26,14 +32,23 @@ public class InternalController : StreamingControllerBase { private readonly ILogger _logger; private readonly IMediator _mediator; + private readonly IDbContextFactory _dbContextFactory; + private readonly IDynamicPlayoutItemService _dynamicPlayoutItemService; + private readonly IPlayoutItemConverter _playoutItemConverter; public InternalController( IGraphicsEngine graphicsEngine, IMediator mediator, + IDbContextFactory dbContextFactory, + IDynamicPlayoutItemService dynamicPlayoutItemService, + IPlayoutItemConverter playoutItemConverter, ILogger logger) : base(graphicsEngine, logger) { _mediator = mediator; + _dbContextFactory = dbContextFactory; + _dynamicPlayoutItemService = dynamicPlayoutItemService; + _playoutItemConverter = playoutItemConverter; _logger = logger; } @@ -267,6 +282,54 @@ public class InternalController : StreamingControllerBase return new NotFoundResult(); } + [HttpGet("/media/fallback")] + public async Task GetFallbackPlayoutJson(CancellationToken cancellationToken) + { + if (!Request.Headers.TryGetValue("x-etv-channel", out StringValues channelNumber) || channelNumber.Count != 1) + { + return BadRequest(); + } + + if (!Request.Headers.TryGetValue("x-etv-now", out StringValues nowString) || nowString.Count != 1 || + !DateTimeOffset.TryParse(nowString[0], out DateTimeOffset now)) + { + return BadRequest(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + Option maybeChannel = await dbContext.Channels + .SingleOrDefaultAsync(c => c.Number == channelNumber[0], cancellationToken) + .Map(Optional); + + foreach (var channel in maybeChannel) + { + Either maybePlayoutItem = + await _dynamicPlayoutItemService.CheckForFallbackFiller( + dbContext, + channel, + now, + cancellationToken); + + foreach (var itemWithPath in maybePlayoutItem.RightToSeq()) + { + Option maybeNextPlayoutItem = await _playoutItemConverter.ToNext( + channelNumber[0], + itemWithPath.PlayoutItem, + cancellationToken); + + foreach (Core.Next.PlayoutItem nextPlayoutItem in maybeNextPlayoutItem) + { + return Content( + JsonConvert.SerializeObject(nextPlayoutItem, Core.Next.Converter.Settings), + "application/json"); + } + } + + } + + return NotFound(); + } + private async Task GetTsLegacyStream(string channelNumber) { var request = new GetPlayoutItemProcessByChannelNumber( diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 4ace6cb47..ed8fec951 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -824,6 +824,8 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();