Browse Source

add mpegts script system (#2609)

* add basic mpegts script

* use custom mpegts script

* update changelog
pull/2610/head
Jason Dove 2 months ago committed by GitHub
parent
commit
053b3cd1d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Configuration/Queries/GetMpegTsScripts.cs
  3. 14
      ErsatzTV.Application/Configuration/Queries/GetMpegTsScriptsHandler.cs
  4. 4
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  5. 1
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  6. 55
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  7. 2
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  8. 3
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  9. 7
      ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs
  10. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  11. 27
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  12. 18
      ErsatzTV.Core/FFmpeg/MpegTsScript.cs
  13. 1
      ErsatzTV.Core/FFmpeg/TempFileCategory.cs
  14. 7
      ErsatzTV.Core/FileSystemLayout.cs
  15. 3
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  16. 14
      ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs
  17. 136
      ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs
  18. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  19. 2
      ErsatzTV/ErsatzTV.csproj
  20. 17
      ErsatzTV/Pages/Settings/FFmpegSettings.razor
  21. 2
      ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml
  22. 3
      ErsatzTV/Resources/Scripts/MpegTs/run.sh
  23. 26
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  24. 9
      ErsatzTV/Services/SchedulerService.cs
  25. 5
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
- Add `MediaItem_Start` template data (DateTimeOffset)
- Add `MediaItem_Stop` template data (DateTimeOffset)
- Add `ScaledResolution` (the final size of the frame before padding)
- Add `ScaledResolution` template data (the final size of the frame before padding)
- Add `place_within_source_content` (true/false) field to image graphics element
- Classic schedules: add collection type `Search Query`
- This allows defining search queries directly on schedule items without creating smart collections beforehand
@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
- Scripted schedules: add `custom_title` to `start_epg_group`
- Add MPEG-TS Script system
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
- Scripts live in config / scripts / mpegts
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (powershell) and linux (bash) scripts
### Fixed
- Fix HLS Direct playback with Jellyfin 10.11

5
ErsatzTV.Application/Configuration/Queries/GetMpegTsScripts.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;

14
ErsatzTV.Application/Configuration/Queries/GetMpegTsScriptsHandler.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
{
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
{
await mpegTsScriptService.RefreshScripts();
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
}
}

4
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -85,6 +85,10 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, @@ -85,6 +85,10 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
ConfigElementKey.FFmpegHlsDirectOutputFormat,
request.Settings.HlsDirectOutputFormat,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegDefaultMpegTsScript,
request.Settings.DefaultMpegTsScript,
cancellationToken);
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{

1
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -17,4 +17,5 @@ public class FFmpegSettingsViewModel @@ -17,4 +17,5 @@ public class FFmpegSettingsViewModel
public int WorkAheadSegmenterLimit { get; set; }
public int InitialSegmentCount { get; set; }
public OutputFormatKind HlsDirectOutputFormat { get; set; }
public string DefaultMpegTsScript { get; set; }
}

55
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -4,41 +4,55 @@ using ErsatzTV.FFmpeg.OutputFormat; @@ -4,41 +4,55 @@ using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Application.FFmpegProfiles;
public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<FFmpegSettingsViewModel> Handle(
GetFFmpegSettings request,
CancellationToken cancellationToken)
{
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
Option<string> ffmpegPath = await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPath,
cancellationToken);
Option<string> ffprobePath = await configElementRepository.GetValue<string>(
ConfigElementKey.FFprobePath,
cancellationToken);
Option<int> defaultFFmpegProfileId =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
await configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
Option<string> preferredAudioLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode,
cancellationToken);
Option<bool> useEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
cancellationToken);
Option<bool> extractEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
cancellationToken);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
await configElementRepository.GetValue<int>(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
cancellationToken);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
Option<int> initialSegmentCount =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
Option<OutputFormatKind> outputFormatKind =
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
await configElementRepository.GetValue<OutputFormatKind>(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
cancellationToken);
Option<string> defaultMpegTsScript =
await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegDefaultMpegTsScript,
cancellationToken);
var result = new FFmpegSettingsViewModel
{
@ -52,7 +66,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe @@ -52,7 +66,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs),
DefaultMpegTsScript = await defaultMpegTsScript.IfNoneAsync("Default"),
};
foreach (int watermarkId in watermark)

2
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -791,7 +791,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -791,7 +791,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (PtsTime pts in queryResult.RightToSeq())
{
_logger.LogWarning("Last pts offset is {Pts}", pts.Value);
_logger.LogDebug("Last pts offset is {Pts}", pts.Value);
result = pts.Value;
}

3
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -37,7 +37,8 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW @@ -37,7 +37,8 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
channel,
request.Scheme,
request.Host,
request.AccessToken);
request.AccessToken,
cancellationToken);
return new PlayoutItemProcessModel(
process,

7
ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs

@ -236,6 +236,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -236,6 +236,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
await _configElementRepository.GetValue<OutputFormatKind>(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
cancellationToken);
Option<string> defaultMpegTsScript =
await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegDefaultMpegTsScript,
cancellationToken);
var result = new FFmpegSettingsViewModel
{
@ -249,7 +253,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -249,7 +253,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs),
DefaultMpegTsScript = await defaultMpegTsScript.IfNoneAsync("Default")
};
foreach (int watermarkId in watermark)

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -26,6 +26,7 @@ public class ConfigElementKey @@ -26,6 +26,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
public static ConfigElementKey FFmpegHlsDirectOutputFormat => new("ffmpeg.hls_direct.output_format");
public static ConfigElementKey FFmpegDefaultMpegTsScript => new("ffmpeg.default_mpegts_script");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");

27
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -25,6 +25,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -25,6 +25,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IConfigElementRepository _configElementRepository;
private readonly IGraphicsElementLoader _graphicsElementLoader;
private readonly IMemoryCache _memoryCache;
private readonly IMpegTsScriptService _mpegTsScriptService;
private readonly ICustomStreamSelector _customStreamSelector;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
@ -41,6 +42,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -41,6 +42,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IConfigElementRepository configElementRepository,
IGraphicsElementLoader graphicsElementLoader,
IMemoryCache memoryCache,
IMpegTsScriptService mpegTsScriptService,
ILogger<FFmpegLibraryProcessService> logger)
{
_ffmpegProcessService = ffmpegProcessService;
@ -51,6 +53,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -51,6 +53,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_configElementRepository = configElementRepository;
_graphicsElementLoader = graphicsElementLoader;
_memoryCache = memoryCache;
_mpegTsScriptService = mpegTsScriptService;
_logger = logger;
}
@ -823,7 +826,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -823,7 +826,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Channel channel,
string scheme,
string host,
string accessToken)
string accessToken,
CancellationToken cancellationToken)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@ -840,6 +844,27 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -840,6 +844,27 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile.AudioFormat = AudioFormat.AacLatm;
}
// TODO: save reports?
string defaultScript = await _configElementRepository
.GetValue<string>(ConfigElementKey.FFmpegDefaultMpegTsScript, cancellationToken)
.IfNoneAsync("Default");
List<MpegTsScript> allScripts = _mpegTsScriptService.GetScripts();
foreach (var script in allScripts.Where(s => string.Equals(
s.Name,
defaultScript,
StringComparison.OrdinalIgnoreCase)))
{
Option<Command> maybeCommand = await _mpegTsScriptService.Execute(
script,
channel,
concatInputFile.Url,
ffmpegPath);
foreach (var command in maybeCommand)
{
return command;
}
}
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
Option<VideoInputFile>.None,

18
ErsatzTV.Core/FFmpeg/MpegTsScript.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.FFmpeg;
public class MpegTsScript
{
[YamlIgnore]
public string Id { get; set; }
[YamlMember(Alias = "name", ApplyNamingConventions = false)]
public string Name { get; set; }
[YamlMember(Alias = "linux_script", ApplyNamingConventions = false)]
public string LinuxScript { get; set; }
[YamlMember(Alias = "windows_script", ApplyNamingConventions = false)]
public string WindowsScript { get; set; }
}

1
ErsatzTV.Core/FFmpeg/TempFileCategory.cs

@ -6,6 +6,7 @@ public enum TempFileCategory @@ -6,6 +6,7 @@ public enum TempFileCategory
SongBackground = 1,
CoverArt = 2,
CachedArtwork = 3,
MpegTsScript = 4,
Fmp4LastSegment = 97,
BadTranscodeFolder = 98,

7
ErsatzTV.Core/FileSystemLayout.cs

@ -61,6 +61,10 @@ public static class FileSystemLayout @@ -61,6 +61,10 @@ public static class FileSystemLayout
public static readonly string ChannelStreamSelectorsFolder;
public static readonly string MpegTsScriptsFolder;
public static readonly string DefaultMpegTsScriptFolder;
public static readonly string MacOsOldAppDataFolder = Path.Combine(
Environment.GetEnvironmentVariable("HOME") ?? string.Empty,
".local",
@ -181,5 +185,8 @@ public static class FileSystemLayout @@ -181,5 +185,8 @@ public static class FileSystemLayout
AudioStreamSelectorScriptsFolder = Path.Combine(ScriptsFolder, "audio-stream-selector");
ChannelStreamSelectorsFolder = Path.Combine(ScriptsFolder, "channel-stream-selectors");
MpegTsScriptsFolder = Path.Combine(ScriptsFolder, "mpegts");
DefaultMpegTsScriptFolder = Path.Combine(MpegTsScriptsFolder, "default");
}
}

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

@ -65,7 +65,8 @@ public interface IFFmpegProcessService @@ -65,7 +65,8 @@ public interface IFFmpegProcessService
Channel channel,
string scheme,
string host,
string accessToken);
string accessToken,
CancellationToken cancellationToken);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);

14
ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IMpegTsScriptService
{
Task RefreshScripts();
List<MpegTsScript> GetScripts();
Task<Option<Command>> Execute(MpegTsScript script, Channel channel, string hlsUrl, string ffmpegPath);
}

136
ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
using Scriban;
using Scriban.Runtime;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Infrastructure.FFmpeg;
public class MpegTsScriptService(
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool,
ILogger<MpegTsScriptService> logger) : IMpegTsScriptService
{
private static readonly ConcurrentDictionary<string, MpegTsScript> Scripts = new();
public async Task RefreshScripts()
{
foreach (string folder in localFileSystem.ListSubdirectories(FileSystemLayout.MpegTsScriptsFolder))
{
string definition = Path.Combine(folder, "mpegts.yml");
if (!Scripts.ContainsKey(folder) && localFileSystem.FileExists(definition))
{
Option<MpegTsScript> maybeScript = FromYaml(await localFileSystem.ReadAllText(definition));
foreach (var script in maybeScript)
{
script.Id = Path.GetFileName(folder);
Scripts[folder] = script;
}
}
}
}
public List<MpegTsScript> GetScripts() => Scripts.Values.ToList();
public async Task<Option<Command>> Execute(MpegTsScript script, Channel channel, string hlsUrl, string ffmpegPath)
{
string scriptFolder = string.Empty;
foreach (KeyValuePair<string, MpegTsScript> kvp in Scripts.Where(kvp => kvp.Value == script))
{
scriptFolder = kvp.Key;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string scriptInput = Path.Combine(scriptFolder, script.WindowsScript);
if (File.Exists(scriptInput))
{
Option<string> maybeScript = await GetTemplatedScript(
scriptInput,
hlsUrl,
channel.Name,
ffmpegPath);
foreach (string finalScript in maybeScript)
{
string fileName = tempFilePool.GetNextTempFile(TempFileCategory.MpegTsScript);
await File.WriteAllTextAsync(fileName, finalScript);
return Cli.Wrap("pwsh").WithArguments(["-File", fileName]);
}
}
}
else
{
string scriptInput = Path.Combine(scriptFolder, script.LinuxScript);
if (File.Exists(scriptInput))
{
Option<string> maybeScript = await GetTemplatedScript(
scriptInput,
hlsUrl,
channel.Name,
ffmpegPath);
foreach (string finalScript in maybeScript)
{
string fileName = tempFilePool.GetNextTempFile(TempFileCategory.MpegTsScript);
await File.WriteAllTextAsync(fileName, finalScript);
return Cli.Wrap("bash").WithArguments([fileName]);
}
}
}
return [];
}
private async Task<Option<string>> GetTemplatedScript(
string fileName,
string hlsUrl,
string channelName,
string ffmpegPath)
{
string script = await localFileSystem.ReadAllText(fileName);
try
{
var data = new Dictionary<string, string>
{
["HlsUrl"] = hlsUrl,
["ChannelName"] = channelName,
["FFmpegPath"] = ffmpegPath
};
var scriptObject = new ScriptObject();
scriptObject.Import(data, renamer: member => member.Name);
var context = new TemplateContext { MemberRenamer = member => member.Name };
context.PushGlobal(scriptObject);
return await Template.Parse(script).RenderAsync(context);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to render mpegts script as scriban template");
return Option<string>.None;
}
}
private Option<MpegTsScript> FromYaml(string yaml)
{
try
{
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
return deserializer.Deserialize<MpegTsScript>(yaml);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load mpegts script YAML definition");
return Option<MpegTsScript>.None;
}
}
}

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -282,6 +282,7 @@ public class TranscodingTests @@ -282,6 +282,7 @@ public class TranscodingTests
Substitute.For<IConfigElementRepository>(),
graphicsElementLoader,
MemoryCache,
Substitute.For<IMpegTsScriptService>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, service);
@ -986,6 +987,7 @@ public class TranscodingTests @@ -986,6 +987,7 @@ public class TranscodingTests
Substitute.For<IConfigElementRepository>(),
graphicsElementLoader,
MemoryCache,
Substitute.For<IMpegTsScriptService>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
return service;

2
ErsatzTV/ErsatzTV.csproj

@ -82,6 +82,8 @@ @@ -82,6 +82,8 @@
<EmbeddedResource Include="Resources\Scripts\_threePartEpisodes.js" />
<EmbeddedResource Include="Resources\Scripts\_episode.js" />
<EmbeddedResource Include="Resources\Scripts\_movie.js" />
<EmbeddedResource Include="Resources\Scripts\MpegTs\run.sh" />
<EmbeddedResource Include="Resources\Scripts\MpegTs\mpegts.yml" />
<EmbeddedResource Include="Resources\song_album_cover_512.png" />
<EmbeddedResource Include="Resources\song_background_1.png" />
<EmbeddedResource Include="Resources\song_background_2.png" />

17
ErsatzTV/Pages/Settings/FFmpegSettings.razor

@ -1,10 +1,12 @@ @@ -1,10 +1,12 @@
@page "/settings/ffmpeg"
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Core.FFmpeg
@using ErsatzTV.FFmpeg.OutputFormat
@implements IDisposable
@inject IMediator Mediator
@ -124,6 +126,17 @@ @@ -124,6 +126,17 @@
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Default MPEG-TS Script</MudText>
</div>
<MudSelect @bind-Value="_ffmpegSettings.DefaultMpegTsScript" HelperText="The MPEG-TS script to use when streaming">
@foreach (MpegTsScript script in _mpegTsScripts)
{
<MudSelectItem Value="@script.Id">@script.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mb-2 mt-10">Custom Resolutions</MudText>
<MudDivider Class="mb-6"/>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" StartIcon="@Icons.Material.Filled.Add">
@ -162,6 +175,7 @@ @@ -162,6 +175,7 @@
private List<WatermarkViewModel> _watermarks = [];
private List<FillerPresetViewModel> _fillerPresets = [];
private List<ResolutionViewModel> _customResolutions = [];
private readonly List<MpegTsScript> _mpegTsScripts = [];
public void Dispose()
{
@ -187,6 +201,9 @@ @@ -187,6 +201,9 @@
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), token)
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
_mpegTsScripts.Clear();
_mpegTsScripts.AddRange(await Mediator.Send(new GetMpegTsScripts(), token));
await RefreshCustomResolutions(token);
}
catch (OperationCanceledException)

2
ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
name: "Default"
linux_script: "run.sh"

3
ErsatzTV/Resources/Scripts/MpegTs/run.sh

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
#! /bin/sh
"{{ FFmpegPath }}" -nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i "{{ HlsUrl }}" -map 0 -c copy -metadata service_provider="ErsatzTV" -metadata service_name="{{ ChannelName }}" -f mpegts pipe:1

26
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -102,6 +102,18 @@ public class ResourceExtractorService : BackgroundService @@ -102,6 +102,18 @@ public class ResourceExtractorService : BackgroundService
"_movie.js",
FileSystemLayout.AudioStreamSelectorScriptsFolder,
stoppingToken);
await ExtractMpegTsScriptResource(
assembly,
"run.sh",
FileSystemLayout.DefaultMpegTsScriptFolder,
stoppingToken);
await ExtractMpegTsScriptResource(
assembly,
"mpegts.yml",
FileSystemLayout.DefaultMpegTsScriptFolder,
stoppingToken);
}
private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
@ -157,4 +169,18 @@ public class ResourceExtractorService : BackgroundService @@ -157,4 +169,18 @@ public class ResourceExtractorService : BackgroundService
await resource.CopyToAsync(fs, cancellationToken);
}
}
private static async Task ExtractMpegTsScriptResource(
Assembly assembly,
string name,
string targetFolder,
CancellationToken cancellationToken)
{
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.Scripts.MpegTs.{name}");
if (resource != null)
{
await using FileStream fs = File.Create(Path.Combine(targetFolder, name));
await resource.CopyToAsync(fs, cancellationToken);
}
}
}

9
ErsatzTV/Services/SchedulerService.cs

@ -13,6 +13,7 @@ using ErsatzTV.Application.Playouts; @@ -13,6 +13,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@ -118,6 +119,7 @@ public class SchedulerService : BackgroundService @@ -118,6 +119,7 @@ public class SchedulerService : BackgroundService
{
await DeleteOrphanedArtwork(cancellationToken);
await DeleteOrphanedSubtitles(cancellationToken);
await RefreshMpegTsScripts(cancellationToken);
await RefreshChannelGuideChannelList(cancellationToken);
await BuildPlayouts(cancellationToken);
#if !DEBUG_NO_SYNC
@ -376,6 +378,13 @@ public class SchedulerService : BackgroundService @@ -376,6 +378,13 @@ public class SchedulerService : BackgroundService
}
}
private async Task RefreshMpegTsScripts(CancellationToken _)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IMpegTsScriptService>();
await service.RefreshScripts();
}
private ValueTask RefreshGraphicsElements(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new RefreshGraphicsElements(), cancellationToken);

5
ErsatzTV/Startup.cs

@ -380,7 +380,9 @@ public class Startup @@ -380,7 +380,9 @@ public class Startup
FileSystemLayout.GraphicsElementsMotionTemplatesFolder,
FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder
FileSystemLayout.AudioStreamSelectorScriptsFolder,
FileSystemLayout.MpegTsScriptsFolder,
FileSystemLayout.DefaultMpegTsScriptFolder
];
foreach (string directory in directoriesToCreate)
@ -817,6 +819,7 @@ public class Startup @@ -817,6 +819,7 @@ public class Startup
services.AddScoped<IWatermarkSelector, WatermarkSelector>();
services.AddScoped<IGraphicsElementSelector, GraphicsElementSelector>();
services.AddScoped<IHlsInitSegmentCache, HlsInitSegmentCache>();
services.AddScoped<IMpegTsScriptService, MpegTsScriptService>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save