Browse Source

add basic mpegts script

pull/2609/head
Jason Dove 9 months ago
parent
commit
5624b209f1
No known key found for this signature in database
  1. 2
      CHANGELOG.md
  2. 21
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  3. 15
      ErsatzTV.Core/FFmpeg/MpegTsScript.cs
  4. 1
      ErsatzTV.Core/FFmpeg/TempFileCategory.cs
  5. 7
      ErsatzTV.Core/FileSystemLayout.cs
  6. 14
      ErsatzTV.Core/Interfaces/FFmpeg/IMpegTsScriptService.cs
  7. 135
      ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs
  8. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  9. 2
      ErsatzTV/ErsatzTV.csproj
  10. 2
      ErsatzTV/Resources/Scripts/MpegTs/mpegts.yml
  11. 3
      ErsatzTV/Resources/Scripts/MpegTs/run.sh
  12. 26
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  13. 9
      ErsatzTV/Services/SchedulerService.cs
  14. 5
      ErsatzTV/Startup.cs

2
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

21
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;
}
@ -840,6 +843,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -840,6 +843,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile.AudioFormat = AudioFormat.AacLatm;
}
// TODO: save reports?
List<MpegTsScript> allScripts = _mpegTsScriptService.GetScripts();
foreach (var script in allScripts.Where(s => string.Equals(
s.Name,
"Default",
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,

15
ErsatzTV.Core/FFmpeg/MpegTsScript.cs

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

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

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

135
ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs

@ -0,0 +1,135 @@ @@ -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<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)
{
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" />

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