|
|
@ -1,33 +1,47 @@ |
|
|
|
using ErsatzTV.Core.Domain; |
|
|
|
using System.Diagnostics; |
|
|
|
|
|
|
|
using System.Diagnostics.CodeAnalysis; |
|
|
|
|
|
|
|
using ErsatzTV.Core.Domain; |
|
|
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
|
|
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
|
|
|
|
|
|
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
|
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
|
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
|
|
|
|
|
|
using ErsatzTV.Core.Interfaces.Scripting; |
|
|
|
|
|
|
|
using ErsatzTV.Core.Scripting; |
|
|
|
using Microsoft.Extensions.Logging; |
|
|
|
using Microsoft.Extensions.Logging; |
|
|
|
|
|
|
|
|
|
|
|
namespace ErsatzTV.Core.FFmpeg; |
|
|
|
namespace ErsatzTV.Core.FFmpeg; |
|
|
|
|
|
|
|
|
|
|
|
public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
{ |
|
|
|
{ |
|
|
|
|
|
|
|
private readonly IScriptEngine _scriptEngine; |
|
|
|
|
|
|
|
private readonly IStreamSelectorRepository _streamSelectorRepository; |
|
|
|
|
|
|
|
private readonly ISearchRepository _searchRepository; |
|
|
|
private readonly IConfigElementRepository _configElementRepository; |
|
|
|
private readonly IConfigElementRepository _configElementRepository; |
|
|
|
|
|
|
|
private readonly ILocalFileSystem _localFileSystem; |
|
|
|
private readonly ILogger<FFmpegStreamSelector> _logger; |
|
|
|
private readonly ILogger<FFmpegStreamSelector> _logger; |
|
|
|
private readonly ISearchRepository _searchRepository; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public FFmpegStreamSelector( |
|
|
|
public FFmpegStreamSelector( |
|
|
|
|
|
|
|
IScriptEngine scriptEngine, |
|
|
|
|
|
|
|
IStreamSelectorRepository streamSelectorRepository, |
|
|
|
ISearchRepository searchRepository, |
|
|
|
ISearchRepository searchRepository, |
|
|
|
ILogger<FFmpegStreamSelector> logger, |
|
|
|
IConfigElementRepository configElementRepository, |
|
|
|
IConfigElementRepository configElementRepository) |
|
|
|
ILocalFileSystem localFileSystem, |
|
|
|
|
|
|
|
ILogger<FFmpegStreamSelector> logger) |
|
|
|
{ |
|
|
|
{ |
|
|
|
|
|
|
|
_scriptEngine = scriptEngine; |
|
|
|
|
|
|
|
_streamSelectorRepository = streamSelectorRepository; |
|
|
|
_searchRepository = searchRepository; |
|
|
|
_searchRepository = searchRepository; |
|
|
|
_logger = logger; |
|
|
|
|
|
|
|
_configElementRepository = configElementRepository; |
|
|
|
_configElementRepository = configElementRepository; |
|
|
|
|
|
|
|
_localFileSystem = localFileSystem; |
|
|
|
|
|
|
|
_logger = logger; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
public Task<MediaStream> SelectVideoStream(MediaVersion version) => |
|
|
|
public Task<MediaStream> SelectVideoStream(MediaVersion version) => |
|
|
|
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); |
|
|
|
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); |
|
|
|
|
|
|
|
|
|
|
|
public async Task<Option<MediaStream>> SelectAudioStream( |
|
|
|
public async Task<Option<MediaStream>> SelectAudioStream( |
|
|
|
MediaVersion version, |
|
|
|
MediaItemAudioVersion version, |
|
|
|
StreamingMode streamingMode, |
|
|
|
StreamingMode streamingMode, |
|
|
|
string channelNumber, |
|
|
|
Channel channel, |
|
|
|
string preferredAudioLanguage, |
|
|
|
string preferredAudioLanguage, |
|
|
|
string preferredAudioTitle) |
|
|
|
string preferredAudioTitle) |
|
|
|
{ |
|
|
|
{ |
|
|
@ -36,16 +50,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogDebug( |
|
|
|
_logger.LogDebug( |
|
|
|
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams", |
|
|
|
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams", |
|
|
|
channelNumber); |
|
|
|
channel.Number); |
|
|
|
return None; |
|
|
|
return None; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant(); |
|
|
|
string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant(); |
|
|
|
if (string.IsNullOrWhiteSpace(language)) |
|
|
|
if (string.IsNullOrWhiteSpace(language)) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogDebug("Channel {Number} has no preferred audio language code", channelNumber); |
|
|
|
_logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number); |
|
|
|
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>( |
|
|
|
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>( |
|
|
|
ConfigElementKey.FFmpegPreferredLanguageCode); |
|
|
|
ConfigElementKey.FFmpegPreferredLanguageCode); |
|
|
|
maybeDefaultLanguage.Match( |
|
|
|
maybeDefaultLanguage.Match( |
|
|
@ -57,33 +69,45 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language }); |
|
|
|
List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language }); |
|
|
|
if (allCodes.Count > 1) |
|
|
|
if (allLanguageCodes.Count > 1) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allCodes); |
|
|
|
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allLanguageCodes); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
var correctLanguage = audioStreams.Filter( |
|
|
|
try |
|
|
|
s => allCodes.Any( |
|
|
|
|
|
|
|
c => string.Equals( |
|
|
|
|
|
|
|
s.Language, |
|
|
|
|
|
|
|
c, |
|
|
|
|
|
|
|
StringComparison.InvariantCultureIgnoreCase))).ToList(); |
|
|
|
|
|
|
|
if (correctLanguage.Any()) |
|
|
|
|
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogDebug( |
|
|
|
switch (version.MediaItem) |
|
|
|
"Found {Count} audio streams with preferred audio language code(s) {Code}", |
|
|
|
{ |
|
|
|
correctLanguage.Count, |
|
|
|
case Episode: |
|
|
|
allCodes); |
|
|
|
var sw = Stopwatch.StartNew(); |
|
|
|
|
|
|
|
Option<MediaStream> result = await SelectEpisodeAudioStream( |
|
|
|
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty); |
|
|
|
channel, |
|
|
|
|
|
|
|
allLanguageCodes, |
|
|
|
|
|
|
|
version.MediaItem.Id, |
|
|
|
|
|
|
|
version.MediaVersion); |
|
|
|
|
|
|
|
sw.Stop(); |
|
|
|
|
|
|
|
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw.Elapsed); |
|
|
|
|
|
|
|
return result; |
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
return result2; |
|
|
|
|
|
|
|
// let default fall through
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
catch (Exception ex) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
_logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
_logger.LogDebug( |
|
|
|
return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle); |
|
|
|
"Unable to find audio stream with preferred audio language code(s) {Code}", |
|
|
|
|
|
|
|
allCodes); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
public async Task<Option<Subtitle>> SelectSubtitleStream( |
|
|
|
public async Task<Option<Subtitle>> SelectSubtitleStream( |
|
|
@ -165,9 +189,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
return None; |
|
|
|
return None; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private Option<MediaStream> DefaultSelectAudioStream( |
|
|
|
|
|
|
|
MediaVersion version, |
|
|
|
|
|
|
|
List<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.InvariantCultureIgnoreCase))).ToList(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (correctLanguage.Any()) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
_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) |
|
|
|
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title) |
|
|
|
{ |
|
|
|
{ |
|
|
|
// return correctLanguage.OrderByDescending(s => s.Channels).Head();
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(title)) |
|
|
|
if (string.IsNullOrWhiteSpace(title)) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogDebug("No audio title has been specified; selecting stream with most channels"); |
|
|
|
_logger.LogDebug("No audio title has been specified; selecting stream with most channels"); |
|
|
@ -194,4 +248,125 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector |
|
|
|
|
|
|
|
|
|
|
|
return streams.OrderByDescending(s => s.Channels).Head(); |
|
|
|
return streams.OrderByDescending(s => s.Channels).Head(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.LogWarning("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.LogWarning("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); |
|
|
|
} |
|
|
|
} |
|
|
|