Browse Source

fix management of fmp4 init segments (#2546)

pull/2548/head
Jason Dove 10 months ago committed by GitHub
parent
commit
889904e70d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  3. 83
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  4. 23
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  5. 2
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  6. 48
      ErsatzTV.Core/FFmpeg/HlsInitSegmentCache.cs
  7. 91
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  8. 12
      ErsatzTV.Core/FFmpeg/IHlsInitSegmentCache.cs
  9. 9
      ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs
  10. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  11. 12
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  12. 1
      ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs
  13. 2
      ErsatzTV/Startup.cs

1
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 Trakt List sync
- Fix QSV audio sync - Fix QSV audio sync
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland) - 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 ### Changed
- Do not use graphics engine for single, permanent watermark - Do not use graphics engine for single, permanent watermark

4
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -25,6 +25,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<StartFFmpegSessionHandler> _logger; private readonly ILogger<StartFFmpegSessionHandler> _logger;
@ -35,6 +36,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
public StartFFmpegSessionHandler( public StartFFmpegSessionHandler(
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
IMediator mediator, IMediator mediator,
IClient client, IClient client,
@ -48,6 +50,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel)
{ {
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_hlsInitSegmentCache = hlsInitSegmentCache;
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_mediator = mediator; _mediator = mediator;
_client = client; _client = client;
@ -124,6 +127,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_graphicsEngine, _graphicsEngine,
_client, _client,
_hlsPlaylistFilter, _hlsPlaylistFilter,
_hlsInitSegmentCache,
_configElementRepository, _configElementRepository,
_localFileSystem, _localFileSystem,
_sessionWorkerLogger, _sessionWorkerLogger,

83
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -1,5 +1,4 @@
using System.Collections.Concurrent; using System.Diagnostics;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Pipelines; using System.IO.Pipelines;
@ -27,6 +26,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
private static int _workAheadCount; private static int _workAheadCount;
private readonly IClient _client; private readonly IClient _client;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly OutputFormatKind _outputFormat; private readonly OutputFormatKind _outputFormat;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
@ -49,7 +49,6 @@ public class HlsSessionWorker : IHlsSessionWorker
private Timer _timer; private Timer _timer;
private DateTimeOffset _transcodedUntil; private DateTimeOffset _transcodedUntil;
private string _workingDirectory; private string _workingDirectory;
private ConcurrentDictionary<string, DateTimeOffset> _initTouches = new();
public HlsSessionWorker( public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
@ -57,6 +56,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
IClient client, IClient client,
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger, ILogger<HlsSessionWorker> logger,
@ -67,6 +67,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_outputFormat = outputFormat; _outputFormat = outputFormat;
_graphicsEngine = graphicsEngine; _graphicsEngine = graphicsEngine;
_client = client; _client = client;
_hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@ -99,12 +100,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_lastAccess = DateTimeOffset.Now; _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?.Stop();
_timer?.Start(); _timer?.Start();
} }
@ -123,12 +118,15 @@ public class HlsSessionWorker : IHlsSessionWorker
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
foreach (string[] input in maybeLines) foreach (string[] input in maybeLines)
{ {
await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
_outputFormat, _outputFormat,
PlaylistStart, PlaylistStart,
filterBefore, filterBefore,
GetAllInits(), _hlsInitSegmentCache,
input); input,
maybeMaxSegments: 10);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{ {
DeleteOldSegments(trimResult); DeleteOldSegments(trimResult);
@ -271,7 +269,7 @@ public class HlsSessionWorker : IHlsSessionWorker
try try
{ {
_localFileSystem.EmptyFolder(_workingDirectory); //_localFileSystem.EmptyFolder(_workingDirectory);
} }
catch catch
{ {
@ -647,12 +645,14 @@ public class HlsSessionWorker : IHlsSessionWorker
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
foreach (string[] lines in maybeLines) foreach (string[] lines in maybeLines)
{ {
await RefreshInits();
// trim playlist and insert discontinuity before appending with new ffmpeg process // trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_outputFormat, _outputFormat,
PlaylistStart, PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1), DateTimeOffset.Now.AddMinutes(-1),
GetAllInits(), _hlsInitSegmentCache,
lines); lines);
await WritePlaylist(trimResult.Playlist, cancellationToken); await WritePlaylist(trimResult.Playlist, cancellationToken);
@ -693,14 +693,9 @@ public class HlsSessionWorker : IHlsSessionWorker
.ToList(); .ToList();
var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4") var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4")
.Map(file => .Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt)
{ ? new Segment(file, 0, generatedAt)
string fileName = Path.GetFileName(file); : Option<Segment>.None)
// 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<Segment>.None;
})
.Somes() .Somes()
.ToList(); .ToList();
@ -714,21 +709,22 @@ public class HlsSessionWorker : IHlsSessionWorker
// trimResult.Sequence); // trimResult.Sequence);
} }
DateTimeOffset toKeep = DateTimeOffset.Now.AddMinutes(-10);
foreach (var init in allInits) foreach (var init in allInits)
{ {
string fileName = Path.GetFileName(init.File) ?? string.Empty; // 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)
if (!_initTouches.TryGetValue(fileName, out DateTimeOffset touchedAt))
{ {
continue; continue;
} }
if (touchedAt < toKeep) string fileName = Path.GetFileName(init.File);
if (_hlsInitSegmentCache.IsEarliestByHash(fileName))
{ {
toDelete.Add(init); continue;
_initTouches.TryRemove(fileName, out _);
} }
toDelete.Add(init);
_hlsInitSegmentCache.DeleteSegment(fileName);
} }
foreach (Segment segment in toDelete) foreach (Segment segment in toDelete)
@ -746,16 +742,27 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
private List<long> GetAllInits() => private async Task RefreshInits()
_outputFormat is OutputFormatKind.HlsMp4 {
? Directory.GetFiles(_workingDirectory, "*init.mp4") if (_outputFormat is not OutputFormatKind.HlsMp4)
.Map(file => {
{ return;
string fileName = Path.GetFileName(file); }
return long.TryParse(fileName.Split('_')[0], out long generatedAt) ? generatedAt : long.MaxValue;
}) var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s")
.ToList() .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<long> GetPtsOffset(string channelNumber, CancellationToken cancellationToken) private async Task<long> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
{ {

23
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

@ -45,8 +45,9 @@ live001139.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(-30), start.AddSeconds(-30),
[], Substitute.For<IHlsInitSegmentCache>(),
input); input,
maybeMaxSegments: 10);
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -95,7 +96,7 @@ live001139.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(-30), start.AddSeconds(-30),
[], Substitute.For<IHlsInitSegmentCache>(),
input, input,
2); 2);
@ -143,7 +144,7 @@ live001139.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(-30), start.AddSeconds(-30),
[], Substitute.For<IHlsInitSegmentCache>(),
input, input,
int.MaxValue, int.MaxValue,
true); true);
@ -196,7 +197,7 @@ live001139.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(6), start.AddSeconds(6),
[], Substitute.For<IHlsInitSegmentCache>(),
input, input,
1); 1);
@ -242,8 +243,9 @@ live001139.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(6), start.AddSeconds(6),
[], Substitute.For<IHlsInitSegmentCache>(),
input); input,
maybeMaxSegments: 10);
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -535,8 +537,9 @@ live000082.ts").Split(Environment.NewLine);
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
start.AddSeconds(220), start.AddSeconds(220),
[], Substitute.For<IHlsInitSegmentCache>(),
input); input,
maybeMaxSegments: 10);
// result.PlaylistStart.ShouldBe(start); // result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(56); result.Sequence.ShouldBe(56);
@ -611,7 +614,7 @@ live000048.ts
OutputFormatKind.Hls, OutputFormatKind.Hls,
start, start,
filterBefore, filterBefore,
[], Substitute.For<IHlsInitSegmentCache>(),
input, input,
2); 2);

2
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -70,6 +70,8 @@ public class FakeLocalFileSystem : ILocalFileSystem
.IfNoneAsync(string.Empty) .IfNoneAsync(string.Empty)
.Map(s => s.Split(Environment.NewLine)); .Map(s => s.Split(Environment.NewLine));
public Task<byte[]> GetHash(string path) => throw new NotSupportedException();
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path) private static List<DirectoryInfo> Split(DirectoryInfo path)

48
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<string, string> _knownSegments = [];
private readonly ConcurrentDictionary<string, string> _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 _);
}
}

91
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -6,24 +6,15 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class HlsPlaylistFilter : IHlsPlaylistFilter public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger) : IHlsPlaylistFilter
{ {
private readonly ILogger<HlsPlaylistFilter> _logger;
private readonly ITempFilePool _tempFilePool;
public HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger)
{
_tempFilePool = tempFilePool;
_logger = logger;
}
public TrimPlaylistResult TrimPlaylist( public TrimPlaylistResult TrimPlaylist(
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
List<long> inits, IHlsInitSegmentCache hlsInitSegmentCache,
string[] lines, string[] lines,
int maxSegments = 10, Option<int> maybeMaxSegments,
bool endWithDiscontinuity = false) bool endWithDiscontinuity = false)
{ {
try try
@ -107,11 +98,11 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
GeneratePlaylist( GeneratePlaylist(
outputFormat, outputFormat,
items, items,
inits, hlsInitSegmentCache,
filterBefore, filterBefore,
targetDuration, targetDuration,
discontinuitySequence, discontinuitySequence,
maxSegments); maybeMaxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments); return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments);
} }
@ -119,10 +110,10 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
{ {
try try
{ {
string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadPlaylist); string file = tempFilePool.GetNextTempFile(TempFileCategory.BadPlaylist);
File.WriteAllLines(file, lines); 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? // TODO: better error result?
return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0); return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0);
@ -140,18 +131,18 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
List<long> inits, IHlsInitSegmentCache hlsInitSegmentCache,
string[] lines) => string[] lines) =>
TrimPlaylist(outputFormat, playlistStart, filterBefore, inits, lines, int.MaxValue, true); TrimPlaylist(outputFormat, playlistStart, filterBefore, hlsInitSegmentCache, lines, Option<int>.None, true);
private static Tuple<string, DateTimeOffset, long, long, int> GeneratePlaylist( private static Tuple<string, DateTimeOffset, long, long, int> GeneratePlaylist(
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
List<PlaylistItem> items, List<PlaylistItem> items,
List<long> inits, IHlsInitSegmentCache hlsInitSegmentCache,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
int targetDuration, int targetDuration,
int discontinuitySequence, int discontinuitySequence,
int maxSegments) Option<int> maybeMaxSegments)
{ {
if (items.Count != 0 && items[0] is PlaylistDiscontinuity) if (items.Count != 0 && items[0] is PlaylistDiscontinuity)
{ {
@ -164,16 +155,26 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
} }
var allSegments = items.OfType<PlaylistSegment>().ToList(); var allSegments = items.OfType<PlaylistSegment>().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 foreach (int maxSegments in maybeMaxSegments)
// otherwise return the last maxSegments {
allSegments = afterFilter.Count >= maxSegments // only filter if we have more than requested
? afterFilter.Take(maxSegments).ToList() if (allSegments.Count > maxSegments)
: allSegments.TakeLast(maxSegments).ToList(); {
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; long startSequence = 0;
@ -184,12 +185,6 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
generatedAt = startSegment.GeneratedAt; generatedAt = startSegment.GeneratedAt;
} }
long minGeneratedAt = 0;
foreach (var firstSegment in allSegments.HeadOrNone())
{
minGeneratedAt = firstSegment.GeneratedAt;
}
// count all discontinuities that were filtered out // count all discontinuities that were filtered out
if (allSegments.Count != 0) if (allSegments.Count != 0)
{ {
@ -208,12 +203,9 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
if (outputFormat is OutputFormatKind.HlsMp4) if (outputFormat is OutputFormatKind.HlsMp4)
{ {
Option<long> maybeStartInit = Optional(inits.Find(init => init > 0 && init <= minGeneratedAt)); output.AppendLine(
foreach (long init in maybeStartInit) CultureInfo.InvariantCulture,
{ $"#EXT-X-MAP:URI=\"{hlsInitSegmentCache.EarliestSegmentByHash(generatedAt)}\"");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MAP:URI=\"{init}_init.mp4\"");
inits.Remove(init);
}
} }
for (var i = 0; i < items.Count; i++) for (var i = 0; i < items.Count; i++)
@ -229,15 +221,9 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
if (outputFormat is OutputFormatKind.HlsMp4) if (outputFormat is OutputFormatKind.HlsMp4)
{ {
Option<long> maybeInit = Optional( output.AppendLine(
inits.Find(init => init > 0 && init <= nextSegment.GeneratedAt)); CultureInfo.InvariantCulture,
foreach (long init in maybeInit) $"#EXT-X-MAP:URI=\"{hlsInitSegmentCache.EarliestSegmentByHash(nextSegment.GeneratedAt)}\"");
{
output.AppendLine(
CultureInfo.InvariantCulture,
$"#EXT-X-MAP:URI=\"{init}_init.mp4\"");
inits.Remove(init);
}
} }
} }
} }
@ -269,7 +255,12 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
.Map(s => s.StartTime) .Map(s => s.StartTime)
.IfNone(DateTimeOffset.MaxValue); .IfNone(DateTimeOffset.MaxValue);
return Tuple(playlist, nextPlaylistStart, startSequence, generatedAt, allSegments.Count); return Tuple(
playlist,
nextPlaylistStart,
startSequence,
items.OfType<PlaylistSegment>().Min(s => s.GeneratedAt),
allSegments.Count);
} }
private abstract record PlaylistItem; private abstract record PlaylistItem;

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

9
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; namespace ErsatzTV.Core.FFmpeg;
@ -9,15 +8,15 @@ public interface IHlsPlaylistFilter
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
List<long> inits, IHlsInitSegmentCache hlsInitSegmentCache,
string[] lines, string[] lines,
int maxSegments = 10, Option<int> maybeMaxSegments,
bool endWithDiscontinuity = false); bool endWithDiscontinuity = false);
TrimPlaylistResult TrimPlaylistWithDiscontinuity( TrimPlaylistResult TrimPlaylistWithDiscontinuity(
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
List<long> inits, IHlsInitSegmentCache hlsInitSegmentCache,
string[] lines); string[] lines);
} }

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -16,4 +16,5 @@ public interface ILocalFileSystem
Unit EmptyFolder(string folder); Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path); Task<string> ReadAllText(string path);
Task<string[]> ReadAllLines(string path); Task<string[]> ReadAllLines(string path);
Task<byte[]> GetHash(string path);
} }

12
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.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -158,4 +160,12 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path); public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
public Task<string[]> ReadAllLines(string path) => File.ReadAllLinesAsync(path); public Task<string[]> ReadAllLines(string path) => File.ReadAllLinesAsync(path);
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public async Task<byte[]> GetHash(string path)
{
using var md5 = MD5.Create();
await using var stream = File.OpenRead(path);
return await md5.ComputeHashAsync(stream);
}
} }

1
ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs

@ -59,6 +59,7 @@ public class FakeLocalFileSystem : ILocalFileSystem
public Unit EmptyFolder(string folder) => Unit.Default; public Unit EmptyFolder(string folder) => Unit.Default;
public Task<string> ReadAllText(string path) => throw new NotImplementedException(); public Task<string> ReadAllText(string path) => throw new NotImplementedException();
public Task<string[]> ReadAllLines(string path) => throw new NotImplementedException(); public Task<string[]> ReadAllLines(string path) => throw new NotImplementedException();
public Task<byte[]> GetHash(string path) => throw new NotImplementedException();
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();

2
ErsatzTV/Startup.cs

@ -14,7 +14,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Health; using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Images; using ErsatzTV.Core.Images;
@ -813,6 +812,7 @@ public class Startup
services.AddScoped<IDecoSelector, DecoSelector>(); services.AddScoped<IDecoSelector, DecoSelector>();
services.AddScoped<IWatermarkSelector, WatermarkSelector>(); services.AddScoped<IWatermarkSelector, WatermarkSelector>();
services.AddScoped<IGraphicsElementSelector, GraphicsElementSelector>(); services.AddScoped<IGraphicsElementSelector, GraphicsElementSelector>();
services.AddScoped<IHlsInitSegmentCache, HlsInitSegmentCache>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>(); services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>(); services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save