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.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [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 ## [0.6.9-beta] - 2022-10-21
### Fixed ### Fixed

2
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -12,7 +12,7 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="11.0.0" /> <PackageReference Include="MediatR" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

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

@ -171,7 +171,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
saveReports, saveReports,
channel, channel,
videoVersion, videoVersion,
audioVersion, new MediaItemAudioVersion(playoutItemWithPath.PlayoutItem.MediaItem, audioVersion),
videoPath, videoPath,
audioPath, audioPath,
settings => GetSubtitles(playoutItemWithPath, channel, settings), settings => GetSubtitles(playoutItemWithPath, channel, settings),

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

@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" /> <PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.5.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="LanguageExt.Core" Version="4.2.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
@ -17,7 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

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

@ -484,7 +484,7 @@ public class TranscodingTests
SubtitleMode = subtitleMode SubtitleMode = subtitleMode
}, },
v, v,
v, new MediaItemAudioVersion(null, v),
file, file,
file, file,
_ => subtitles.AsTask(), _ => subtitles.AsTask(),
@ -575,12 +575,12 @@ public class TranscodingTests
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public Task<Option<MediaStream>> SelectAudioStream( public Task<Option<MediaStream>> SelectAudioStream(
MediaVersion version, MediaItemAudioVersion version,
StreamingMode streamingMode, StreamingMode streamingMode,
string channelNumber, Channel channel,
string preferredAudioLanguage, string preferredAudioLanguage,
string preferredAudioTitle) => 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( public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
List<Domain.Subtitle> subtitles, List<Domain.Subtitle> subtitles,

2
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

4
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

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

243
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.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Scripting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class FFmpegStreamSelector : IFFmpegStreamSelector public class FFmpegStreamSelector : IFFmpegStreamSelector
{ {
private readonly IScriptEngine _scriptEngine;
private readonly IStreamSelectorRepository _streamSelectorRepository;
private readonly ISearchRepository _searchRepository;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<FFmpegStreamSelector> _logger; private readonly ILogger<FFmpegStreamSelector> _logger;
private readonly ISearchRepository _searchRepository;
public FFmpegStreamSelector( public FFmpegStreamSelector(
IScriptEngine scriptEngine,
IStreamSelectorRepository streamSelectorRepository,
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILogger<FFmpegStreamSelector> logger, IConfigElementRepository configElementRepository,
IConfigElementRepository configElementRepository) ILocalFileSystem localFileSystem,
ILogger<FFmpegStreamSelector> logger)
{ {
_scriptEngine = scriptEngine;
_streamSelectorRepository = streamSelectorRepository;
_searchRepository = searchRepository; _searchRepository = searchRepository;
_logger = logger;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_logger = logger;
} }
public Task<MediaStream> SelectVideoStream(MediaVersion version) => public Task<MediaStream> SelectVideoStream(MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public async Task<Option<MediaStream>> SelectAudioStream( public async Task<Option<MediaStream>> SelectAudioStream(
MediaVersion version, MediaItemAudioVersion version,
StreamingMode streamingMode, StreamingMode streamingMode,
string channelNumber, Channel channel,
string preferredAudioLanguage, string preferredAudioLanguage,
string preferredAudioTitle) string preferredAudioTitle)
{ {
@ -36,16 +50,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
{ {
_logger.LogDebug( _logger.LogDebug(
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams", "Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams",
channelNumber); channel.Number);
return None; return None;
} }
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant(); string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language)) if (string.IsNullOrWhiteSpace(language))
{ {
_logger.LogDebug("Channel {Number} has no preferred audio language code", channelNumber); _logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number);
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>( Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode); ConfigElementKey.FFmpegPreferredLanguageCode);
maybeDefaultLanguage.Match( maybeDefaultLanguage.Match(
@ -57,33 +69,45 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}); });
} }
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language }); List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1) if (allLanguageCodes.Count > 1)
{ {
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allCodes); _logger.LogDebug("Preferred audio language has multiple codes {Codes}", allLanguageCodes);
} }
var correctLanguage = audioStreams.Filter( try
s => allCodes.Any(
c => string.Equals(
s.Language,
c,
StringComparison.InvariantCultureIgnoreCase))).ToList();
if (correctLanguage.Any())
{ {
_logger.LogDebug( switch (version.MediaItem)
"Found {Count} audio streams with preferred audio language code(s) {Code}", {
correctLanguage.Count, case Episode:
allCodes); var sw = Stopwatch.StartNew();
Option<MediaStream> result = await SelectEpisodeAudioStream(
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty); channel,
allLanguageCodes,
version.MediaItem.Id,
version.MediaVersion);
sw.Stop();
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw.Elapsed);
return result;
case Movie:
var sw2 = Stopwatch.StartNew();
Option<MediaStream> result2 = await SelectMovieAudioStream(
channel,
allLanguageCodes,
version.MediaItem.Id,
version.MediaVersion);
sw2.Stop();
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw2.Elapsed);
return result2;
// let default fall through
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic");
} }
_logger.LogDebug( return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle);
"Unable to find audio stream with preferred audio language code(s) {Code}",
allCodes);
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty);
} }
public async Task<Option<Subtitle>> SelectSubtitleStream( public async Task<Option<Subtitle>> SelectSubtitleStream(
@ -165,9 +189,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
return None; return None;
} }
private Option<MediaStream> DefaultSelectAudioStream(
MediaVersion version,
List<string> preferredLanguageCodes,
string preferredAudioTitle)
{
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
var correctLanguage = audioStreams.Filter(
s => preferredLanguageCodes.Any(
c => string.Equals(
s.Language,
c,
StringComparison.InvariantCultureIgnoreCase))).ToList();
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred audio language code(s) {Code}",
correctLanguage.Count,
preferredLanguageCodes);
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty);
}
_logger.LogDebug(
"Unable to find audio stream with preferred audio language code(s) {Code}",
preferredLanguageCodes);
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty);
}
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title) private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title)
{ {
// return correctLanguage.OrderByDescending(s => s.Channels).Head();
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
{ {
_logger.LogDebug("No audio title has been specified; selecting stream with most channels"); _logger.LogDebug("No audio title has been specified; selecting stream with most channels");
@ -194,4 +248,125 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
return streams.OrderByDescending(s => s.Channels).Head(); return streams.OrderByDescending(s => s.Channels).Head();
} }
private async Task<Option<MediaStream>> SelectEpisodeAudioStream(
Channel channel,
List<string> preferredLanguageCodes,
int episodeId,
MediaVersion version)
{
string jsScriptPath = Path.ChangeExtension(
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "episode"),
"js");
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (!_localFileSystem.FileExists(jsScriptPath))
{
_logger.LogWarning("Unable to locate episode audio stream selector script; falling back to built-in logic");
return Option<MediaStream>.None;
}
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
await _scriptEngine.LoadAsync(jsScriptPath);
EpisodeAudioStreamSelectorData data = await _streamSelectorRepository.GetEpisodeData(episodeId);
AudioStream[] audioStreams = GetAudioStreamsForScript(version);
object result = _scriptEngine.Invoke(
"selectEpisodeAudioStreamIndex",
channel.Number,
channel.Name,
data.ShowTitle,
data.ShowGuids,
data.SeasonNumber,
data.EpisodeNumber,
data.EpisodeGuids,
preferredLanguageCodes.ToArray(),
audioStreams);
return ProcessScriptResult(version, result);
}
private async Task<Option<MediaStream>> SelectMovieAudioStream(
Channel channel,
List<string> preferredLanguageCodes,
int movieId,
MediaVersion version)
{
string jsScriptPath = Path.ChangeExtension(
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "movie"),
"js");
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (!_localFileSystem.FileExists(jsScriptPath))
{
_logger.LogWarning("Unable to locate movie audio stream selector script; falling back to built-in logic");
return Option<MediaStream>.None;
}
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
await _scriptEngine.LoadAsync(jsScriptPath);
MovieAudioStreamSelectorData data = await _streamSelectorRepository.GetMovieData(movieId);
AudioStream[] audioStreams = GetAudioStreamsForScript(version);
object result = _scriptEngine.Invoke(
"selectMovieAudioStreamIndex",
channel.Number,
channel.Name,
data.Title,
data.Guids,
preferredLanguageCodes.ToArray(),
audioStreams);
return ProcessScriptResult(version, result);
}
private Option<MediaStream> ProcessScriptResult(MediaVersion version, object result)
{
if (result is double d)
{
var streamIndex = (int)d;
Option<MediaStream> maybeStream = version.Streams.Find(s => s.Index == streamIndex);
foreach (MediaStream stream in maybeStream)
{
_logger.LogDebug(
"JS Script returned audio stream index {Index} with language {Language} and {Channels} audio channel(s)",
streamIndex,
stream.Language,
stream.Channels);
return stream;
}
_logger.LogWarning(
"JS Script returned audio stream index {Index} which does not exist",
streamIndex);
}
else
{
_logger.LogInformation(
"JS Script did not return an audio stream index; falling back to built-in logic");
}
return Option<MediaStream>.None;
}
private static AudioStream[] GetAudioStreamsForScript(MediaVersion version) => version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio)
.Map(a => new AudioStream(a.Index, a.Channels, a.Codec, a.Default, a.Forced, a.Language, a.Title))
.ToArray();
[SuppressMessage("ReSharper", "InconsistentNaming")]
private record AudioStream(
int index,
int channels,
string codec,
bool isDefault,
bool isForced,
string language,
string title);
} }

5
ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs

@ -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
public static readonly string MultiEpisodeShuffleTemplatesFolder = public static readonly string MultiEpisodeShuffleTemplatesFolder =
Path.Combine(ScriptsFolder, "multi-episode-shuffle"); 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
bool saveReports, bool saveReports,
Channel channel, Channel channel,
MediaVersion videoVersion, MediaVersion videoVersion,
MediaVersion audioVersion, MediaItemAudioVersion audioVersion,
string videoPath, string videoPath,
string audioPath, string audioPath,
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles, Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,

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

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

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

@ -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;
public interface IMultiEpisodeShuffleCollectionEnumeratorFactory public interface IMultiEpisodeShuffleCollectionEnumeratorFactory
{ {
IMediaCollectionEnumerator Create( IMediaCollectionEnumerator Create(
string luaScriptPath, string jsScriptPath,
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state); CollectionEnumeratorState state);
} }

9
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);
}

12
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -765,18 +765,18 @@ public class PlayoutBuilder : IPlayoutBuilder
{ {
foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten()) foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten())
{ {
string luaScriptPath = Path.ChangeExtension( string jsScriptPath = Path.ChangeExtension(
Path.Combine( Path.Combine(
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
guid.Guid.Replace("://", "_")), guid.Guid.Replace("://", "_")),
"lua"); "js");
_logger.LogDebug("Checking for lua script at {Path}", luaScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (_localFileSystem.FileExists(luaScriptPath)) if (_localFileSystem.FileExists(jsScriptPath))
{ {
_logger.LogDebug("Found lua script at {Path}", luaScriptPath); _logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
try try
{ {
return _multiEpisodeFactory.Create(luaScriptPath, mediaItems, state); return _multiEpisodeFactory.Create(jsScriptPath, mediaItems, state);
} }
catch (Exception ex) catch (Exception ex)
{ {

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

3
ErsatzTV.Core/Scripting/MovieAudioStreamSelectorData.cs

@ -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 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />

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

@ -9,12 +9,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <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" /> <PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup> </ItemGroup>

80
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<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 @@
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" /> <PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.5.0" /> <PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="Dapper" Version="2.0.123" /> <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" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" 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" /> <PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
@ -20,11 +21,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.10" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NLua" Version="1.6.0" />
<PackageReference Include="Refit" Version="6.3.2" /> <PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" /> <PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" />
<PackageReference Include="Refit.Xml" Version="6.3.2" /> <PackageReference Include="Refit.Xml" Version="6.3.2" />

23
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -1,8 +1,8 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NLua;
namespace ErsatzTV.Infrastructure.Scheduling; namespace ErsatzTV.Infrastructure.Scheduling;
@ -18,17 +18,18 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
public MultiEpisodeShuffleCollectionEnumerator( public MultiEpisodeShuffleCollectionEnumerator(
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state, CollectionEnumeratorState state,
IScriptEngine scriptEngine,
string scriptFile, string scriptFile,
ILogger logger) ILogger logger)
{ {
_logger = 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>>(); _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>()); _mediaItemGroups.Add(i, new List<MediaItem>());
} }
@ -36,24 +37,20 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
_ungrouped = new List<MediaItem>(); _ungrouped = new List<MediaItem>();
_mediaItemCount = mediaItems.Count; _mediaItemCount = mediaItems.Count;
var groupForEpisode = (LuaFunction)lua["partNumberForEpisode"];
IList<Episode> validEpisodes = mediaItems IList<Episode> validEpisodes = mediaItems
.OfType<Episode>() .OfType<Episode>()
.Filter(e => e.Season is not null && e.EpisodeMetadata is not null && e.EpisodeMetadata.Count == 1) .Filter(e => e.Season is not null && e.EpisodeMetadata is not null && e.EpisodeMetadata.Count == 1)
.ToList(); .ToList();
foreach (Episode episode in validEpisodes) foreach (Episode episode in validEpisodes)
{ {
// prep lua params // prep script params
int seasonNumber = episode.Season.SeasonNumber; int seasonNumber = episode.Season.SeasonNumber;
int episodeNumber = episode.EpisodeMetadata[0].EpisodeNumber; int episodeNumber = episode.EpisodeMetadata[0].EpisodeNumber;
// call the lua fn // call the script function, and if we get a part (group) number back, use it
object[] result = groupForEpisode.Call(seasonNumber, episodeNumber); if (scriptEngine.Invoke("partNumberForEpisode", seasonNumber, episodeNumber) is double result)
// if we get a group number back, use it
if (result[0] is long groupNumber)
{ {
_mediaItemGroups[(int)groupNumber].Add(episode); _mediaItemGroups[(int)result].Add(episode);
} }
else else
{ {

8
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs

@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Scheduling; namespace ErsatzTV.Infrastructure.Scheduling;
@ -7,17 +8,20 @@ namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumeratorFactory public class MultiEpisodeShuffleCollectionEnumeratorFactory
: IMultiEpisodeShuffleCollectionEnumeratorFactory : IMultiEpisodeShuffleCollectionEnumeratorFactory
{ {
private readonly IScriptEngine _scriptEngine;
private readonly ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> _logger; private readonly ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> _logger;
public MultiEpisodeShuffleCollectionEnumeratorFactory( public MultiEpisodeShuffleCollectionEnumeratorFactory(
IScriptEngine scriptEngine,
ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> logger) ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> logger)
{ {
_scriptEngine = scriptEngine;
_logger = logger; _logger = logger;
} }
public IMediaCollectionEnumerator Create( public IMediaCollectionEnumerator Create(
string luaScriptPath, string jsScriptPath,
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state) => 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 @@
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
Debug No Sync|Any CPU = Debug No Sync|Any CPU Debug No Sync|Any CPU = Debug No Sync|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution 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.ActiveCfg = Release|Any CPU
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = 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.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 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.ActiveCfg = Debug|Any CPU
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = 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 {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU

6
ErsatzTV/ErsatzTV.csproj

@ -66,7 +66,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.48">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@ -98,7 +98,9 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\background.png" /> <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_1.png" />
<EmbeddedResource Include="Resources\song_background_2.png" /> <EmbeddedResource Include="Resources\song_background_2.png" />
<EmbeddedResource Include="Resources\song_background_3.png" /> <EmbeddedResource Include="Resources\song_background_3.png" />

22
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;
}

19
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;
}

9
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;
}

13
ErsatzTV/Resources/Scripts/_threePartEpisodes.lua

@ -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
await ExtractScriptResource( await ExtractScriptResource(
assembly, assembly,
"_threePartEpisodes.lua", "_threePartEpisodes.js",
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
cancellationToken); cancellationToken);
await ExtractScriptResource(
assembly,
"_episode.js",
FileSystemLayout.AudioStreamSelectorScriptsFolder,
cancellationToken);
await ExtractScriptResource(
assembly,
"_movie.js",
FileSystemLayout.AudioStreamSelectorScriptsFolder,
cancellationToken);
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

9
ErsatzTV/Startup.cs

@ -24,6 +24,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
@ -49,6 +50,7 @@ using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Infrastructure.Scheduling; using ErsatzTV.Infrastructure.Scheduling;
using ErsatzTV.Infrastructure.Scripting;
using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Serialization; using ErsatzTV.Serialization;
@ -219,6 +221,11 @@ public class Startup
Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder); Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder);
} }
if (!Directory.Exists(FileSystemLayout.AudioStreamSelectorScriptsFolder))
{
Directory.CreateDirectory(FileSystemLayout.AudioStreamSelectorScriptsFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access // until we add a setting for a file-specific scheme://host:port to access
@ -437,6 +444,7 @@ public class Startup
services.AddScoped<IRuntimeInfo, RuntimeInfo>(); services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>(); services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>(); services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>(); services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory, services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory,
MultiEpisodeShuffleCollectionEnumeratorFactory>(); MultiEpisodeShuffleCollectionEnumeratorFactory>();
@ -463,6 +471,7 @@ public class Startup
services.AddScoped<IMusicVideoNfoReader, MusicVideoNfoReader>(); services.AddScoped<IMusicVideoNfoReader, MusicVideoNfoReader>();
services.AddScoped<ITvShowNfoReader, TvShowNfoReader>(); services.AddScoped<ITvShowNfoReader, TvShowNfoReader>();
services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>(); services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>();
services.AddScoped<IScriptEngine, ScriptEngine>();
services.AddScoped<PlexEtag>(); services.AddScoped<PlexEtag>();

Loading…
Cancel
Save