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:
parent
ed75f06fb2
commit
1dcbfe159a
31 changed files with 1386 additions and 36 deletions
78
src/Yavsc.Server/Services/FfmpegPodcastReplayRemuxer.cs
Normal file
78
src/Yavsc.Server/Services/FfmpegPodcastReplayRemuxer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue