diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index d709ed85a..b57ccbac5 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Globalization; using System.IO.Abstractions; using System.Runtime.InteropServices; @@ -134,18 +135,27 @@ public partial class SyncNextPlayoutHandler( .ThenInclude(i => (i as Episode).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Subtitles) + .Include(i => i.MediaItem) .ThenInclude(i => (i as Movie).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(i => (i as Movie).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Subtitles) + .Include(i => i.MediaItem) .ThenInclude(i => (i as OtherVideo).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) .ThenInclude(i => (i as OtherVideo).MediaVersions) .ThenInclude(mv => mv.Streams) .Include(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(ovm => ovm.Subtitles) + .Include(i => i.MediaItem) .ThenInclude(i => (i as MusicVideo).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) @@ -238,6 +248,8 @@ public partial class SyncNextPlayoutHandler( nextPlayoutItem, playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle, + playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, + playoutItem.SubtitleMode ?? channel.SubtitleMode, cancellationToken); } @@ -254,13 +266,14 @@ public partial class SyncNextPlayoutHandler( Core.Next.PlayoutItem nextPlayoutItem, string preferredAudioLanguage, string preferredAudioTitle, + string preferredSubtitleLanguage, + ChannelSubtitleMode subtitleMode, CancellationToken cancellationToken) { - // TODO: NEXT: support subtitles - List allSubtitles = []; + List allSubtitles = await GetSubtitles(audioVersion.MediaItem); Option maybeAudioStream = Option.None; - //Option maybeSubtitle = Option.None; + Option maybeSubtitle = Option.None; if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) { @@ -270,7 +283,7 @@ public partial class SyncNextPlayoutHandler( audioVersion, allSubtitles); maybeAudioStream = result.AudioStream; - //maybeSubtitle = result.Subtitle; + maybeSubtitle = result.Subtitle; } if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) @@ -285,13 +298,14 @@ public partial class SyncNextPlayoutHandler( shouldLogMessages: false, cancellationToken); - // maybeSubtitle = - // await ffmpegStreamSelector.SelectSubtitleStream( - // allSubtitles.ToImmutableList(), - // channel, - // preferredSubtitleLanguage, - // subtitleMode, - // cancellationToken); + maybeSubtitle = + await ffmpegStreamSelector.SelectSubtitleStream( + allSubtitles.ToImmutableList(), + channel, + preferredSubtitleLanguage, + subtitleMode, + shouldLogMessages: false, + cancellationToken); } foreach (MediaStream audioStream in maybeAudioStream) @@ -303,6 +317,16 @@ public partial class SyncNextPlayoutHandler( nextPlayoutItem.Tracks.Audio.StreamIndex = audioStream.Index; } } + + foreach (Subtitle subtitle in maybeSubtitle) + { + 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; + } + } } private async Task> SourceForItem( @@ -431,4 +455,38 @@ public partial class SyncNextPlayoutHandler( } } } + + private static async Task> GetSubtitles(MediaItem mediaItem) + { + 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 musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings), + 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) + { + return []; + + // closed captions are currently unsupported + //allSubtitles.RemoveAll(s => s.Codec == "eia_608"); + } + + // TODO: support text subtitles; external image subtitles + allSubtitles.RemoveAll(s => !s.IsImage || s.SubtitleKind is not SubtitleKind.Embedded); + + return allSubtitles; + } } diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs index 47b92b019..d29356924 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs @@ -185,6 +185,7 @@ public class FFmpegStreamSelectorTests channel, "heb", ChannelSubtitleMode.Any, + shouldLogMessages: true, cancellationToken); selectedStream.IsSome.ShouldBeTrue(); foreach (Subtitle stream in selectedStream) diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 1c3f67d95..bba799528 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -162,6 +162,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService channel, preferredSubtitleLanguage, subtitleMode, + shouldLogMessages: true, cancellationToken); } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs index 88d8d9166..57d0fde00 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs @@ -155,12 +155,17 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode, + bool shouldLogMessages, CancellationToken cancellationToken) { if (channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles && subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle) { - _logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number); + if (shouldLogMessages) + { + _logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number); + } + return Optional(generatedSubtitle); } @@ -177,7 +182,11 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector if (!useEmbeddedSubtitles) { - _logger.LogDebug("Ignoring embedded subtitles for channel {Number}", channel.Number); + if (shouldLogMessages) + { + _logger.LogDebug("Ignoring embedded subtitles for channel {Number}", channel.Number); + } + candidateSubtitles = candidateSubtitles.Filter(s => s.SubtitleKind is not SubtitleKind.Embedded).ToList(); } @@ -189,17 +198,23 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector { if (!subtitle.IsExtracted) { - _logger.LogDebug( - "Ignoring embedded subtitle with index {Index} that has not been extracted", - subtitle.StreamIndex); + if (shouldLogMessages) + { + _logger.LogDebug( + "Ignoring embedded subtitle with index {Index} that has not been extracted", + subtitle.StreamIndex); + } candidateSubtitles.Remove(subtitle); } else if (string.IsNullOrWhiteSpace(subtitle.Path)) { - _logger.LogDebug( - "BUG: ignoring embedded subtitle with index {Index} that is missing a path", - subtitle.StreamIndex); + if (shouldLogMessages) + { + _logger.LogDebug( + "BUG: ignoring embedded subtitle with index {Index} that is missing a path", + subtitle.StreamIndex); + } candidateSubtitles.Remove(subtitle); } @@ -210,7 +225,10 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(language)) { - _logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number); + if (shouldLogMessages) + { + _logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number); + } } else { @@ -218,7 +236,10 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector allCodes = GetTwoAndThreeLetterLanguageCodes(_languageCodeService.GetAllLanguageCodes([language])); if (allCodes.Count > 1) { - _logger.LogDebug("Preferred subtitle language has multiple codes {Codes}", allCodes); + if (shouldLogMessages) + { + _logger.LogDebug("Preferred subtitle language has multiple codes {Codes}", allCodes); + } } candidateSubtitles = candidateSubtitles @@ -249,16 +270,23 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector foreach (Subtitle subtitle in maybeSelectedSubtitle) { - _logger.LogDebug("Selecting subtitle {@Subtitle}", subtitle); + if (shouldLogMessages) + { + _logger.LogDebug("Selecting subtitle {@Subtitle}", subtitle); + } + return subtitle; } } - _logger.LogDebug( - "Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}", - channel.Number, - subtitleMode, - allCodes); + if (shouldLogMessages) + { + _logger.LogDebug( + "Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}", + channel.Number, + subtitleMode, + allCodes); + } return None; } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs index 7ac1474dd..f8410a035 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs @@ -22,5 +22,6 @@ public interface IFFmpegStreamSelector Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode, + bool shouldLogMessages, CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 7a05e7227..69793bd12 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -171,6 +171,12 @@ namespace ErsatzTV.Core.Next [JsonProperty("audio")] public TrackSelection Audio { get; set; } + /// + /// Subtitle track selection. + /// + [JsonProperty("subtitle")] + public TrackSelection Subtitle { get; set; } + /// /// Video track selection. /// diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 1194dacba..0e084921e 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -1196,6 +1196,7 @@ public class TranscodingTests Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode, + bool shouldLogMessages, CancellationToken cancellationToken) => subtitles.HeadOrNone().AsTask(); }