Browse Source

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
pull/1007/head
Jason Dove 3 years ago committed by GitHub
parent
commit
c9bd94d9f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 37
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/ErsatzTV.Application.csproj
  3. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  4. 4
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  5. 8
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  6. 2
      ErsatzTV.Core/ErsatzTV.Core.csproj
  7. 4
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  8. 243
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  9. 5
      ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs
  10. 3
      ErsatzTV.Core/FileSystemLayout.cs
  11. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  12. 5
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs
  13. 9
      ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs
  14. 2
      ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs
  15. 9
      ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs
  16. 12
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  17. 8
      ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs
  18. 3
      ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs
  19. 2
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  20. 7
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  21. 80
      ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs
  22. 4
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  23. 23
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs
  24. 8
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs
  25. 45
      ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs
  26. 4
      ErsatzTV.sln
  27. 6
      ErsatzTV/ErsatzTV.csproj
  28. 22
      ErsatzTV/Resources/Scripts/_episode.js
  29. 19
      ErsatzTV/Resources/Scripts/_movie.js
  30. 9
      ErsatzTV/Resources/Scripts/_threePartEpisodes.js
  31. 13
      ErsatzTV/Resources/Scripts/_threePartEpisodes.lua
  32. 14
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  33. 9
      ErsatzTV/Startup.cs

37
CHANGELOG.md

@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file. @@ -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

2
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -171,7 +171,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -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),

4
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -9,7 +9,7 @@ @@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

8
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -484,7 +484,7 @@ public class TranscodingTests @@ -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 @@ -575,12 +575,12 @@ public class TranscodingTests
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public Task<Option<MediaStream>> 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<Option<Domain.Subtitle>> SelectSubtitleStream(
List<Domain.Subtitle> subtitles,

2
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

4
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -48,7 +48,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -48,7 +48,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaVersion audioVersion,
MediaItemAudioVersion audioVersion,
string videoPath,
string audioPath,
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,
@ -77,7 +77,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -77,7 +77,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel.Number,
channel,
preferredAudioLanguage,
preferredAudioTitle);

243
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -1,33 +1,47 @@ @@ -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<FFmpegStreamSelector> _logger;
private readonly ISearchRepository _searchRepository;
public FFmpegStreamSelector(
IScriptEngine scriptEngine,
IStreamSelectorRepository streamSelectorRepository,
ISearchRepository searchRepository,
ILogger<FFmpegStreamSelector> logger,
IConfigElementRepository configElementRepository)
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<FFmpegStreamSelector> logger)
{
_scriptEngine = scriptEngine;
_streamSelectorRepository = streamSelectorRepository;
_searchRepository = searchRepository;
_logger = logger;
_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(
MediaVersion version,
MediaItemAudioVersion version,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredAudioLanguage,
string preferredAudioTitle)
{
@ -36,16 +50,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -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<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode);
maybeDefaultLanguage.Match(
@ -57,33 +69,45 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -57,33 +69,45 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
});
}
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1)
List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { 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<MediaStream> 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<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(
"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<Option<Subtitle>> SelectSubtitleStream(
@ -165,9 +189,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -165,9 +189,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
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)
{
// 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 @@ -194,4 +248,125 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
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);
}

5
ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.FFmpeg;
public record MediaItemAudioVersion(MediaItem MediaItem, MediaVersion MediaVersion);

3
ErsatzTV.Core/FileSystemLayout.cs

@ -55,4 +55,7 @@ public static class FileSystemLayout @@ -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");
}

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -14,7 +14,7 @@ public interface IFFmpegProcessService @@ -14,7 +14,7 @@ public interface IFFmpegProcessService
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaVersion audioVersion,
MediaItemAudioVersion audioVersion,
string videoPath,
string audioPath,
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
@ -7,9 +8,9 @@ public interface IFFmpegStreamSelector @@ -7,9 +8,9 @@ public interface IFFmpegStreamSelector
Task<MediaStream> SelectVideoStream(MediaVersion version);
Task<Option<MediaStream>> SelectAudioStream(
MediaVersion version,
MediaItemAudioVersion version,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredAudioLanguage,
string preferredAudioTitle);

9
ErsatzTV.Core/Interfaces/Repositories/IStreamSelectorRepository.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core.Scripting;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IStreamSelectorRepository
{
Task<EpisodeAudioStreamSelectorData> GetEpisodeData(int episodeId);
Task<MovieAudioStreamSelectorData> GetMovieData(int movieId);
}

2
ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IMultiEpisodeShuffleCollectionEnumeratorFactory
{
IMediaCollectionEnumerator Create(
string luaScriptPath,
string jsScriptPath,
IList<MediaItem> mediaItems,
CollectionEnumeratorState state);
}

9
ErsatzTV.Core/Interfaces/Scripting/IScriptEngine.cs

@ -0,0 +1,9 @@ @@ -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);
}

12
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -765,18 +765,18 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -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)
{

8
ErsatzTV.Core/Scripting/EpisodeAudioStreamSelectorData.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Scripting;
public record EpisodeAudioStreamSelectorData(
string ShowTitle,
string[] ShowGuids,
int SeasonNumber,
int EpisodeNumber,
string[] EpisodeGuids);

3
ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Scripting;
public record MovieAudioStreamSelectorData(string Title, string[] Guids);

2
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="NUnit" Version="3.13.3" />

7
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -9,12 +9,15 @@ @@ -9,12 +9,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.5.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>

80
ErsatzTV.Infrastructure/Data/Repositories/StreamSelectorRepository.cs

@ -0,0 +1,80 @@ @@ -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<TvContext> _dbContextFactory;
public StreamSelectorRepository(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<EpisodeAudioStreamSelectorData> 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<string>(
@"SELECT Guid FROM MetadataGuid WHERE ShowMetadataId = @Id",
new { Id = (int)episodeData.ShowMetadataId })
.MapT(FormatGuid)
.Map(result => result.ToArray());
string[] episodeGuids = await dbContext.Connection
.QueryAsync<string>(
@"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<MovieAudioStreamSelectorData> 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<string>(
@"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("://", "_");
}

4
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Jint" Version="3.0.0-beta-2042" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
@ -20,11 +21,10 @@ @@ -20,11 +21,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NLua" Version="1.6.0" />
<PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" />
<PackageReference Include="Refit.Xml" Version="6.3.2" />

23
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -1,8 +1,8 @@ @@ -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 @@ -18,17 +18,18 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
public MultiEpisodeShuffleCollectionEnumerator(
IList<MediaItem> 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<int, List<MediaItem>>();
for (var i = 1; i <= numGroups; i++)
for (var i = 1; i <= numParts; i++)
{
_mediaItemGroups.Add(i, new List<MediaItem>());
}
@ -36,24 +37,20 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato @@ -36,24 +37,20 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
_ungrouped = new List<MediaItem>();
_mediaItemCount = mediaItems.Count;
var groupForEpisode = (LuaFunction)lua["partNumberForEpisode"];
IList<Episode> validEpisodes = mediaItems
.OfType<Episode>()
.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
{

8
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs

@ -1,5 +1,6 @@ @@ -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; @@ -7,17 +8,20 @@ namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumeratorFactory
: IMultiEpisodeShuffleCollectionEnumeratorFactory
{
private readonly IScriptEngine _scriptEngine;
private readonly ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> _logger;
public MultiEpisodeShuffleCollectionEnumeratorFactory(
IScriptEngine scriptEngine,
ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> logger)
{
_scriptEngine = scriptEngine;
_logger = logger;
}
public IMediaCollectionEnumerator Create(
string luaScriptPath,
string jsScriptPath,
IList<MediaItem> mediaItems,
CollectionEnumeratorState state) =>
new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, luaScriptPath, _logger);
new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, _scriptEngine, jsScriptPath, _logger);
}

45
ErsatzTV.Infrastructure/Scripting/ScriptEngine.cs

@ -0,0 +1,45 @@ @@ -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<ScriptEngine> logger)
{
_engine = new Engine(
options =>
{
options.AllowClr();
options.LimitMemory(4_000_000);
options.TimeoutInterval(TimeSpan.FromSeconds(4));
options.MaxStatements(1000);
})
.SetValue("log", new Action<string>(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;
}
}

4
ErsatzTV.sln

@ -23,12 +23,12 @@ Global @@ -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

6
ErsatzTV/ErsatzTV.csproj

@ -66,7 +66,7 @@ @@ -66,7 +66,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -98,7 +98,9 @@ @@ -98,7 +98,9 @@
<ItemGroup>
<EmbeddedResource Include="Resources\background.png" />
<EmbeddedResource Include="Resources\Scripts\_threePartEpisodes.lua" />
<EmbeddedResource Include="Resources\Scripts\_threePartEpisodes.js" />
<EmbeddedResource Include="Resources\Scripts\_episode.js" />
<EmbeddedResource Include="Resources\Scripts\_movie.js" />
<EmbeddedResource Include="Resources\song_background_1.png" />
<EmbeddedResource Include="Resources\song_background_2.png" />
<EmbeddedResource Include="Resources\song_background_3.png" />

22
ErsatzTV/Resources/Scripts/_episode.js

@ -0,0 +1,22 @@ @@ -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;
}

19
ErsatzTV/Resources/Scripts/_movie.js

@ -0,0 +1,19 @@ @@ -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;
}

9
ErsatzTV/Resources/Scripts/_threePartEpisodes.js

@ -0,0 +1,9 @@ @@ -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;
}

13
ErsatzTV/Resources/Scripts/_threePartEpisodes.lua

@ -1,13 +0,0 @@ @@ -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

14
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -31,9 +31,21 @@ public class ResourceExtractorService : IHostedService @@ -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;

9
ErsatzTV/Startup.cs

@ -24,6 +24,7 @@ using ErsatzTV.Core.Interfaces.Plex; @@ -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; @@ -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 @@ -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 @@ -437,6 +444,7 @@ public class Startup
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory,
MultiEpisodeShuffleCollectionEnumeratorFactory>();
@ -463,6 +471,7 @@ public class Startup @@ -463,6 +471,7 @@ public class Startup
services.AddScoped<IMusicVideoNfoReader, MusicVideoNfoReader>();
services.AddScoped<ITvShowNfoReader, TvShowNfoReader>();
services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>();
services.AddScoped<IScriptEngine, ScriptEngine>();
services.AddScoped<PlexEtag>();

Loading…
Cancel
Save