Browse Source

feat: select embedded image subtitle streams for next engine during playout build (#2868)

pull/2869/head
Jason Dove 4 weeks ago committed by GitHub
parent
commit
57f7dfa5a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 80
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  2. 1
      ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
  3. 1
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  4. 60
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  5. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs
  6. 6
      ErsatzTV.Core/Next/Playout.cs
  7. 1
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

80
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@ -134,18 +135,27 @@ public partial class SyncNextPlayoutHandler(
.ThenInclude(i => (i as Episode).MediaVersions) .ThenInclude(i => (i as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem) .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(i => (i as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MediaVersions) .ThenInclude(i => (i as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem) .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(i => (i as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).MediaVersions) .ThenInclude(i => (i as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem) .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(i => (i as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
@ -238,6 +248,8 @@ public partial class SyncNextPlayoutHandler(
nextPlayoutItem, nextPlayoutItem,
playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode,
playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle, playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle,
playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode,
playoutItem.SubtitleMode ?? channel.SubtitleMode,
cancellationToken); cancellationToken);
} }
@ -254,13 +266,14 @@ public partial class SyncNextPlayoutHandler(
Core.Next.PlayoutItem nextPlayoutItem, Core.Next.PlayoutItem nextPlayoutItem,
string preferredAudioLanguage, string preferredAudioLanguage,
string preferredAudioTitle, string preferredAudioTitle,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// TODO: NEXT: support subtitles List<Subtitle> allSubtitles = await GetSubtitles(audioVersion.MediaItem);
List<Subtitle> allSubtitles = [];
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None; Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
//Option<Subtitle> maybeSubtitle = Option<Subtitle>.None; Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
{ {
@ -270,7 +283,7 @@ public partial class SyncNextPlayoutHandler(
audioVersion, audioVersion,
allSubtitles); allSubtitles);
maybeAudioStream = result.AudioStream; maybeAudioStream = result.AudioStream;
//maybeSubtitle = result.Subtitle; maybeSubtitle = result.Subtitle;
} }
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone)
@ -285,13 +298,14 @@ public partial class SyncNextPlayoutHandler(
shouldLogMessages: false, shouldLogMessages: false,
cancellationToken); cancellationToken);
// maybeSubtitle = maybeSubtitle =
// await ffmpegStreamSelector.SelectSubtitleStream( await ffmpegStreamSelector.SelectSubtitleStream(
// allSubtitles.ToImmutableList(), allSubtitles.ToImmutableList(),
// channel, channel,
// preferredSubtitleLanguage, preferredSubtitleLanguage,
// subtitleMode, subtitleMode,
// cancellationToken); shouldLogMessages: false,
cancellationToken);
} }
foreach (MediaStream audioStream in maybeAudioStream) foreach (MediaStream audioStream in maybeAudioStream)
@ -303,6 +317,16 @@ public partial class SyncNextPlayoutHandler(
nextPlayoutItem.Tracks.Audio.StreamIndex = audioStream.Index; 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<Option<Core.Next.Source>> SourceForItem( private async Task<Option<Core.Next.Source>> SourceForItem(
@ -431,4 +455,38 @@ public partial class SyncNextPlayoutHandler(
} }
} }
} }
private static async Task<List<Subtitle>> GetSubtitles(MediaItem mediaItem)
{
List<Subtitle> 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;
}
} }

1
ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs

@ -185,6 +185,7 @@ public class FFmpegStreamSelectorTests
channel, channel,
"heb", "heb",
ChannelSubtitleMode.Any, ChannelSubtitleMode.Any,
shouldLogMessages: true,
cancellationToken); cancellationToken);
selectedStream.IsSome.ShouldBeTrue(); selectedStream.IsSome.ShouldBeTrue();
foreach (Subtitle stream in selectedStream) foreach (Subtitle stream in selectedStream)

1
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -162,6 +162,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
channel, channel,
preferredSubtitleLanguage, preferredSubtitleLanguage,
subtitleMode, subtitleMode,
shouldLogMessages: true,
cancellationToken); cancellationToken);
} }

60
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -155,12 +155,17 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
Channel channel, Channel channel,
string preferredSubtitleLanguage, string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode, ChannelSubtitleMode subtitleMode,
bool shouldLogMessages,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles && if (channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles &&
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle) 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); return Optional(generatedSubtitle);
} }
@ -177,7 +182,11 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
if (!useEmbeddedSubtitles) 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(); candidateSubtitles = candidateSubtitles.Filter(s => s.SubtitleKind is not SubtitleKind.Embedded).ToList();
} }
@ -189,17 +198,23 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
{ {
if (!subtitle.IsExtracted) if (!subtitle.IsExtracted)
{ {
_logger.LogDebug( if (shouldLogMessages)
"Ignoring embedded subtitle with index {Index} that has not been extracted", {
subtitle.StreamIndex); _logger.LogDebug(
"Ignoring embedded subtitle with index {Index} that has not been extracted",
subtitle.StreamIndex);
}
candidateSubtitles.Remove(subtitle); candidateSubtitles.Remove(subtitle);
} }
else if (string.IsNullOrWhiteSpace(subtitle.Path)) else if (string.IsNullOrWhiteSpace(subtitle.Path))
{ {
_logger.LogDebug( if (shouldLogMessages)
"BUG: ignoring embedded subtitle with index {Index} that is missing a path", {
subtitle.StreamIndex); _logger.LogDebug(
"BUG: ignoring embedded subtitle with index {Index} that is missing a path",
subtitle.StreamIndex);
}
candidateSubtitles.Remove(subtitle); candidateSubtitles.Remove(subtitle);
} }
@ -210,7 +225,10 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant(); string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language)) 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 else
{ {
@ -218,7 +236,10 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
allCodes = GetTwoAndThreeLetterLanguageCodes(_languageCodeService.GetAllLanguageCodes([language])); allCodes = GetTwoAndThreeLetterLanguageCodes(_languageCodeService.GetAllLanguageCodes([language]));
if (allCodes.Count > 1) 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 candidateSubtitles = candidateSubtitles
@ -249,16 +270,23 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
foreach (Subtitle subtitle in maybeSelectedSubtitle) foreach (Subtitle subtitle in maybeSelectedSubtitle)
{ {
_logger.LogDebug("Selecting subtitle {@Subtitle}", subtitle); if (shouldLogMessages)
{
_logger.LogDebug("Selecting subtitle {@Subtitle}", subtitle);
}
return subtitle; return subtitle;
} }
} }
_logger.LogDebug( if (shouldLogMessages)
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}", {
channel.Number, _logger.LogDebug(
subtitleMode, "Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
allCodes); channel.Number,
subtitleMode,
allCodes);
}
return None; return None;
} }

1
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs

@ -22,5 +22,6 @@ public interface IFFmpegStreamSelector
Channel channel, Channel channel,
string preferredSubtitleLanguage, string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode, ChannelSubtitleMode subtitleMode,
bool shouldLogMessages,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }

6
ErsatzTV.Core/Next/Playout.cs

@ -171,6 +171,12 @@ namespace ErsatzTV.Core.Next
[JsonProperty("audio")] [JsonProperty("audio")]
public TrackSelection Audio { get; set; } public TrackSelection Audio { get; set; }
/// <summary>
/// Subtitle track selection.
/// </summary>
[JsonProperty("subtitle")]
public TrackSelection Subtitle { get; set; }
/// <summary> /// <summary>
/// Video track selection. /// Video track selection.
/// </summary> /// </summary>

1
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -1196,6 +1196,7 @@ public class TranscodingTests
Channel channel, Channel channel,
string preferredSubtitleLanguage, string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode, ChannelSubtitleMode subtitleMode,
bool shouldLogMessages,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
subtitles.HeadOrNone().AsTask(); subtitles.HeadOrNone().AsTask();
} }

Loading…
Cancel
Save