diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db8018ce..898befc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Optimize QSV pipelines by merging consecutive vpp_qsv filters as much as possible (e.g. tonemap, scale and format using a single filter) ### Fixed +- Next engine: + - Fix many issues with subtitles that caused black/silence (error output from next) - Fix health checks causing a flood of (harmless) logged errors when quickly navigating away from home page - Health check results will now be cached for 5 minutes by default; a refresh button has been added to immediately re-run all checks - Fix `/api/sessions` response when channels use Next streaming engine - Save and restore sequential schedule mid-roll, post-roll, and graphics state between builds +- Fix subtitle playback with Plex other video libraries ## [26.9.0] - 2026-09-06 ### Fixed diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index a2a16e47f..ba742fa8a 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -444,7 +444,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< Option maybeDuration = maybeNextStart.Map(s => s - now); // limit working ahead on errors to 1 minute - if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1)) + if (!request.HlsRealtime && + await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1)) { maybeNextStart = now.AddMinutes(1); maybeDuration = TimeSpan.FromMinutes(1); @@ -569,8 +570,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< _ => [] }; - bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or - JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; + bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or PlexOtherVideo + or JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; if (isMediaServer) { diff --git a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs index e0cf5f857..dda4aac06 100644 --- a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs +++ b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs @@ -82,6 +82,21 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa .Map(Optional); } + // check for plex other video + if (maybePlexId.IsNone) + { + maybePlexId = 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 + inner join MediaItem MI on LP.Id = MI.LibraryPathId + inner join OtherVideoMetadata OVM on OVM.OtherVideoId = MI.Id + inner join Subtitle S on OVM.Id = S.OtherVideoMetadataId + where S.Id = @SubtitleId", + new { SubtitleId = subtitleId }) + .Map(Optional); + } + foreach (int plexMediaSourceId in maybePlexId) { foreach (string subtitlePath in maybeSubtitle.Map(s => s.Path)) diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 8816f85ce..179548aaf 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -542,7 +542,7 @@ public class PrepareTroubleshootingPlaybackHandler( _ => [] }; - bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or + bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or PlexOtherVideo or JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; if (isMediaServer) diff --git a/ErsatzTV.Infrastructure.Tests/Scheduling/PlayoutItemConverterTests.cs b/ErsatzTV.Infrastructure.Tests/Scheduling/PlayoutItemConverterTests.cs new file mode 100644 index 000000000..a3e955db3 --- /dev/null +++ b/ErsatzTV.Infrastructure.Tests/Scheduling/PlayoutItemConverterTests.cs @@ -0,0 +1,297 @@ +using System.Collections.Immutable; +using System.Text.Json; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +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.Security; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Scheduling; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using static LanguageExt.Prelude; +using Next = ErsatzTV.Core.Next; + +namespace ErsatzTV.Infrastructure.Tests.Scheduling; + +[TestFixture] +public class PlayoutItemConverterTests +{ + private const string VideoPath = "/media/movie.mkv"; + private MockFileSystem _fileSystem = null!; + private PlayoutItemConverter _converter = null!; + private Channel _channel = null!; + private DateTimeOffset _start; + + [SetUp] + public void SetUp() + { + _start = DateTimeOffset.UtcNow.AddDays(1); + _fileSystem = new MockFileSystem(); + _fileSystem.Directory.CreateDirectory("/media"); + _fileSystem.File.WriteAllText(VideoPath, string.Empty); + var plex = Substitute.For(); + plex.GetReplacementPlexPath(default, default!, default, default).ReturnsForAnyArgs(VideoPath); + var jellyfin = Substitute.For(); + jellyfin.GetReplacementJellyfinPath(default, default!, default, default).ReturnsForAnyArgs(VideoPath); + var emby = Substitute.For(); + emby.GetReplacementEmbyPath(default, default!, default, default).ReturnsForAnyArgs(VideoPath); + + var selector = Substitute.For(); + selector.SelectSubtitleStream(default!, default!, default!, default, default, default) + .ReturnsForAnyArgs(call => Task.FromResult( + call.Arg>().HeadOrNone())); + var watermarks = Substitute.For(); + watermarks.SelectWatermarks(default, default!, default!, default, default).ReturnsForAnyArgs([]); + var graphics = Substitute.For(); + graphics.SelectGraphicsElements(default!, default!, default, default).ReturnsForAnyArgs([]); + _converter = new PlayoutItemConverter( + _fileSystem, plex, jellyfin, emby, + Substitute.For(), selector, watermarks, graphics, + Substitute.For>(), + Substitute.For>()); + _channel = new Channel(Guid.NewGuid()) { StreamSelectorMode = ChannelStreamSelectorMode.Default }; + } + + [TestCase("jellyfin", null)] + [TestCase("plex", "/library/streams/123")] + [TestCase("emby", "media-source-id")] + [TestCase("jellyfin-episode", null)] + [TestCase("plex-episode", "/library/streams/123")] + [TestCase("emby-episode", "media-source-id")] + public async Task Server_sidecars_use_signed_subtitle_proxy(string server, string? path) + { + MediaItem movie = server switch + { + "jellyfin" => new JellyfinMovie(), + "plex" => new PlexMovie(), + "jellyfin-episode" => new JellyfinEpisode(), + "plex-episode" => new PlexEpisode(), + "emby-episode" => new EmbyEpisode(), + _ => new EmbyMovie() + }; + var subtitle = new Subtitle + { + Id = 9873, SubtitleKind = SubtitleKind.Sidecar, Codec = "subrip", + StreamIndex = 100000, Path = path! + }; + + Next.PlayoutItem result = await Convert(movie, [subtitle]); + Next.Source source = result.Tracks!.Subtitle!.Source!; + source.SourceType.ShouldBe(Next.SourceType.Http); + source.Path.ShouldBeNull(); + var uri = new Uri(source.Uri!); + uri.AbsolutePath.ShouldBe("/internal/media/subtitle/9873"); + var query = uri.Query.TrimStart('?').Split('&') + .Select(part => part.Split('=', 2)).ToDictionary(part => part[0], part => part[1]); + InternalUrlSigner.Verify(query["exp"], query["sig"], "subtitle", "9873").ShouldBeTrue(); + long.Parse(query["exp"]).ShouldBe(_start.AddSeconds(30).AddHours(2).ToUnixTimeSeconds()); + query.ContainsKey("seekToMs").ShouldBeFalse(); + AssertTiming(source); + subtitle.Path.ShouldBe(path); + + using JsonDocument json = JsonDocument.Parse(Next.Serialize.ToJson(new Next.Playout + { + Version = "https://ersatztv.org/playout/version/0.0.4", Items = [result] + })); + JsonElement serialized = json.RootElement.GetProperty("items")[0] + .GetProperty("tracks").GetProperty("subtitle").GetProperty("source"); + serialized.GetProperty("source_type").GetString().ShouldBe("http"); + serialized.GetProperty("uri").GetString().ShouldBe(source.Uri); + } + + [Test] + public async Task Persisted_local_sidecar_uses_proxy_after_checking_file_exists() + { + _fileSystem.File.WriteAllText("/media/movie.srt", string.Empty); + Next.PlayoutItem result = await Convert(new Movie(), + [new Subtitle { Id = 123, SubtitleKind = SubtitleKind.Sidecar, Path = "/media/movie.srt" }]); + Next.Source source = result.Tracks!.Subtitle!.Source!; + source.SourceType.ShouldBe(Next.SourceType.Http); + new Uri(source.Uri!).AbsolutePath.ShouldBe("/internal/media/subtitle/123"); + AssertTiming(source); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("/media/missing.srt")] + public async Task Unavailable_local_sidecars_do_not_emit_subtitle_tracks(string? path) + { + var subtitle = new Subtitle { Id = 123, SubtitleKind = SubtitleKind.Sidecar, Path = path! }; + Next.PlayoutItem result = await Convert(new Movie(), [subtitle]); + result.Tracks?.Subtitle.ShouldBeNull(); + } + + [TestCase("jellyfin", 0, null)] + [TestCase("plex", 123, null)] + [TestCase("emby", 123, "")] + public async Task Unresolvable_server_sidecars_do_not_emit_subtitle_tracks(string server, int id, string? path) + { + Movie movie = server switch + { + "jellyfin" => new JellyfinMovie(), + "plex" => new PlexMovie(), + _ => new EmbyMovie() + }; + Next.PlayoutItem result = await Convert(movie, + [new Subtitle { Id = id, SubtitleKind = SubtitleKind.Sidecar, Path = path! }]); + result.Tracks?.Subtitle.ShouldBeNull(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Extracted_subtitle_requires_existing_cache_file(bool exists) + { + string path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, "movie.srt"); + if (exists) + { + _fileSystem.Directory.CreateDirectory(FileSystemLayout.SubtitleCacheFolder); + _fileSystem.File.WriteAllText(path, string.Empty); + } + + Next.PlayoutItem result = await Convert(new Movie(), + [new Subtitle { SubtitleKind = SubtitleKind.Embedded, IsExtracted = true, Path = "movie.srt" }]); + if (exists) + { + Next.Source source = result.Tracks!.Subtitle!.Source!; + source.Path.ShouldBe(path); + source.SourceType.ShouldBe(Next.SourceType.Local); + AssertTiming(source); + } + else + { + result.Tracks?.Subtitle.ShouldBeNull(); + } + } + + [TestCase("http://localhost:8409/subtitle.srt")] + [TestCase("http://127.0.0.1:8409/subtitle.srt")] + [TestCase("https://example.com/subtitle.srt")] + public async Task Remote_subtitles_preserve_uri_and_media_timing(string uri) + { + Next.PlayoutItem result = await Convert(new Movie(), + [new Subtitle { SubtitleKind = SubtitleKind.Sidecar, Path = uri }]); + Next.Source source = result.Tracks!.Subtitle!.Source!; + source.SourceType.ShouldBe(Next.SourceType.Http); + source.Uri.ShouldBe(uri); + AssertTiming(source); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Embedded_image_subtitle_has_effective_video_source(bool hasAudio) + { + Next.PlayoutItem result = await Convert(new Movie(), + [new Subtitle { SubtitleKind = SubtitleKind.Embedded, Codec = "hdmv_pgs_subtitle", StreamIndex = 4 }], + hasAudio); + Next.TrackSelection track = result.Tracks!.Subtitle!; + track.StreamIndex.ShouldBe(4); + Next.Source source = (track.Source ?? result.Source)!; + source.ShouldNotBeNull(); + source.Path.ShouldBe(VideoPath); + AssertTiming(source); + if (!hasAudio) + { + result.Source.ShouldBeNull(); + track.Source.ShouldBeSameAs(result.Tracks.Video!.Source); + } + } + + [Test] + public async Task Music_video_uses_metadata_subtitles_when_credits_are_disabled() + { + _fileSystem.File.WriteAllText("/media/music.srt", string.Empty); + Next.PlayoutItem result = await Convert(new MusicVideo(), + [new Subtitle { SubtitleKind = SubtitleKind.Sidecar, Path = "/media/music.srt" }]); + result.Tracks!.Subtitle!.Source!.Path.ShouldBe("/media/music.srt"); + } + + [Test] + public async Task Generated_credits_do_not_apply_in_point_twice() + { + _channel.MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.GenerateSubtitles; + Next.PlayoutItem result = await Convert(new MusicVideo(), []); + Next.Source source = result.Tracks!.Subtitle!.Source!; + source.SourceType.ShouldBe(Next.SourceType.Http); + source.Uri.ShouldEndWith("/internal/ffmpeg/music-video-credits/42?seekToMs=10000"); + source.InPointMs.ShouldBeNull(); + source.OutPointMs.ShouldBeNull(); + } + + [Test] + public async Task Filtering_does_not_mutate_metadata_or_supplied_subtitles() + { + List subtitles = + [ + new() { SubtitleKind = SubtitleKind.Embedded, Codec = "eia_608" }, + new() { SubtitleKind = SubtitleKind.Sidecar, Codec = "dvd_subtitle" } + ]; + await Convert(new JellyfinMovie(), subtitles); + subtitles.Count.ShouldBe(2); + await Convert(new JellyfinMovie(), subtitles, suppliedSubtitles: Some(subtitles)); + subtitles.Count.ShouldBe(2); + } + + private async Task Convert( + MediaItem mediaItem, + List subtitles, + bool hasAudio = true, + Option> suppliedSubtitles = default) + { + var version = new MediaVersion + { + Duration = TimeSpan.FromMinutes(2), + MediaFiles = [new MediaFile { Path = VideoPath }], + Streams = [new MediaStream { MediaStreamKind = MediaStreamKind.Video, Index = 0 }] + }; + if (hasAudio) + { + version.Streams.Add(new MediaStream { MediaStreamKind = MediaStreamKind.Audio, Index = 1 }); + } + + switch (mediaItem) + { + case Episode episode: + episode.MediaVersions = [version]; + episode.EpisodeMetadata = [new EpisodeMetadata { Subtitles = subtitles }]; + break; + case Movie movie: + movie.MediaVersions = [version]; + movie.MovieMetadata = [new MovieMetadata { Subtitles = subtitles }]; + break; + case MusicVideo musicVideo: + musicVideo.MediaVersions = [version]; + musicVideo.MusicVideoMetadata = [new MusicVideoMetadata { Subtitles = subtitles }]; + break; + } + + var playoutItem = new PlayoutItem + { + Id = 42, + MediaItem = mediaItem, + Start = _start.UtcDateTime, + Finish = _start.AddSeconds(30).UtcDateTime, + InPoint = TimeSpan.FromSeconds(10), + OutPoint = TimeSpan.FromSeconds(40) + }; + Option result = await _converter.ToNext( + Some(_channel), None, TimeSpan.Zero, playoutItem, suppliedSubtitles, true, CancellationToken.None); + result.IsSome.ShouldBeTrue(); + return result.Match(item => item, () => throw new InvalidOperationException("No playout item")); + } + + private static void AssertTiming(Next.Source source) + { + source.InPointMs.ShouldBe(10000); + source.OutPointMs.ShouldBe(40000); + } +} diff --git a/ErsatzTV.Infrastructure/Extensions/PlayoutItemQueryableExtensions.cs b/ErsatzTV.Infrastructure/Extensions/PlayoutItemQueryableExtensions.cs index 65e432224..4bfc97ae0 100644 --- a/ErsatzTV.Infrastructure/Extensions/PlayoutItemQueryableExtensions.cs +++ b/ErsatzTV.Infrastructure/Extensions/PlayoutItemQueryableExtensions.cs @@ -92,6 +92,9 @@ public static class PlayoutItemQueryableExtensions .ThenInclude(i => (i as MusicVideo).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Subtitles) + .Include(i => i.MediaItem) .ThenInclude(i => (i as RemoteStream).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index b3826ef00..5bf82c5d2 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -15,6 +15,7 @@ using ErsatzTV.Core.Security; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using MediaStream = ErsatzTV.Core.Domain.MediaStream; using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; @@ -29,7 +30,8 @@ public class PlayoutItemConverter( IFFmpegStreamSelector ffmpegStreamSelector, IWatermarkSelector watermarkSelector, IGraphicsElementSelector graphicsElementSelector, - IDbContextFactory dbContextFactory) : IPlayoutItemConverter + IDbContextFactory dbContextFactory, + ILogger logger) : IPlayoutItemConverter { public async Task> ToNext( string channelNumber, @@ -400,8 +402,8 @@ public class PlayoutItemConverter( bool shouldLogMessages, CancellationToken cancellationToken) { - List allSubtitles = await subtitles.IfNoneAsync( - await GetSubtitles(channel, audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint)); + List allSubtitles = (await subtitles.IfNoneAsync( + () => GetSubtitles(channel, audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint))).ToList(); // TODO: external image subtitles allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded); @@ -455,65 +457,107 @@ public class PlayoutItemConverter( foreach (Subtitle subtitle in maybeSubtitle) { - if (subtitle.SubtitleKind is SubtitleKind.Embedded) + if (subtitle.SubtitleKind is SubtitleKind.Embedded && subtitle.IsImage) { - if (subtitle.IsImage) - { - 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; - } - } - // next only supports sidecar text subtitles at the moment; ignore non-extracted text subs - else if (subtitle.IsExtracted && !string.IsNullOrWhiteSpace(subtitle.Path)) + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle = new Core.Next.TrackSelection { - 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 = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path), - }; - - SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); - } - } + StreamIndex = subtitle.StreamIndex, + // Without audio, the video source is on tracks.video rather than the item. + Source = nextPlayoutItem.Source is null ? nextPlayoutItem.Tracks.Video?.Source : null + }; + continue; } - else if (!IsRemoteUri(subtitle.Path)) + + foreach (Core.Next.Source source in SubtitleSource(playoutItem, subtitle, shouldLogMessages)) { - 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, - }; + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle = new Core.Next.TrackSelection { Source = source }; + } + } + } - SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); - } + private Option SubtitleSource( + PlayoutItem playoutItem, + Subtitle subtitle, + bool shouldLogMessages) + { + string path = subtitle.Path; + bool isMediaServer = playoutItem.MediaItem is PlexMovie or PlexEpisode or PlexOtherVideo or + JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; + bool isServerSidecar = isMediaServer && subtitle.SubtitleKind is SubtitleKind.Sidecar; + + if (subtitle.SubtitleKind is SubtitleKind.Embedded) + { + // burned text subtitles must have been extracted to a sidecar file + if (!subtitle.IsExtracted || string.IsNullOrWhiteSpace(path)) + { + return None; } - else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) + + path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, path); + } + + // jellyfin sidecars have no path; plex and emby store item ids + bool isJellyfin = playoutItem.MediaItem is JellyfinMovie or JellyfinEpisode; + bool isRemote = IsRemoteUri(path); + bool isAvailable = isServerSidecar + ? subtitle.Id > 0 && (isJellyfin || !string.IsNullOrWhiteSpace(path)) + : !string.IsNullOrWhiteSpace(path) && (isRemote || fileSystem.File.Exists(path)); + if (!isAvailable) + { + if (shouldLogMessages) { - 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 - }; - } + logger.LogWarning( + "Ignoring unavailable subtitle {SubtitleId} for media item {MediaItemId}", + subtitle.Id, + playoutItem.MediaItemId); } + + return None; + } + + Core.Next.Source source; + if (subtitle.SubtitleKind is SubtitleKind.Sidecar && subtitle.Id > 0) + { + DateTimeOffset exp = playoutItem.FinishOffset + TimeSpan.FromHours(2); + string sig = InternalUrlSigner.Sign(exp, "subtitle", $"{subtitle.Id}"); + source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = + $"http://localhost:{Settings.StreamingPort}/internal/media/subtitle/{subtitle.Id}?exp={exp.ToUnixTimeSeconds()}&sig={sig}", + KeepAlive = false, + Reconnect = true + }; } + else if (isRemote) + { + source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = path, + KeepAlive = false, + Reconnect = true + }; + } + else + { + source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Local, + Path = path + }; + } + + // generated http credits already apply the item's in-point at the endpoint. + // other subtitles retain media timestamps, and next applies the seek + if (subtitle.SubtitleKind is not SubtitleKind.Generated || !isRemote) + { + SetInOutPoints(playoutItem, source); + } + + return source; } private void SelectGraphics( @@ -601,20 +645,24 @@ public class PlayoutItemConverter( Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), - MusicVideo => GetMusicVideoSubtitles(channel, playoutItemId, playoutItemInPoint), + MusicVideo musicVideo => channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles + ? GetMusicVideoSubtitles(channel, playoutItemId, playoutItemInPoint) + : await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone() + .Map(mm => mm.Subtitles ?? []) + .IfNoneAsync([]), OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), _ => [] }; - bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or + bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or PlexOtherVideo or JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; if (isMediaServer) { // closed captions are currently unsupported - allSubtitles.RemoveAll(s => s.Codec == "eia_608"); + allSubtitles = allSubtitles.Where(s => s.Codec != "eia_608").ToList(); } return allSubtitles;