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();