From c9bd94d9f82cfcc3a904fc752600e0661432d256 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Fri, 28 Oct 2022 17:05:07 -0500 Subject: [PATCH] use javascript instead of lua for external scripts; add audio stream selector scripts (#1005) * use js instead of lua * update dependencies * add audio stream selector script for episodes * add audio stream selector script for movies * update changelog --- CHANGELOG.md | 37 +++ .../ErsatzTV.Application.csproj | 2 +- ...layoutItemProcessByChannelNumberHandler.cs | 2 +- .../ErsatzTV.Core.Tests.csproj | 4 +- .../FFmpeg/TranscodingTests.cs | 8 +- ErsatzTV.Core/ErsatzTV.Core.csproj | 2 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 4 +- ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs | 243 +++++++++++++++--- ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs | 5 + ErsatzTV.Core/FileSystemLayout.cs | 3 + .../FFmpeg/IFFmpegProcessService.cs | 2 +- .../FFmpeg/IFFmpegStreamSelector.cs | 5 +- .../Repositories/IStreamSelectorRepository.cs | 9 + ...isodeShuffleCollectionEnumeratorFactory.cs | 2 +- .../Interfaces/Scripting/IScriptEngine.cs | 9 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 12 +- .../EpisodeAudioStreamSelectorData.cs | 8 + .../Scripting/MovieAudioStreamSelectorData.cs | 3 + .../ErsatzTV.FFmpeg.Tests.csproj | 2 +- .../ErsatzTV.Infrastructure.Tests.csproj | 7 +- .../Repositories/StreamSelectorRepository.cs | 80 ++++++ .../ErsatzTV.Infrastructure.csproj | 4 +- ...MultiEpisodeShuffleCollectionEnumerator.cs | 23 +- ...isodeShuffleCollectionEnumeratorFactory.cs | 8 +- .../Scripting/ScriptEngine.cs | 45 ++++ ErsatzTV.sln | 4 +- ErsatzTV/ErsatzTV.csproj | 6 +- ErsatzTV/Resources/Scripts/_episode.js | 22 ++ ErsatzTV/Resources/Scripts/_movie.js | 19 ++ .../Resources/Scripts/_threePartEpisodes.js | 9 + .../Resources/Scripts/_threePartEpisodes.lua | 13 - .../RunOnce/ResourceExtractorService.cs | 14 +- ErsatzTV/Startup.cs | 9 + 33 files changed, 532 insertions(+), 93 deletions(-) create mode 100644 ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs create mode 100644 ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs create mode 100644 ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs create mode 100644 ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs create mode 100644 ErsatzTV/Resources/Scripts/_episode.js create mode 100644 ErsatzTV/Resources/Scripts/_movie.js create mode 100644 ErsatzTV/Resources/Scripts/_threePartEpisodes.js delete mode 100644 ErsatzTV/Resources/Scripts/_threePartEpisodes.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f67916b..3a397b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Add audio stream selector scripts for episodes and movies + - This will let you customize which audio stream is selected for playback + - Episodes are passed the following data: + - `channelNumber` + - `channelName` + - `showTitle` + - `showGuids`: array of string ids like `imdb_1234` or `tvdb_1234` + - `seasonNumber` + - `episodeNumber` + - `episodeGuids`: array of string ids like `imdb_1234` or `tvdb_1234` + - `preferredLanguageCodes`: array of string preferred language codes configured for the channel + - `audioStreams`: array of audio stream data, each containing + - `index`: the stream's index number, this is what the function needs to return + - `channels`: the number of audio channels + - `codec`: the audio codec + - `isDefault`: bool indicating whether the stream is flagged as default + - `isForced`: bool indicating whether the stream is flagged as forced + - `language`: the stream's language + - `title`: the stream's title + - Movies are passed the following data: + - `channelNumber` + - `channelName` + - `title` + - `guids`: array of string ids like `imdb_1234` or `tvdb_1234` + - `preferredLanguageCodes`: array of string preferred language codes configured for the channel + - `audioStreams`: array of audio stream data, each containing + - `index`: the stream's index number, this is what the function needs to return + - `channels`: the number of audio channels + - `codec`: the audio codec + - `isDefault`: bool indicating whether the stream is flagged as default + - `isForced`: bool indicating whether the stream is flagged as forced + - `language`: the stream's language + - `title`: the stream's title + +### Changed +- Change `Multi-Episode Shuffle` scripting system to use Javascript instead of Lua ## [0.6.9-beta] - 2022-10-21 ### Fixed diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj index cf1f8b81..11dc55bc 100644 --- a/ErsatzTV.Application/ErsatzTV.Application.csproj +++ b/ErsatzTV.Application/ErsatzTV.Application.csproj @@ -12,7 +12,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 87372bad..5ef75345 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -171,7 +171,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< saveReports, channel, videoVersion, - audioVersion, + new MediaItemAudioVersion(playoutItemWithPath.PlayoutItem.MediaItem, audioVersion), videoPath, audioPath, settings => GetSubtitles(playoutItemWithPath, channel, settings), diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 7a3f3279..1f121d02 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -9,7 +9,7 @@ - + @@ -17,7 +17,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs index 74b48611..d2291ae1 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs @@ -484,7 +484,7 @@ public class TranscodingTests SubtitleMode = subtitleMode }, v, - v, + new MediaItemAudioVersion(null, v), file, file, _ => subtitles.AsTask(), @@ -575,12 +575,12 @@ public class TranscodingTests version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); public Task> SelectAudioStream( - MediaVersion version, + MediaItemAudioVersion version, StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredAudioLanguage, string preferredAudioTitle) => - Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask(); + Optional(version.MediaVersion.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask(); public Task> SelectSubtitleStream( List subtitles, diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index d4f0c0e6..f59d0c3b 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -18,7 +18,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index a8deab5d..c0e7b3ee 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -48,7 +48,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService bool saveReports, Channel channel, MediaVersion videoVersion, - MediaVersion audioVersion, + MediaItemAudioVersion audioVersion, string videoPath, string audioPath, Func>> getSubtitles, @@ -77,7 +77,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService await _ffmpegStreamSelector.SelectAudioStream( audioVersion, channel.StreamingMode, - channel.Number, + channel, preferredAudioLanguage, preferredAudioTitle); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs index 6fe1746f..8cadce11 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs @@ -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.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 IScriptEngine _scriptEngine; + private readonly IStreamSelectorRepository _streamSelectorRepository; + private readonly ISearchRepository _searchRepository; private readonly IConfigElementRepository _configElementRepository; + private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; - private readonly ISearchRepository _searchRepository; public FFmpegStreamSelector( + IScriptEngine scriptEngine, + IStreamSelectorRepository streamSelectorRepository, ISearchRepository searchRepository, - ILogger logger, - IConfigElementRepository configElementRepository) + IConfigElementRepository configElementRepository, + ILocalFileSystem localFileSystem, + ILogger logger) { + _scriptEngine = scriptEngine; + _streamSelectorRepository = streamSelectorRepository; _searchRepository = searchRepository; - _logger = logger; _configElementRepository = configElementRepository; + _localFileSystem = localFileSystem; + _logger = logger; } public Task SelectVideoStream(MediaVersion version) => version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); public async Task> SelectAudioStream( - MediaVersion version, + MediaItemAudioVersion version, StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredAudioLanguage, string preferredAudioTitle) { @@ -36,16 +50,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector { _logger.LogDebug( "Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams", - channelNumber); + channel.Number); return None; } - var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList(); - string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant(); 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 maybeDefaultLanguage = await _configElementRepository.GetValue( ConfigElementKey.FFmpegPreferredLanguageCode); maybeDefaultLanguage.Match( @@ -57,33 +69,45 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector }); } - List allCodes = await _searchRepository.GetAllLanguageCodes(new List { language }); - if (allCodes.Count > 1) + List allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List { language }); + 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( - s => allCodes.Any( - c => string.Equals( - s.Language, - c, - StringComparison.InvariantCultureIgnoreCase))).ToList(); - if (correctLanguage.Any()) + + try { - _logger.LogDebug( - "Found {Count} audio streams with preferred audio language code(s) {Code}", - correctLanguage.Count, - allCodes); - - return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty); + switch (version.MediaItem) + { + case Episode: + var sw = Stopwatch.StartNew(); + Option result = await SelectEpisodeAudioStream( + 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 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( - "Unable to find audio stream with preferred audio language code(s) {Code}", - allCodes); - - return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty); + return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle); } public async Task> SelectSubtitleStream( @@ -165,9 +189,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector return None; } + private Option DefaultSelectAudioStream( + MediaVersion version, + List 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 PrioritizeAudioTitle(IReadOnlyCollection streams, string title) { - // return correctLanguage.OrderByDescending(s => s.Channels).Head(); if (string.IsNullOrWhiteSpace(title)) { _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(); } + + private async Task> SelectEpisodeAudioStream( + Channel channel, + List 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.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> SelectMovieAudioStream( + Channel channel, + List 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.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 ProcessScriptResult(MediaVersion version, object result) + { + if (result is double d) + { + var streamIndex = (int)d; + Option 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.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); } diff --git a/ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs b/ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs new file mode 100644 index 00000000..0b8f1de0 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.FFmpeg; + +public record MediaItemAudioVersion(MediaItem MediaItem, MediaVersion MediaVersion); diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 6929232d..199bb413 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -55,4 +55,7 @@ public static class FileSystemLayout public static readonly string MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle"); + + public static readonly string AudioStreamSelectorScriptsFolder = + Path.Combine(ScriptsFolder, "audio-stream-selector"); } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 5b5837bd..af0b9866 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -14,7 +14,7 @@ public interface IFFmpegProcessService bool saveReports, Channel channel, MediaVersion videoVersion, - MediaVersion audioVersion, + MediaItemAudioVersion audioVersion, string videoPath, string audioPath, Func>> getSubtitles, diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs index ff8f7820..bcfb6f04 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.Interfaces.FFmpeg; @@ -7,9 +8,9 @@ public interface IFFmpegStreamSelector Task SelectVideoStream(MediaVersion version); Task> SelectAudioStream( - MediaVersion version, + MediaItemAudioVersion version, StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredAudioLanguage, string preferredAudioTitle); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs new file mode 100644 index 00000000..469d639d --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Scripting; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IStreamSelectorRepository +{ + Task GetEpisodeData(int episodeId); + Task GetMovieData(int movieId); +} diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs b/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs index ad649227..553d552b 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling; public interface IMultiEpisodeShuffleCollectionEnumeratorFactory { IMediaCollectionEnumerator Create( - string luaScriptPath, + string jsScriptPath, IList mediaItems, CollectionEnumeratorState state); } diff --git a/ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs b/ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs new file mode 100644 index 00000000..232a7524 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Interfaces.Scripting; + +public interface IScriptEngine : IDisposable +{ + void Load(string jsScriptPath); + Task LoadAsync(string jsScriptPath); + object GetValue(string propertyName); + object Invoke(string functionName, params object[] args); +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 74b81d55..0519fdb0 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -765,18 +765,18 @@ public class PlayoutBuilder : IPlayoutBuilder { foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten()) { - string luaScriptPath = Path.ChangeExtension( + string jsScriptPath = Path.ChangeExtension( Path.Combine( FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, guid.Guid.Replace("://", "_")), - "lua"); - _logger.LogDebug("Checking for lua script at {Path}", luaScriptPath); - if (_localFileSystem.FileExists(luaScriptPath)) + "js"); + _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); + if (_localFileSystem.FileExists(jsScriptPath)) { - _logger.LogDebug("Found lua script at {Path}", luaScriptPath); + _logger.LogDebug("Found JS Script at {Path}", jsScriptPath); try { - return _multiEpisodeFactory.Create(luaScriptPath, mediaItems, state); + return _multiEpisodeFactory.Create(jsScriptPath, mediaItems, state); } catch (Exception ex) { diff --git a/ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs b/ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs new file mode 100644 index 00000000..2a94ba5b --- /dev/null +++ b/ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Scripting; + +public record EpisodeAudioStreamSelectorData( + string ShowTitle, + string[] ShowGuids, + int SeasonNumber, + int EpisodeNumber, + string[] EpisodeGuids); diff --git a/ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs b/ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs new file mode 100644 index 00000000..fe39fa6f --- /dev/null +++ b/ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Scripting; + +public record MovieAudioStreamSelectorData(string Title, string[] Guids); diff --git a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj index 267d14dd..c3cd9445 100644 --- a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj +++ b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj index bbff379e..12ba3c88 100644 --- a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj +++ b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj @@ -9,12 +9,15 @@ - + - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs new file mode 100644 index 00000000..12001bcc --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs @@ -0,0 +1,80 @@ +using Dapper; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scripting; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class StreamSelectorRepository : IStreamSelectorRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public StreamSelectorRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task GetEpisodeData(int episodeId) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + dynamic episodeData = await dbContext.Connection.QuerySingleAsync( + @"select + SM.Id AS ShowMetadataId, + SM.Title as ShowTitle, + S.SeasonNumber, + EM.EpisodeNumber, + EM.Id as EpisodeMetadataId + from EpisodeMetadata EM + inner join Episode E on EM.EpisodeId = E.Id + inner join Season S on S.Id == E.SeasonId + inner join ShowMetadata SM on S.ShowId = SM.ShowId + where EpisodeId = @Id", + new { Id = episodeId }); + + string[] showGuids = await dbContext.Connection + .QueryAsync( + @"SELECT Guid FROM MetadataGuid WHERE ShowMetadataId = @Id", + new { Id = (int)episodeData.ShowMetadataId }) + .MapT(FormatGuid) + .Map(result => result.ToArray()); + + string[] episodeGuids = await dbContext.Connection + .QueryAsync( + @"SELECT Guid FROM MetadataGuid WHERE EpisodeMetadataId = @Id", + new { Id = (int)episodeData.EpisodeMetadataId }) + .MapT(FormatGuid) + .Map(result => result.ToArray()); + + return new EpisodeAudioStreamSelectorData( + (string)episodeData.ShowTitle, + showGuids, + (int)episodeData.SeasonNumber, + (int)episodeData.EpisodeNumber, + episodeGuids); + } + + public async Task GetMovieData(int movieId) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + dynamic movieData = await dbContext.Connection.QuerySingleAsync( + @"select + MM.Id AS MovieMetadataId, + MM.Title as Title + from MovieMetadata MM + where MovieId = @Id", + new { Id = movieId }); + + string[] movieGuids = await dbContext.Connection + .QueryAsync( + @"SELECT Guid FROM MetadataGuid WHERE MovieMetadataId = @Id", + new { Id = (int)movieData.MovieMetadataId }) + .MapT(FormatGuid) + .Map(result => result.ToArray()); + + return new MovieAudioStreamSelectorData(movieData.Title, movieGuids); + } + + private static string FormatGuid(string guid) => guid.Replace("://", "_"); +} diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 0b1e6470..950ebaf3 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -11,6 +11,7 @@ + @@ -20,11 +21,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs index a3821fcf..859a3179 100644 --- a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs +++ b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs @@ -1,8 +1,8 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Scheduling; using Microsoft.Extensions.Logging; -using NLua; namespace ErsatzTV.Infrastructure.Scheduling; @@ -18,17 +18,18 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato public MultiEpisodeShuffleCollectionEnumerator( IList mediaItems, CollectionEnumeratorState state, + IScriptEngine scriptEngine, string scriptFile, ILogger logger) { _logger = logger; - using var lua = new Lua(); - lua.DoFile(scriptFile); - var numGroups = (int)(double)lua["numParts"]; + scriptEngine.Load(scriptFile); + + var numParts = (int)(double)scriptEngine.GetValue("numParts"); _mediaItemGroups = new Dictionary>(); - for (var i = 1; i <= numGroups; i++) + for (var i = 1; i <= numParts; i++) { _mediaItemGroups.Add(i, new List()); } @@ -36,24 +37,20 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato _ungrouped = new List(); _mediaItemCount = mediaItems.Count; - var groupForEpisode = (LuaFunction)lua["partNumberForEpisode"]; IList validEpisodes = mediaItems .OfType() .Filter(e => e.Season is not null && e.EpisodeMetadata is not null && e.EpisodeMetadata.Count == 1) .ToList(); foreach (Episode episode in validEpisodes) { - // prep lua params + // prep script params int seasonNumber = episode.Season.SeasonNumber; int episodeNumber = episode.EpisodeMetadata[0].EpisodeNumber; - // call the lua fn - object[] result = groupForEpisode.Call(seasonNumber, episodeNumber); - - // if we get a group number back, use it - if (result[0] is long groupNumber) + // call the script function, and if we get a part (group) number back, use it + if (scriptEngine.Invoke("partNumberForEpisode", seasonNumber, episodeNumber) is double result) { - _mediaItemGroups[(int)groupNumber].Add(episode); + _mediaItemGroups[(int)result].Add(episode); } else { diff --git a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs index b1cda998..f7ab8b20 100644 --- a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs +++ b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Interfaces.Scripting; using Microsoft.Extensions.Logging; namespace ErsatzTV.Infrastructure.Scheduling; @@ -7,17 +8,20 @@ namespace ErsatzTV.Infrastructure.Scheduling; public class MultiEpisodeShuffleCollectionEnumeratorFactory : IMultiEpisodeShuffleCollectionEnumeratorFactory { + private readonly IScriptEngine _scriptEngine; private readonly ILogger _logger; public MultiEpisodeShuffleCollectionEnumeratorFactory( + IScriptEngine scriptEngine, ILogger logger) { + _scriptEngine = scriptEngine; _logger = logger; } public IMediaCollectionEnumerator Create( - string luaScriptPath, + string jsScriptPath, IList mediaItems, CollectionEnumeratorState state) => - new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, luaScriptPath, _logger); + new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, _scriptEngine, jsScriptPath, _logger); } diff --git a/ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs b/ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs new file mode 100644 index 00000000..3230fd63 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs @@ -0,0 +1,45 @@ +using ErsatzTV.Core.Interfaces.Scripting; +using Jint; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Infrastructure.Scripting; + +public class ScriptEngine : IScriptEngine +{ + private Engine _engine; + + public ScriptEngine(ILogger logger) + { + _engine = new Engine( + options => + { + options.AllowClr(); + options.LimitMemory(4_000_000); + options.TimeoutInterval(TimeSpan.FromSeconds(4)); + options.MaxStatements(1000); + }) + .SetValue("log", new Action(s => logger.LogDebug("JS Script: {Message}", s))); + } + + public void Load(string jsScriptPath) + { + string contents = File.ReadAllText(jsScriptPath); + _engine.Execute(contents); + } + + public async Task LoadAsync(string jsScriptPath) + { + string contents = await File.ReadAllTextAsync(jsScriptPath); + _engine.Execute(contents); + } + + public object GetValue(string propertyName) => _engine.GetValue(propertyName).ToObject(); + + public object Invoke(string functionName, params object[] args) => _engine.Invoke(functionName, args).ToObject(); + + public void Dispose() + { + _engine?.Dispose(); + _engine = null; + } +} diff --git a/ErsatzTV.sln b/ErsatzTV.sln index 7160e020..11c53b1c 100644 --- a/ErsatzTV.sln +++ b/ErsatzTV.sln @@ -23,12 +23,12 @@ Global Debug No Sync|Any CPU = Debug No Sync|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug No Sync|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug No Sync|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 87bbb89a..9f2d2c84 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -66,7 +66,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -98,7 +98,9 @@ - + + + diff --git a/ErsatzTV/Resources/Scripts/_episode.js b/ErsatzTV/Resources/Scripts/_episode.js new file mode 100644 index 00000000..67d5e0f9 --- /dev/null +++ b/ErsatzTV/Resources/Scripts/_episode.js @@ -0,0 +1,22 @@ +// copy this file to episode.js (no underscore) and modify + +function selectEpisodeAudioStreamIndex( + channelNumber, + channelName, + showTitle, + showGuids, + seasonNumber, + episodeNumber, + episodeGuids, + preferredLanguageCodes, + audioStreams +) { + // check for a particular show using something like + // if (showGuids.includes("tvdb_12345")) { ... } + + // write debug messages to etv's log using + // log("message"); + + // return null to use built-in logic + return null; +} \ No newline at end of file diff --git a/ErsatzTV/Resources/Scripts/_movie.js b/ErsatzTV/Resources/Scripts/_movie.js new file mode 100644 index 00000000..b04f1459 --- /dev/null +++ b/ErsatzTV/Resources/Scripts/_movie.js @@ -0,0 +1,19 @@ +// copy this file to movie.js (no underscore) and modify + +function selectMovieAudioStreamIndex( + channelNumber, + channelName, + title, + guids, + preferredLanguageCodes, + audioStreams +) { + // check for a particular movie using something like + // if (guids.includes("tvdb_12345")) { ... } + + // write debug messages to etv's log using + // log("message"); + + // return null to use built-in logic + return null; +} \ No newline at end of file diff --git a/ErsatzTV/Resources/Scripts/_threePartEpisodes.js b/ErsatzTV/Resources/Scripts/_threePartEpisodes.js new file mode 100644 index 00000000..f328a224 --- /dev/null +++ b/ErsatzTV/Resources/Scripts/_threePartEpisodes.js @@ -0,0 +1,9 @@ +// the number of parts that each un-split file typically contains +// noinspection ES6ConvertVarToLetConst +var numParts = 3; + +// return the part number for the given season number and episode number +function partNumberForEpisode(seasonNumber, episodeNumber) { + const mod = episodeNumber % 3; + return mod === 0 ? 3 : mod; +} diff --git a/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua b/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua deleted file mode 100644 index bd380e38..00000000 --- a/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua +++ /dev/null @@ -1,13 +0,0 @@ --- the number of parts that each un-split file typically contains -numParts = 3 - --- return the part number for the given season number and episode number -function partNumberForEpisode(seasonNumber, episodeNumber) - local mod = episodeNumber % 3 - if mod == 0 - then - return 3 - else - return mod - end -end diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index 24ad3ac8..193b7ec9 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -31,9 +31,21 @@ public class ResourceExtractorService : IHostedService await ExtractScriptResource( assembly, - "_threePartEpisodes.lua", + "_threePartEpisodes.js", FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, cancellationToken); + + await ExtractScriptResource( + assembly, + "_episode.js", + FileSystemLayout.AudioStreamSelectorScriptsFolder, + cancellationToken); + + await ExtractScriptResource( + assembly, + "_movie.js", + FileSystemLayout.AudioStreamSelectorScriptsFolder, + cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 92492d26..49ea3e41 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -24,6 +24,7 @@ using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Jellyfin; @@ -49,6 +50,7 @@ using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Scheduling; +using ErsatzTV.Infrastructure.Scripting; using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Serialization; @@ -219,6 +221,11 @@ public class Startup Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder); } + if (!Directory.Exists(FileSystemLayout.AudioStreamSelectorScriptsFolder)) + { + Directory.CreateDirectory(FileSystemLayout.AudioStreamSelectorScriptsFolder); + } + Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); // until we add a setting for a file-specific scheme://host:port to access @@ -437,6 +444,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -463,6 +471,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped();