feat(podcasts): prepare seekable MP4 replays and overlay controls

Remux completed broadcasts with FFmpeg, retain source segments and account for both formats. Serve authenticated byte ranges and add native Android/Desktop replay controls with auto-hide.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-25 00:48:53 +01:00
commit 1dcbfe159a
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
31 changed files with 1386 additions and 36 deletions

View file

@ -0,0 +1,78 @@
using System.Diagnostics;
using Microsoft.Extensions.Options;
namespace Yavsc.Services;
public sealed class FfmpegPodcastReplayRemuxer(IOptions<PodcastOptions> options) : IPodcastReplayRemuxer
{
public async Task RemuxAsync(string directory, int segmentCount, string outputPath, long maxBytes, CancellationToken ct)
{
if (segmentCount < 1) throw new InvalidDataException("Le podcast ne contient aucun segment.");
var list = Path.Combine(directory, "replay-concat.txt");
// Concatenate TS bytes without resetting timestamps or guessing durations between segments.
// Only generated local basenames enter the protocol, never uploaded metadata.
try
{
await File.WriteAllLinesAsync(list,
Enumerable.Range(0, segmentCount).Select(i => $"{i:D8}.ts"), ct);
var start = new ProcessStartInfo(options.Value.FfmpegPath)
{
UseShellExecute = false,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = directory
};
foreach (var argument in new[]
{
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-protocol_whitelist", "file,concatf", "-f", "mpegts", "-i", $"concatf:{list}",
"-map", "0:v:0", "-map", "0:a:0?", "-c", "copy", "-bsf:a", "aac_adtstoasc",
"-movflags", "+faststart", "-f", "mp4", outputPath
})
start.ArgumentList.Add(argument);
using var process = Process.Start(start)
?? throw new IOException("Impossible de demarrer FFmpeg.");
var errors = DrainErrorsAsync(process.StandardError, CancellationToken.None);
try
{
var exited = process.WaitForExitAsync(ct);
// -fs can report success for a truncated replay. Abort instead of publishing partial media.
while (!exited.IsCompleted)
{
await Task.WhenAny(exited, Task.Delay(50, ct));
ct.ThrowIfCancellationRequested();
if (File.Exists(outputPath) && new FileInfo(outputPath).Length > maxBytes)
throw new IOException("Le MP4 depasse le quota de stockage disponible.");
}
await exited;
var error = await errors;
if (process.ExitCode != 0)
throw new InvalidDataException($"FFmpeg a echoue ({process.ExitCode}) : {error}");
var length = new FileInfo(outputPath).Length;
if (length == 0 || length > maxBytes)
throw new IOException("Le MP4 depasse le quota de stockage disponible ou est vide.");
}
catch
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
await errors;
throw;
}
}
finally
{
File.Delete(list);
}
}
private static async Task<string> DrainErrorsAsync(StreamReader reader, CancellationToken ct)
{
var text = new System.Text.StringBuilder();
var buffer = new char[1024];
int count;
while ((count = await reader.ReadAsync(buffer.AsMemory(), ct)) != 0)
if (text.Length < 4096) text.Append(buffer, 0, Math.Min(count, 4096 - text.Length));
return text.ToString();
}
}

View file

@ -21,6 +21,8 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
private readonly TimeProvider _clock;
private readonly SemaphoreSlim _gate = new(1);
private readonly SemaphoreSlim _uploads = new(2);
private readonly SemaphoreSlim _replays = new(1);
private Guid? _preparingReplay;
private readonly Dictionary<Guid, Document> _documents = new();
private bool _loaded;
@ -34,7 +36,8 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
if (!Path.IsPathFullyQualified(_options.RootPath) || _options.MaxSegmentBytes < 188
|| _options.MaxSegmentBytes > 8 * 1024 * 1024
|| _options.MaxOwnerBytes < _options.MaxSegmentBytes || _options.MaxSegments < 1
|| _options.IdleTimeoutSeconds < 15 || _options.MaxOwnerPodcasts < 1)
|| _options.IdleTimeoutSeconds < 15 || _options.MaxOwnerPodcasts < 1
|| _options.ReplayTimeoutSeconds < 1 || string.IsNullOrWhiteSpace(_options.FfmpegPath))
throw new InvalidOperationException("Invalid Podcast storage options.");
}
@ -184,7 +187,11 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
var podcast = document.Manifest.Podcast;
if (podcast.State == PodcastState.Completed)
return podcast;
var updated = podcast with { State = PodcastState.Completed, UpdatedAt = _clock.GetUtcNow() };
var updated = podcast with
{
State = PodcastState.Completed, UpdatedAt = _clock.GetUtcNow(),
Replay = new(PodcastReplayState.Pending)
};
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = updated } }, ct);
_logger.LogInformation("Podcast {PodcastId} completed with {Segments} segments", id, updated.SegmentCount);
return updated;
@ -192,6 +199,119 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
finally { _gate.Release(); }
}
public async Task<Stream> OpenReplayAsync(Guid id, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var podcast = Find(id).Manifest.Podcast;
if (podcast.State != PodcastState.Completed || podcast.Replay?.State != PodcastReplayState.Ready)
throw new PodcastException(409, "Le replay MP4 n'est pas disponible.");
return new FileStream(Path.Combine(DirectoryFor(id), "replay.mp4"), FileMode.Open, FileAccess.Read,
FileShare.Read | FileShare.Delete, 64 * 1024, FileOptions.Asynchronous | FileOptions.RandomAccess);
}
finally { _gate.Release(); }
}
public async Task RetryReplayAsync(Guid id, string ownerId, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var document = Owned(id, ownerId);
if (document.Manifest.Podcast.Replay?.State != PodcastReplayState.Failed)
throw new PodcastException(409, "Seul un replay en echec peut etre relance.");
await SetReplayAsync(document, new(PodcastReplayState.Pending), ct);
}
finally { _gate.Release(); }
}
public async Task<bool> PrepareNextReplayAsync(IPodcastReplayRemuxer remuxer, CancellationToken ct)
{
await _replays.WaitAsync(ct);
try { return await PrepareReplayAsync(remuxer, ct); }
finally { _replays.Release(); }
}
private async Task<bool> PrepareReplayAsync(IPodcastReplayRemuxer remuxer, CancellationToken ct)
{
Document document;
long allowance;
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var next = _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Completed
&& d.Manifest.Podcast.Replay?.State == PodcastReplayState.Pending)
.OrderBy(d => d.Manifest.Podcast.CreatedAt).FirstOrDefault();
if (next is null) return false;
document = next;
allowance = _options.MaxOwnerBytes - OwnerBytes(document.Manifest.Podcast.OwnerId);
await SetReplayAsync(document, new(PodcastReplayState.Processing), ct);
_preparingReplay = document.Manifest.Podcast.Id;
}
finally { _gate.Release(); }
var id = document.Manifest.Podcast.Id;
var directory = DirectoryFor(id);
var temporary = Path.Combine(directory, "replay.tmp.mp4");
try
{
if (allowance <= 1) throw new IOException("Quota de stockage des podcasts atteint.");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(_options.ReplayTimeoutSeconds));
await remuxer.RemuxAsync(directory, document.Manifest.Segments.Count, temporary, allowance, timeout.Token);
var bytes = new FileInfo(temporary).Length;
if (bytes <= 0 || bytes > allowance) throw new IOException("Taille du MP4 invalide ou quota atteint.");
await _gate.WaitAsync(ct);
try
{
var current = Find(id);
var podcast = current.Manifest.Podcast;
if (OwnerBytes(podcast.OwnerId) + bytes > _options.MaxOwnerBytes)
throw new IOException("Quota de stockage des podcasts atteint.");
File.Move(temporary, Path.Combine(directory, "replay.mp4"), overwrite: true);
var updated = podcast with { Bytes = podcast.Bytes + bytes, Replay = new(PodcastReplayState.Ready, bytes) };
await SaveAsync(current with { Manifest = current.Manifest with { Podcast = updated } }, ct);
_logger.LogInformation("Podcast {PodcastId} MP4 ready ({Bytes} bytes)", id, bytes);
}
finally { _gate.Release(); }
}
catch (Exception ex)
{
_logger.LogError(ex, "Podcast {PodcastId} MP4 preparation failed", id);
File.Delete(temporary);
File.Delete(Path.Combine(directory, "replay.mp4"));
await _gate.WaitAsync(CancellationToken.None);
try
{
var current = Find(id);
var replay = ct.IsCancellationRequested
? new PodcastReplayResponse(PodcastReplayState.Pending)
: new PodcastReplayResponse(PodcastReplayState.Failed, Error:
"Preparation du MP4 impossible. L'auteur peut relancer la preparation.");
await SetReplayAsync(current, replay, CancellationToken.None);
}
finally { _gate.Release(); }
if (ct.IsCancellationRequested) throw;
}
finally
{
await _gate.WaitAsync(CancellationToken.None);
try { _preparingReplay = null; }
finally { _gate.Release(); }
}
return true;
}
private Task SetReplayAsync(Document document, PodcastReplayResponse replay, CancellationToken ct)
=> SaveAsync(document with
{
Manifest = document.Manifest with { Podcast = document.Manifest.Podcast with { Replay = replay } }
}, ct);
public async Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct)
{
await _gate.WaitAsync(ct);
@ -216,6 +336,8 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
var document = Owned(id, ownerId);
if (document.Manifest.Podcast.State == PodcastState.Live)
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
if (_preparingReplay == id)
throw new PodcastException(409, "Preparation du MP4 en cours. Reessayez apres sa finalisation.");
Directory.Delete(DirectoryFor(id), recursive: true);
_documents.Remove(id);
_logger.LogInformation("Podcast {PodcastId} deleted by {OwnerId}", id, ownerId);
@ -242,6 +364,9 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
// A restarted process cannot know whether its old encoder is still running.
foreach (var document in _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Live).ToArray())
await InterruptAsync(document, ct);
foreach (var document in _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Completed
&& (d.Manifest.Podcast.Replay is null || d.Manifest.Podcast.Replay.State == PodcastReplayState.Processing)).ToArray())
await SetReplayAsync(document, new(PodcastReplayState.Pending), ct);
_loaded = true;
}
@ -356,5 +481,6 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
foreach (var handler in Casters.Values) handler.Dispose();
_gate.Dispose();
_uploads.Dispose();
_replays.Dispose();
}
}