mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
418 lines
15 KiB
418 lines
15 KiB
using System.Diagnostics; |
|
using System.Diagnostics.CodeAnalysis; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Scripting; |
|
using ErsatzTV.Core.Scripting; |
|
using Microsoft.Extensions.Logging; |
|
|
|
namespace ErsatzTV.Core.FFmpeg; |
|
|
|
public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
{ |
|
private readonly IConfigElementRepository _configElementRepository; |
|
private readonly ILocalFileSystem _localFileSystem; |
|
private readonly ILogger<FFmpegStreamSelector> _logger; |
|
private readonly IScriptEngine _scriptEngine; |
|
private readonly ISearchRepository _searchRepository; |
|
private readonly IStreamSelectorRepository _streamSelectorRepository; |
|
|
|
public FFmpegStreamSelector( |
|
IScriptEngine scriptEngine, |
|
IStreamSelectorRepository streamSelectorRepository, |
|
ISearchRepository searchRepository, |
|
IConfigElementRepository configElementRepository, |
|
ILocalFileSystem localFileSystem, |
|
ILogger<FFmpegStreamSelector> logger) |
|
{ |
|
_scriptEngine = scriptEngine; |
|
_streamSelectorRepository = streamSelectorRepository; |
|
_searchRepository = searchRepository; |
|
_configElementRepository = configElementRepository; |
|
_localFileSystem = localFileSystem; |
|
_logger = logger; |
|
} |
|
|
|
public Task<MediaStream> SelectVideoStream(MediaVersion version) => |
|
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); |
|
|
|
public async Task<Option<MediaStream>> SelectAudioStream( |
|
MediaItemAudioVersion version, |
|
StreamingMode streamingMode, |
|
Channel channel, |
|
string preferredAudioLanguage, |
|
string preferredAudioTitle) |
|
{ |
|
if (streamingMode == StreamingMode.HttpLiveStreamingDirect && |
|
string.IsNullOrWhiteSpace(preferredAudioLanguage) && string.IsNullOrWhiteSpace(preferredAudioTitle)) |
|
{ |
|
_logger.LogDebug( |
|
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams", |
|
channel.Number); |
|
return None; |
|
} |
|
|
|
string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant(); |
|
if (string.IsNullOrWhiteSpace(language)) |
|
{ |
|
_logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number); |
|
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>( |
|
ConfigElementKey.FFmpegPreferredLanguageCode); |
|
maybeDefaultLanguage.Match( |
|
lang => language = lang.ToLowerInvariant(), |
|
() => |
|
{ |
|
_logger.LogDebug("FFmpeg has no preferred audio language code; falling back to {Code}", "eng"); |
|
language = "eng"; |
|
}); |
|
} |
|
|
|
List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language }); |
|
if (allLanguageCodes.Count > 1) |
|
{ |
|
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allLanguageCodes); |
|
} |
|
|
|
try |
|
{ |
|
switch (version.MediaItem) |
|
{ |
|
case Episode: |
|
var sw = Stopwatch.StartNew(); |
|
Option<MediaStream> result = await SelectEpisodeAudioStream( |
|
channel, |
|
allLanguageCodes, |
|
version.MediaItem.Id, |
|
version.MediaVersion); |
|
sw.Stop(); |
|
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw.Elapsed); |
|
if (result.IsSome) |
|
{ |
|
return result; |
|
} |
|
|
|
break; |
|
case Movie: |
|
var sw2 = Stopwatch.StartNew(); |
|
Option<MediaStream> result2 = await SelectMovieAudioStream( |
|
channel, |
|
allLanguageCodes, |
|
version.MediaItem.Id, |
|
version.MediaVersion); |
|
sw2.Stop(); |
|
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw2.Elapsed); |
|
if (result2.IsSome) |
|
{ |
|
return result2; |
|
} |
|
|
|
break; |
|
// let default fall through |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
_logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic"); |
|
} |
|
|
|
return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle); |
|
} |
|
|
|
public async Task<Option<Subtitle>> SelectSubtitleStream( |
|
List<Subtitle> subtitles, |
|
Channel channel, |
|
string preferredSubtitleLanguage, |
|
ChannelSubtitleMode subtitleMode) |
|
{ |
|
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); |
|
return Optional(generatedSubtitle); |
|
} |
|
|
|
if (subtitleMode == ChannelSubtitleMode.None) |
|
{ |
|
return None; |
|
} |
|
|
|
bool useEmbeddedSubtitles = await _configElementRepository |
|
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles) |
|
.IfNoneAsync(true); |
|
|
|
if (!useEmbeddedSubtitles) |
|
{ |
|
_logger.LogDebug("Ignoring embedded subtitles for channel {Number}", channel.Number); |
|
subtitles = subtitles.Filter(s => s.SubtitleKind is not SubtitleKind.Embedded).ToList(); |
|
} |
|
|
|
foreach (Subtitle subtitle in subtitles.Filter(s => s.SubtitleKind is SubtitleKind.Embedded && !s.IsImage).ToList()) |
|
{ |
|
if (subtitle.IsExtracted == false) |
|
{ |
|
_logger.LogDebug( |
|
"Ignoring embedded subtitle with index {Index} that has not been extracted", |
|
subtitle.StreamIndex); |
|
|
|
subtitles.Remove(subtitle); |
|
} |
|
else if (string.IsNullOrWhiteSpace(subtitle.Path)) |
|
{ |
|
_logger.LogDebug( |
|
"BUG: ignoring embedded subtitle with index {Index} that is missing a path", |
|
subtitle.StreamIndex); |
|
|
|
subtitles.Remove(subtitle); |
|
} |
|
} |
|
|
|
var allCodes = new List<string>(); |
|
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant(); |
|
if (string.IsNullOrWhiteSpace(language)) |
|
{ |
|
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number); |
|
} |
|
else |
|
{ |
|
// filter to preferred language |
|
allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language }); |
|
if (allCodes.Count > 1) |
|
{ |
|
_logger.LogDebug("Preferred subtitle language has multiple codes {Codes}", allCodes); |
|
} |
|
|
|
subtitles = subtitles |
|
.Filter(s => allCodes.Any(c => string.Equals(s.Language, c, StringComparison.OrdinalIgnoreCase))) |
|
.ToList(); |
|
} |
|
|
|
if (subtitles.Count > 0) |
|
{ |
|
Option<Subtitle> maybeSelectedSubtitle = subtitleMode switch |
|
{ |
|
ChannelSubtitleMode.Forced => subtitles |
|
.OrderBy(s => s.StreamIndex) |
|
.Find(s => s.Forced) |
|
.HeadOrNone(), |
|
|
|
ChannelSubtitleMode.Default => subtitles |
|
.OrderBy(s => s.Default ? 0 : 1) |
|
.ThenBy(s => s.StreamIndex) |
|
.HeadOrNone(), |
|
|
|
ChannelSubtitleMode.Any => subtitles |
|
.OrderBy(s => s.StreamIndex) |
|
.HeadOrNone(), |
|
|
|
_ => Option<Subtitle>.None |
|
}; |
|
|
|
foreach (Subtitle subtitle in maybeSelectedSubtitle) |
|
{ |
|
_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); |
|
|
|
return None; |
|
} |
|
|
|
private Option<MediaStream> DefaultSelectAudioStream( |
|
MediaVersion version, |
|
IReadOnlyCollection<string> preferredLanguageCodes, |
|
string preferredAudioTitle) |
|
{ |
|
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList(); |
|
|
|
var correctLanguage = audioStreams.Filter( |
|
s => preferredLanguageCodes.Any(c => string.Equals(s.Language, c, StringComparison.OrdinalIgnoreCase))) |
|
.ToList(); |
|
|
|
if (correctLanguage.Count != 0) |
|
{ |
|
_logger.LogDebug( |
|
"Found {Count} audio streams with preferred audio language code(s) {Code}", |
|
correctLanguage.Count, |
|
preferredLanguageCodes); |
|
|
|
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty); |
|
} |
|
|
|
_logger.LogDebug( |
|
"Unable to find audio stream with preferred audio language code(s) {Code}", |
|
preferredLanguageCodes); |
|
|
|
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty); |
|
} |
|
|
|
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title) |
|
{ |
|
if (string.IsNullOrWhiteSpace(title)) |
|
{ |
|
return PrioritizeDefault(streams); |
|
} |
|
|
|
// prioritize matching titles |
|
var matchingTitle = streams |
|
.Filter(ms => (ms.Title ?? string.Empty).Contains(title, StringComparison.OrdinalIgnoreCase)) |
|
.ToList(); |
|
if (matchingTitle.Count != 0) |
|
{ |
|
_logger.LogDebug( |
|
"Found {Count} audio streams with preferred title {Title}", |
|
matchingTitle.Count, |
|
title); |
|
|
|
return PrioritizeDefault(streams); |
|
} |
|
|
|
_logger.LogDebug("Unable to find audio stream with preferred title {Title}", title); |
|
|
|
return PrioritizeDefault(streams); |
|
} |
|
|
|
private Option<MediaStream> PrioritizeDefault(IReadOnlyCollection<MediaStream> streams) |
|
{ |
|
var sorted = streams.OrderByDescending(s => s.Channels).ToList(); |
|
Option<MediaStream> maybeDefault = Optional(sorted.Find(s => s.Default)); |
|
foreach (MediaStream stream in maybeDefault) |
|
{ |
|
_logger.LogDebug("Found audio stream flagged as default"); |
|
return stream; |
|
} |
|
|
|
_logger.LogDebug("Unable to find default audio stream; selecting stream with most channels"); |
|
|
|
return streams.HeadOrNone(); |
|
} |
|
|
|
private async Task<Option<MediaStream>> SelectEpisodeAudioStream( |
|
Channel channel, |
|
List<string> preferredLanguageCodes, |
|
int episodeId, |
|
MediaVersion version) |
|
{ |
|
string jsScriptPath = Path.ChangeExtension( |
|
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "episode"), |
|
"js"); |
|
|
|
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); |
|
if (!_localFileSystem.FileExists(jsScriptPath)) |
|
{ |
|
_logger.LogDebug("Unable to locate episode audio stream selector script; falling back to built-in logic"); |
|
return Option<MediaStream>.None; |
|
} |
|
|
|
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); |
|
|
|
await _scriptEngine.LoadAsync(jsScriptPath); |
|
|
|
EpisodeAudioStreamSelectorData data = await _streamSelectorRepository.GetEpisodeData(episodeId); |
|
|
|
AudioStream[] audioStreams = GetAudioStreamsForScript(version); |
|
|
|
object result = _scriptEngine.Invoke( |
|
"selectEpisodeAudioStreamIndex", |
|
channel.Number, |
|
channel.Name, |
|
data.ShowTitle, |
|
data.ShowGuids, |
|
data.SeasonNumber, |
|
data.EpisodeNumber, |
|
data.EpisodeGuids, |
|
preferredLanguageCodes.ToArray(), |
|
audioStreams); |
|
|
|
return ProcessScriptResult(version, result); |
|
} |
|
|
|
private async Task<Option<MediaStream>> SelectMovieAudioStream( |
|
Channel channel, |
|
List<string> preferredLanguageCodes, |
|
int movieId, |
|
MediaVersion version) |
|
{ |
|
string jsScriptPath = Path.ChangeExtension( |
|
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "movie"), |
|
"js"); |
|
|
|
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); |
|
if (!_localFileSystem.FileExists(jsScriptPath)) |
|
{ |
|
_logger.LogDebug( |
|
"Unable to locate movie audio stream selector script; falling back to built-in logic"); |
|
return Option<MediaStream>.None; |
|
} |
|
|
|
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); |
|
|
|
await _scriptEngine.LoadAsync(jsScriptPath); |
|
|
|
MovieAudioStreamSelectorData data = await _streamSelectorRepository.GetMovieData(movieId); |
|
|
|
AudioStream[] audioStreams = GetAudioStreamsForScript(version); |
|
|
|
object result = _scriptEngine.Invoke( |
|
"selectMovieAudioStreamIndex", |
|
channel.Number, |
|
channel.Name, |
|
data.Title, |
|
data.Guids, |
|
preferredLanguageCodes.ToArray(), |
|
audioStreams); |
|
|
|
return ProcessScriptResult(version, result); |
|
} |
|
|
|
private Option<MediaStream> ProcessScriptResult(MediaVersion version, object result) |
|
{ |
|
if (result is double d) |
|
{ |
|
var streamIndex = (int)d; |
|
Option<MediaStream> maybeStream = version.Streams.Find(s => s.Index == streamIndex); |
|
foreach (MediaStream stream in maybeStream) |
|
{ |
|
_logger.LogDebug( |
|
"JS Script returned audio stream index {Index} with language {Language} and {Channels} audio channel(s)", |
|
streamIndex, |
|
stream.Language, |
|
stream.Channels); |
|
return stream; |
|
} |
|
|
|
_logger.LogWarning( |
|
"JS Script returned audio stream index {Index} which does not exist", |
|
streamIndex); |
|
} |
|
else |
|
{ |
|
_logger.LogInformation( |
|
"JS Script did not return an audio stream index; falling back to built-in logic"); |
|
} |
|
|
|
return Option<MediaStream>.None; |
|
} |
|
|
|
private static AudioStream[] GetAudioStreamsForScript(MediaVersion version) => version.Streams |
|
.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio) |
|
.Map(a => new AudioStream(a.Index, a.Channels, a.Codec, a.Default, a.Forced, a.Language, a.Title)) |
|
.ToArray(); |
|
|
|
[SuppressMessage("ReSharper", "InconsistentNaming")] |
|
private record AudioStream( |
|
int index, |
|
int channels, |
|
string codec, |
|
bool isDefault, |
|
bool isForced, |
|
string language, |
|
string title); |
|
}
|
|
|