From 889904e70d61228094cb45f33d702fc83bf3d351 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:52:45 -0500 Subject: [PATCH] fix management of fmp4 init segments (#2546) --- CHANGELOG.md | 1 + .../Commands/StartFFmpegSessionHandler.cs | 4 + .../Streaming/HlsSessionWorker.cs | 83 +++++++++-------- .../FFmpeg/HlsPlaylistFilterTests.cs | 23 +++-- .../Fakes/FakeLocalFileSystem.cs | 2 + ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs | 48 ++++++++++ ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs | 91 +++++++++---------- ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs | 12 +++ ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs | 9 +- .../Interfaces/Metadata/ILocalFileSystem.cs | 1 + ErsatzTV.Core/Metadata/LocalFileSystem.cs | 12 ++- .../Core/Fakes/FakeLocalFileSystem.cs | 1 + ErsatzTV/Startup.cs | 2 +- 13 files changed, 184 insertions(+), 105 deletions(-) create mode 100644 ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs create mode 100644 ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f3cf12191..48a114186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix Trakt List sync - Fix QSV audio sync - Fix QSV capability detection on Linux using non-drm displays (e.g. wayland) +- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours ### Changed - Do not use graphics engine for single, permanent watermark diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index 2eb96f7d0..d17552de0 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -25,6 +25,7 @@ public class StartFFmpegSessionHandler : IRequestHandler _logger; @@ -35,6 +36,7 @@ public class StartFFmpegSessionHandler : IRequestHandler workerChannel) { _hlsPlaylistFilter = hlsPlaylistFilter; + _hlsInitSegmentCache = hlsInitSegmentCache; _serviceScopeFactory = serviceScopeFactory; _mediator = mediator; _client = client; @@ -124,6 +127,7 @@ public class StartFFmpegSessionHandler : IRequestHandler _initTouches = new(); public HlsSessionWorker( IServiceScopeFactory serviceScopeFactory, @@ -57,6 +56,7 @@ public class HlsSessionWorker : IHlsSessionWorker IGraphicsEngine graphicsEngine, IClient client, IHlsPlaylistFilter hlsPlaylistFilter, + IHlsInitSegmentCache hlsInitSegmentCache, IConfigElementRepository configElementRepository, ILocalFileSystem localFileSystem, ILogger logger, @@ -67,6 +67,7 @@ public class HlsSessionWorker : IHlsSessionWorker _outputFormat = outputFormat; _graphicsEngine = graphicsEngine; _client = client; + _hlsInitSegmentCache = hlsInitSegmentCache; _hlsPlaylistFilter = hlsPlaylistFilter; _configElementRepository = configElementRepository; _localFileSystem = localFileSystem; @@ -99,12 +100,6 @@ public class HlsSessionWorker : IHlsSessionWorker _lastAccess = DateTimeOffset.Now; - foreach (string init in fileName.Where(f => f.Contains("init.mp4"))) - { - //_logger.LogDebug("Keep alive init {Init} for channel {ChannelNumber}", init, _channelNumber); - _initTouches.AddOrUpdate(init, DateTimeOffset.Now, (_, _) => DateTimeOffset.Now); - } - _timer?.Stop(); _timer?.Start(); } @@ -123,12 +118,15 @@ public class HlsSessionWorker : IHlsSessionWorker Option maybeLines = await ReadPlaylistLines(cancellationToken); foreach (string[] input in maybeLines) { + await RefreshInits(); + TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( _outputFormat, PlaylistStart, filterBefore, - GetAllInits(), - input); + _hlsInitSegmentCache, + input, + maybeMaxSegments: 10); if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) { DeleteOldSegments(trimResult); @@ -271,7 +269,7 @@ public class HlsSessionWorker : IHlsSessionWorker try { - _localFileSystem.EmptyFolder(_workingDirectory); + //_localFileSystem.EmptyFolder(_workingDirectory); } catch { @@ -647,12 +645,14 @@ public class HlsSessionWorker : IHlsSessionWorker Option maybeLines = await ReadPlaylistLines(cancellationToken); foreach (string[] lines in maybeLines) { + await RefreshInits(); + // trim playlist and insert discontinuity before appending with new ffmpeg process TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( _outputFormat, PlaylistStart, DateTimeOffset.Now.AddMinutes(-1), - GetAllInits(), + _hlsInitSegmentCache, lines); await WritePlaylist(trimResult.Playlist, cancellationToken); @@ -693,14 +693,9 @@ public class HlsSessionWorker : IHlsSessionWorker .ToList(); var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4") - .Map(file => - { - string fileName = Path.GetFileName(file); - // only consider deleting inits that have no segments left on disk - return long.TryParse(fileName.Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt) - ? new Segment(file, 0, generatedAt) - : Option.None; - }) + .Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt) + ? new Segment(file, 0, generatedAt) + : Option.None) .Somes() .ToList(); @@ -714,21 +709,22 @@ public class HlsSessionWorker : IHlsSessionWorker // trimResult.Sequence); } - DateTimeOffset toKeep = DateTimeOffset.Now.AddMinutes(-10); foreach (var init in allInits) { - string fileName = Path.GetFileName(init.File) ?? string.Empty; - - if (!_initTouches.TryGetValue(fileName, out DateTimeOffset touchedAt)) + // only consider deleting inits that have no segments left on disk, no segments in ffmpeg playlist + if (generatedAtHash.Contains(init.GeneratedAt) || init.GeneratedAt >= trimResult.GeneratedAt) { continue; } - if (touchedAt < toKeep) + string fileName = Path.GetFileName(init.File); + if (_hlsInitSegmentCache.IsEarliestByHash(fileName)) { - toDelete.Add(init); - _initTouches.TryRemove(fileName, out _); + continue; } + + toDelete.Add(init); + _hlsInitSegmentCache.DeleteSegment(fileName); } foreach (Segment segment in toDelete) @@ -746,16 +742,27 @@ public class HlsSessionWorker : IHlsSessionWorker } } - private List GetAllInits() => - _outputFormat is OutputFormatKind.HlsMp4 - ? Directory.GetFiles(_workingDirectory, "*init.mp4") - .Map(file => - { - string fileName = Path.GetFileName(file); - return long.TryParse(fileName.Split('_')[0], out long generatedAt) ? generatedAt : long.MaxValue; - }) - .ToList() - : []; + private async Task RefreshInits() + { + if (_outputFormat is not OutputFormatKind.HlsMp4) + { + return; + } + + var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s") + .Map(Path.GetFileName) + .Map(s => s.Split("_")[1]) + .ToHashSet(); + + foreach (string file in Directory.GetFiles(_workingDirectory, "*init.mp4")) + { + string key = Path.GetFileName(file).Split("_")[0]; + if (allSegments.Contains(key)) + { + await _hlsInitSegmentCache.AddSegment(file); + } + } + } private async Task GetPtsOffset(string channelNumber, CancellationToken cancellationToken) { diff --git a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs index 1778ac37d..3b42584c1 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs @@ -45,8 +45,9 @@ live001139.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(-30), - [], - input); + Substitute.For(), + input, + maybeMaxSegments: 10); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -95,7 +96,7 @@ live001139.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(-30), - [], + Substitute.For(), input, 2); @@ -143,7 +144,7 @@ live001139.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(-30), - [], + Substitute.For(), input, int.MaxValue, true); @@ -196,7 +197,7 @@ live001139.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(6), - [], + Substitute.For(), input, 1); @@ -242,8 +243,9 @@ live001139.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(6), - [], - input); + Substitute.For(), + input, + maybeMaxSegments: 10); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -535,8 +537,9 @@ live000082.ts").Split(Environment.NewLine); OutputFormatKind.Hls, start, start.AddSeconds(220), - [], - input); + Substitute.For(), + input, + maybeMaxSegments: 10); // result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(56); @@ -611,7 +614,7 @@ live000048.ts OutputFormatKind.Hls, start, filterBefore, - [], + Substitute.For(), input, 2); diff --git a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs index 2fd1f2590..b4d65dfb0 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs @@ -70,6 +70,8 @@ public class FakeLocalFileSystem : ILocalFileSystem .IfNoneAsync(string.Empty) .Map(s => s.Split(Environment.NewLine)); + public Task GetHash(string path) => throw new NotSupportedException(); + public Task ReadAllBytes(string path) => TestBytes.AsTask(); private static List Split(DirectoryInfo path) diff --git a/ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs b/ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs new file mode 100644 index 000000000..b0bf2b1fa --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; +using ErsatzTV.Core.Interfaces.Metadata; + +namespace ErsatzTV.Core.FFmpeg; + +public class HlsInitSegmentCache(ILocalFileSystem localFileSystem) : IHlsInitSegmentCache +{ + private readonly ConcurrentDictionary _knownSegments = []; + private readonly ConcurrentDictionary _earliestSegmentsByHash = []; + + public async Task AddSegment(string segment) + { + string fileName = Path.GetFileName(segment); + if (!_knownSegments.ContainsKey(fileName)) + { + byte[] hash = await localFileSystem.GetHash(segment); + string hashString = Convert.ToHexStringLower(hash); + + //logger.LogDebug("Adding segment {Segment} to cache", fileName); + + _knownSegments.TryAdd(fileName, hashString); + + if (!_earliestSegmentsByHash.TryAdd(hashString, fileName)) + { + //logger.LogDebug("An earlier segment with the same hash was already in the cache"); + } + } + } + + public string EarliestSegmentByHash(long generatedAt) + { + var fileName = $"{generatedAt}_init.mp4"; + string hashString = _knownSegments[fileName]; + return _earliestSegmentsByHash[hashString]; + } + + public bool IsEarliestByHash(string fileName) + { + string hashString = _knownSegments[fileName]; + return _earliestSegmentsByHash[hashString] == fileName; + } + + public void DeleteSegment(string segment) + { + //logger.LogDebug("Segment {Segment} is no longer needed; deleting cached hash", segment); + _knownSegments.TryRemove(segment, out _); + } +} diff --git a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs index f71be1941..8c8494251 100644 --- a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs @@ -6,24 +6,15 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.FFmpeg; -public class HlsPlaylistFilter : IHlsPlaylistFilter +public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger logger) : IHlsPlaylistFilter { - private readonly ILogger _logger; - private readonly ITempFilePool _tempFilePool; - - public HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger logger) - { - _tempFilePool = tempFilePool; - _logger = logger; - } - public TrimPlaylistResult TrimPlaylist( OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, - List inits, + IHlsInitSegmentCache hlsInitSegmentCache, string[] lines, - int maxSegments = 10, + Option maybeMaxSegments, bool endWithDiscontinuity = false) { try @@ -107,11 +98,11 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter GeneratePlaylist( outputFormat, items, - inits, + hlsInitSegmentCache, filterBefore, targetDuration, discontinuitySequence, - maxSegments); + maybeMaxSegments); return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments); } @@ -119,10 +110,10 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter { try { - string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadPlaylist); + string file = tempFilePool.GetNextTempFile(TempFileCategory.BadPlaylist); File.WriteAllLines(file, lines); - _logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file); + logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file); // TODO: better error result? return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0); @@ -140,18 +131,18 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, - List inits, + IHlsInitSegmentCache hlsInitSegmentCache, string[] lines) => - TrimPlaylist(outputFormat, playlistStart, filterBefore, inits, lines, int.MaxValue, true); + TrimPlaylist(outputFormat, playlistStart, filterBefore, hlsInitSegmentCache, lines, Option.None, true); private static Tuple GeneratePlaylist( OutputFormatKind outputFormat, List items, - List inits, + IHlsInitSegmentCache hlsInitSegmentCache, DateTimeOffset filterBefore, int targetDuration, int discontinuitySequence, - int maxSegments) + Option maybeMaxSegments) { if (items.Count != 0 && items[0] is PlaylistDiscontinuity) { @@ -164,16 +155,26 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter } var allSegments = items.OfType().ToList(); - // only filter if we have more than requested - if (allSegments.Count > maxSegments) + + // still need to filter old content + if (maybeMaxSegments.IsNone) { - var afterFilter = allSegments.Filter(s => s.StartTime >= filterBefore).ToList(); + allSegments.RemoveAll(s => s.StartTime < filterBefore); + } - // if there are enough new segments after filtering, use those - // otherwise return the last maxSegments - allSegments = afterFilter.Count >= maxSegments - ? afterFilter.Take(maxSegments).ToList() - : allSegments.TakeLast(maxSegments).ToList(); + foreach (int maxSegments in maybeMaxSegments) + { + // only filter if we have more than requested + if (allSegments.Count > maxSegments) + { + var afterFilter = allSegments.Filter(s => s.StartTime >= filterBefore).ToList(); + + // if there are enough new segments after filtering, use those + // otherwise return the last maxSegments + allSegments = afterFilter.Count >= maxSegments + ? afterFilter.Take(maxSegments).ToList() + : allSegments.TakeLast(maxSegments).ToList(); + } } long startSequence = 0; @@ -184,12 +185,6 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter generatedAt = startSegment.GeneratedAt; } - long minGeneratedAt = 0; - foreach (var firstSegment in allSegments.HeadOrNone()) - { - minGeneratedAt = firstSegment.GeneratedAt; - } - // count all discontinuities that were filtered out if (allSegments.Count != 0) { @@ -208,12 +203,9 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter if (outputFormat is OutputFormatKind.HlsMp4) { - Option maybeStartInit = Optional(inits.Find(init => init > 0 && init <= minGeneratedAt)); - foreach (long init in maybeStartInit) - { - output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MAP:URI=\"{init}_init.mp4\""); - inits.Remove(init); - } + output.AppendLine( + CultureInfo.InvariantCulture, + $"#EXT-X-MAP:URI=\"{hlsInitSegmentCache.EarliestSegmentByHash(generatedAt)}\""); } for (var i = 0; i < items.Count; i++) @@ -229,15 +221,9 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter if (outputFormat is OutputFormatKind.HlsMp4) { - Option maybeInit = Optional( - inits.Find(init => init > 0 && init <= nextSegment.GeneratedAt)); - foreach (long init in maybeInit) - { - output.AppendLine( - CultureInfo.InvariantCulture, - $"#EXT-X-MAP:URI=\"{init}_init.mp4\""); - inits.Remove(init); - } + output.AppendLine( + CultureInfo.InvariantCulture, + $"#EXT-X-MAP:URI=\"{hlsInitSegmentCache.EarliestSegmentByHash(nextSegment.GeneratedAt)}\""); } } } @@ -269,7 +255,12 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter .Map(s => s.StartTime) .IfNone(DateTimeOffset.MaxValue); - return Tuple(playlist, nextPlaylistStart, startSequence, generatedAt, allSegments.Count); + return Tuple( + playlist, + nextPlaylistStart, + startSequence, + items.OfType().Min(s => s.GeneratedAt), + allSegments.Count); } private abstract record PlaylistItem; diff --git a/ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs b/ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs new file mode 100644 index 000000000..1918cbbcc --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs @@ -0,0 +1,12 @@ +namespace ErsatzTV.Core.FFmpeg; + +public interface IHlsInitSegmentCache +{ + Task AddSegment(string segment); + + string EarliestSegmentByHash(long generatedAt); + + bool IsEarliestByHash(string fileName); + + void DeleteSegment(string segment); +} diff --git a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs index 20ef30127..9803342c5 100644 --- a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs @@ -1,5 +1,4 @@ -using ErsatzTV.Core.Domain; -using ErsatzTV.FFmpeg.OutputFormat; +using ErsatzTV.FFmpeg.OutputFormat; namespace ErsatzTV.Core.FFmpeg; @@ -9,15 +8,15 @@ public interface IHlsPlaylistFilter OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, - List inits, + IHlsInitSegmentCache hlsInitSegmentCache, string[] lines, - int maxSegments = 10, + Option maybeMaxSegments, bool endWithDiscontinuity = false); TrimPlaylistResult TrimPlaylistWithDiscontinuity( OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, - List inits, + IHlsInitSegmentCache hlsInitSegmentCache, string[] lines); } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index df141b128..8e33c707d 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -16,4 +16,5 @@ public interface ILocalFileSystem Unit EmptyFolder(string folder); Task ReadAllText(string path); Task ReadAllLines(string path); + Task GetHash(string path); } diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs index 829d048b1..839126b7a 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -1,4 +1,6 @@ -using Bugsnag; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using Bugsnag; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using Microsoft.Extensions.Logging; @@ -158,4 +160,12 @@ public class LocalFileSystem(IClient client, ILogger logger) : public Task ReadAllText(string path) => File.ReadAllTextAsync(path); public Task ReadAllLines(string path) => File.ReadAllLinesAsync(path); + + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] + public async Task GetHash(string path) + { + using var md5 = MD5.Create(); + await using var stream = File.OpenRead(path); + return await md5.ComputeHashAsync(stream); + } } diff --git a/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs index d7480308e..f54a81588 100644 --- a/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs +++ b/ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs @@ -59,6 +59,7 @@ public class FakeLocalFileSystem : ILocalFileSystem public Unit EmptyFolder(string folder) => Unit.Default; public Task ReadAllText(string path) => throw new NotImplementedException(); public Task ReadAllLines(string path) => throw new NotImplementedException(); + public Task GetHash(string path) => throw new NotImplementedException(); public Task ReadAllBytes(string path) => TestBytes.AsTask(); diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index a749b2c13..4e7bbbdc9 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -14,7 +14,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; -using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Health; using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Images; @@ -813,6 +812,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();