diff --git a/CHANGELOG.md b/CHANGELOG.md index 7deef8900..46ae59ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). - 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 diff --git a/ErsatzTV.Application/Configuration/Queries/GetMpegTsScripts.cs b/ErsatzTV.Application/Configuration/Queries/GetMpegTsScripts.cs new file mode 100644 index 000000000..07d680d85 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetMpegTsScripts.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Application.Configuration; + +public record GetMpegTsScripts : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Queries/GetMpegTsScriptsHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetMpegTsScriptsHandler.cs new file mode 100644 index 000000000..87ee2c89f --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetMpegTsScriptsHandler.cs @@ -0,0 +1,14 @@ +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Application.Configuration; + +public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService) + : IRequestHandler> +{ + public async Task> Handle(GetMpegTsScripts request, CancellationToken cancellationToken) + { + await mpegTsScriptService.RefreshScripts(); + return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList(); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs index 2d97809a5..fc40c28c1 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs @@ -85,6 +85,10 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler +public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler { - private readonly IConfigElementRepository _configElementRepository; - - public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) => - _configElementRepository = configElementRepository; - public async Task Handle( GetFFmpegSettings request, CancellationToken cancellationToken) { - Option ffmpegPath = await _configElementRepository.GetValue(ConfigElementKey.FFmpegPath, cancellationToken); - Option ffprobePath = await _configElementRepository.GetValue(ConfigElementKey.FFprobePath, cancellationToken); + Option ffmpegPath = await configElementRepository.GetValue( + ConfigElementKey.FFmpegPath, + cancellationToken); + Option ffprobePath = await configElementRepository.GetValue( + ConfigElementKey.FFprobePath, + cancellationToken); Option defaultFFmpegProfileId = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken); Option saveReports = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegSaveReports, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegSaveReports, cancellationToken); Option preferredAudioLanguageCode = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken); + await configElementRepository.GetValue( + ConfigElementKey.FFmpegPreferredLanguageCode, + cancellationToken); Option useEmbeddedSubtitles = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken); + await configElementRepository.GetValue( + ConfigElementKey.FFmpegUseEmbeddedSubtitles, + cancellationToken); Option extractEmbeddedSubtitles = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken); + await configElementRepository.GetValue( + ConfigElementKey.FFmpegExtractEmbeddedSubtitles, + cancellationToken); Option watermark = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken); Option fallbackFiller = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken); + await configElementRepository.GetValue( + ConfigElementKey.FFmpegGlobalFallbackFillerId, + cancellationToken); Option hlsSegmenterIdleTimeout = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken); Option workAheadSegmenterLimit = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken); Option initialSegmentCount = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken); + await configElementRepository.GetValue(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken); Option outputFormatKind = - await _configElementRepository.GetValue(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken); + await configElementRepository.GetValue( + ConfigElementKey.FFmpegHlsDirectOutputFormat, + cancellationToken); + Option defaultMpegTsScript = + await configElementRepository.GetValue( + ConfigElementKey.FFmpegDefaultMpegTsScript, + cancellationToken); var result = new FFmpegSettingsViewModel { @@ -52,7 +66,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler( ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken); + Option defaultMpegTsScript = + await _configElementRepository.GetValue( + ConfigElementKey.FFmpegDefaultMpegTsScript, + cancellationToken); var result = new FFmpegSettingsViewModel { @@ -249,7 +253,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler 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"); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index ff645ab60..9d6e1d176 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -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 IConfigElementRepository configElementRepository, IGraphicsElementLoader graphicsElementLoader, IMemoryCache memoryCache, + IMpegTsScriptService mpegTsScriptService, ILogger logger) { _ffmpegProcessService = ffmpegProcessService; @@ -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 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 concatInputFile.AudioFormat = AudioFormat.AacLatm; } + // TODO: save reports? + string defaultScript = await _configElementRepository + .GetValue(ConfigElementKey.FFmpegDefaultMpegTsScript, cancellationToken) + .IfNoneAsync("Default"); + List allScripts = _mpegTsScriptService.GetScripts(); + foreach (var script in allScripts.Where(s => string.Equals( + s.Name, + defaultScript, + StringComparison.OrdinalIgnoreCase))) + { + Option maybeCommand = await _mpegTsScriptService.Execute( + script, + channel, + concatInputFile.Url, + ffmpegPath); + foreach (var command in maybeCommand) + { + return command; + } + } + IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( HardwareAccelerationMode.None, Option.None, diff --git a/ErsatzTV.Core/FFmpeg/MpegTsScript.cs b/ErsatzTV.Core/FFmpeg/MpegTsScript.cs new file mode 100644 index 000000000..97639861a --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/MpegTsScript.cs @@ -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; } +} diff --git a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs index d63510a4d..5106fbd8e 100644 --- a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs +++ b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs @@ -6,6 +6,7 @@ public enum TempFileCategory SongBackground = 1, CoverArt = 2, CachedArtwork = 3, + MpegTsScript = 4, Fmp4LastSegment = 97, BadTranscodeFolder = 98, diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 1d6bb2847..749256edb 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -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 AudioStreamSelectorScriptsFolder = Path.Combine(ScriptsFolder, "audio-stream-selector"); ChannelStreamSelectorsFolder = Path.Combine(ScriptsFolder, "channel-stream-selectors"); + + MpegTsScriptsFolder = Path.Combine(ScriptsFolder, "mpegts"); + DefaultMpegTsScriptFolder = Path.Combine(MpegTsScriptsFolder, "default"); } } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index fd6ef6b31..096b827dc 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -65,7 +65,8 @@ public interface IFFmpegProcessService Channel channel, string scheme, string host, - string accessToken); + string accessToken, + CancellationToken cancellationToken); Task ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs new file mode 100644 index 000000000..8a71ab488 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs @@ -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 GetScripts(); + + Task> Execute(MpegTsScript script, Channel channel, string hlsUrl, string ffmpegPath); +} diff --git a/ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs b/ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs new file mode 100644 index 000000000..a9d32514b --- /dev/null +++ b/ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs @@ -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 logger) : IMpegTsScriptService +{ + private static readonly ConcurrentDictionary 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 maybeScript = FromYaml(await localFileSystem.ReadAllText(definition)); + foreach (var script in maybeScript) + { + script.Id = Path.GetFileName(folder); + Scripts[folder] = script; + } + } + } + } + + public List GetScripts() => Scripts.Values.ToList(); + + public async Task> Execute(MpegTsScript script, Channel channel, string hlsUrl, string ffmpegPath) + { + string scriptFolder = string.Empty; + foreach (KeyValuePair 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 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 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> GetTemplatedScript( + string fileName, + string hlsUrl, + string channelName, + string ffmpegPath) + { + string script = await localFileSystem.ReadAllText(fileName); + try + { + var data = new Dictionary + { + ["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.None; + } + } + + private Option FromYaml(string yaml) + { + try + { + IDeserializer deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return deserializer.Deserialize(yaml); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load mpegts script YAML definition"); + return Option.None; + } + } +} diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index d0efca0f2..cf3bdfc37 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -282,6 +282,7 @@ public class TranscodingTests Substitute.For(), graphicsElementLoader, MemoryCache, + Substitute.For(), LoggerFactory.CreateLogger()); var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, service); @@ -986,6 +987,7 @@ public class TranscodingTests Substitute.For(), graphicsElementLoader, MemoryCache, + Substitute.For(), LoggerFactory.CreateLogger()); return service; diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index e4670752c..fd0ae2f8b 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -82,6 +82,8 @@ + + diff --git a/ErsatzTV/Pages/Settings/FFmpegSettings.razor b/ErsatzTV/Pages/Settings/FFmpegSettings.razor index 25414463b..1c4c94455 100644 --- a/ErsatzTV/Pages/Settings/FFmpegSettings.razor +++ b/ErsatzTV/Pages/Settings/FFmpegSettings.razor @@ -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 @@ MKV + +
+ Default MPEG-TS Script +
+ + @foreach (MpegTsScript script in _mpegTsScripts) + { + @script.Name + } + +
Custom Resolutions @@ -162,6 +175,7 @@ private List _watermarks = []; private List _fillerPresets = []; private List _customResolutions = []; + private readonly List _mpegTsScripts = []; public void Dispose() { @@ -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) diff --git a/ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml b/ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml new file mode 100644 index 000000000..d7e93dd3c --- /dev/null +++ b/ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml @@ -0,0 +1,2 @@ +name: "Default" +linux_script: "run.sh" diff --git a/ErsatzTV/Resources/Scripts/MpegTs/run.sh b/ErsatzTV/Resources/Scripts/MpegTs/run.sh new file mode 100644 index 000000000..1b4b7d6b3 --- /dev/null +++ b/ErsatzTV/Resources/Scripts/MpegTs/run.sh @@ -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 diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index 748bd248e..e5e02b09c 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -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 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); + } + } } diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index a01e250c1..80d2df68b 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -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 { 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 } } + private async Task RefreshMpegTsScripts(CancellationToken _) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.RefreshScripts(); + } + private ValueTask RefreshGraphicsElements(CancellationToken cancellationToken) => _workerChannel.WriteAsync(new RefreshGraphicsElements(), cancellationToken); diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 369376f16..15272481c 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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 services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();