diff --git a/CHANGELOG.md b/CHANGELOG.md index 7deef8900..948ac0ef6 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 diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index ff645ab60..48ca2a0ec 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; } @@ -840,6 +843,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService concatInputFile.AudioFormat = AudioFormat.AacLatm; } + // TODO: save reports? + List allScripts = _mpegTsScriptService.GetScripts(); + foreach (var script in allScripts.Where(s => string.Equals( + s.Name, + "Default", + 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..31f435ead --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/MpegTsScript.cs @@ -0,0 +1,15 @@ +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.FFmpeg; + +public class MpegTsScript +{ + [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/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..39410a47d --- /dev/null +++ b/ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs @@ -0,0 +1,135 @@ +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) + { + 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/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();