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

@ -1,6 +1,13 @@
# Changelog
## [1.0.8-rc34] - unstable
### Added
* [Podcasts] Prepare authenticated, seekable MP4 replays for new and existing Completed broadcasts using server-side FFmpeg stream copy and faststart. Retain MPEG-TS, account for both copies in the owner quota, recover preparation after restarts, and expose owner-only retries plus GET/HEAD byte-range playback.
* [PostIt] Add Android/Desktop native video overlays for MP4 replay position, duration, seeking and play/pause, with three-second auto-hide. Use bounded authenticated range input with token refresh; preserve MPEG-TS live/interrupted playback.
## [1.0.8-rc33] - unstable
### Added

View file

@ -93,6 +93,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
ENTRYPOINT ["dotnet", "Yavsc.Org.dll"]
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api-runtime
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && apt-get clean
WORKDIR /app
COPY --from=publish-api /publish/ ./
RUN --mount=type=secret,id=yavsc_appsettings,dst=/run/secrets/yavsc_appsettings \

View file

@ -81,7 +81,7 @@ these Windows runtime behaviours.
PostIt Desktop's home page provides **Podcasts : directs et replays**.
Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the
Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the
default PulseAudio microphone. FFmpeg is **not** a runtime dependency.
default PulseAudio microphone. FFmpeg is **not** a client dependency.
Select a camera and optionally a microphone, enter a title, then start.
Stopping publishes the retained replay; navigating away or logging out
stops capture too. A second signed-in PostIt instance can watch the live
@ -93,7 +93,11 @@ replays; only the broadcaster can upload, complete or delete their podcast.
Media is not anonymous and no access token is included in a playback URL.
Configure Yavsc.Api's `Podcast` section (or environment variables such as
`Podcast__RootPath`). No database migration or server-side encoder is needed:
`Podcast__RootPath`). No database migration is needed. Install **FFmpeg 5+
on the API server** (with `concatf`, MPEG-TS, MP4 and AAC bitstream-filter
support; for Debian/Ubuntu: `apt-get install ffmpeg`). The API runtime stage
in the main Dockerfile includes it. Only stream-copy remuxing is performed,
not video/audio re-encoding:
```json
{
@ -103,7 +107,9 @@ Configure Yavsc.Api's `Podcast` section (or environment variables such as
"MaxOwnerBytes": 1073741824,
"MaxSegments": 7200,
"MaxOwnerPodcasts": 100,
"IdleTimeoutSeconds": 60
"IdleTimeoutSeconds": 60,
"FfmpegPath": "ffmpeg",
"ReplayTimeoutSeconds": 600
}
}
```
@ -120,7 +126,10 @@ owner. At the target two-second segment duration, this is roughly four hours
per episode; the byte quota can be reached earlier. Podcast quotas are
separate from the legacy user-files `DiskQuota`. Deleting an ended podcast
releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body
and forward Authorization on every API request.
and forward Authorization, Range and Content-Range on every API request/response.
The retained MPEG-TS **and** the additional MP4 count toward `MaxOwnerBytes`.
Allow enough free quota and disk space for both copies and the temporary MP4;
deletion removes all copies.
A broadcast without a new committed segment for 60 seconds, or present
when the API restarts, becomes an interrupted replay on the next catalogue,
@ -134,9 +143,26 @@ for manual recovery. There is no automatic resumption after an application
restart; these private local recordings may be removed manually when no
longer needed.
The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated
player joins a live broadcast up to three segments behind the edge and
reads a replay from its beginning; seeking is not implemented. Native
On completion, the API queues a private, seekable **MP4** (`-c copy`,
`+faststart`). The persisted `replay.state` moves through `Pending`,
`Processing`, `Ready` or `Failed`; completion itself does not wait for FFmpeg.
The single API worker also queues older Completed indexes automatically,
recovers interrupted preparation after a restart, and scans every five seconds.
Interrupted broadcasts keep MPEG-TS playback unless the owner explicitly
completes them. Source segments and existing HLS routes remain available.
Preparation failure keeps all source media and reports an error; after checking
server logs, FFmpeg installation, timeout and quota, the owner can use PostIt's
**Relancer la preparation MP4** action, or `POST /api/v1/podcasts/{id}/replay/retry`.
The MP4 is exposed only when ready, at authenticated
`GET /api/v1/podcasts/{id}/replay.mp4` (also HEAD; byte ranges supported).
PostIt's Android and Desktop player waits for MP4 preparation, then fetches
bounded authenticated ranges with token refresh rather than giving LibVLC
a bearer URL. Replay controls overlay the native video: play/pause, position,
duration and draggable seek bar. They hide after three seconds without
interaction while playing, stay visible during seeking/pause/keyboard focus,
and reappear on pointer movement or touch.
The live player still joins up to three segments behind the edge. Native
camera/microphone permissions, drivers and codecs must still be verified
on each target Windows/Linux machine.

View file

@ -79,22 +79,37 @@ Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) :
```bash
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests'
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' \
--filter-class '*PodcastReplayTests'
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests'
--filter-class '*PodcastTests' --filter-class '*PodcastReplayTests' \
--filter-class '*YavscApiClientTests' --filter-class '*PodcastNativeCaptureTests'
```
Les tests serveur couvrent HTTP/authentification, proprietaire, sequences,
reprises idempotentes, limites exactes, concurrence et persistance des replays
apres interruption/redemarrage, dans des dossiers temporaires isoles.
Installer FFmpeg 5+ et ffprobe pour ces tests serveur : ils generent une
source H.264/AAC synthetique de quatre secondes, verifient le remuxage reel
(pistes, duree, faststart, decodage et seek a deux secondes), puis les reponses
HTTP authentifiees GET/HEAD/206/416 du worker. Aucune camera ni microphone
n'est utilise. Les tests client couvrent les plages exactes et bornees,
les seeks avant/arriere/EOF, l'annulation, le renouvellement des jetons
et le seuil exact d'auto-hide de trois secondes.
Pour tester aussi le decodage MP4 et les seeks avant/arriere dans LibVLC,
definir `POSTIT_TEST_REPLAY` sur un MP4 H.264/AAC local de 8 a 30 secondes
(LibVLC installe sur la machine de test). Le test natif est ignore sinon.
Verifier manuellement les overlays sur Desktop et Android : pause/reprise,
glissement de la barre, masquage apres trois secondes, reapparition au
toucher/survol et nettoyage apres navigation ou mise en arriere-plan.
Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`.
Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir
cette variable sur un fichier video local d'environ huit secondes contenant
une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la
reprise apres acquittement perdu, le dernier segment apres arret et le
decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni
par l'application (le fichier d'entree peut provenir de n'importe quelle source).
decodage authentifie du replay MPEG-TS. Aucun FFmpeg n'est requis par ces tests
natifs client ni par PostIt (le serveur MP4, lui, en a besoin).
Les tests materiels camera/microphone Windows/Linux restent manuels.
### Rotation de la capture Android

View file

@ -21,7 +21,7 @@ internal static class PlatformBootstrap
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
Platform.CreateVideoSurface = (player, replay) => new AndroidVideoSurface(player, replay);
if (System.OperatingSystem.IsAndroidVersionAtLeast(26))
{
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;

View file

@ -3,13 +3,20 @@ using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
using LibVLCSharp.Shared;
using Android.Content;
using Android.OS;
using Android.Views;
using Android.Widget;
using PostIt.Services;
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
using Button = Android.Widget.Button;
namespace PostIt.Android.Services;
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : NativeControlHost, IDisposable
{
private VideoView? _video;
private ReplayLayout? _layout;
private bool _disposed;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
@ -18,13 +25,19 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
if (replay && !_disposed)
{
_layout = new ReplayLayout(activity, video, player);
return new AndroidViewControlHandle(_layout);
}
return new AndroidViewControlHandle(video);
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (control is AndroidViewControlHandle { View: VideoView video })
video.MediaPlayer = null;
_layout?.Stop();
_layout = null;
if (_video is not null) _video.MediaPlayer = null;
_video = null;
base.DestroyNativeControlCore(control);
}
@ -32,6 +45,101 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
public void Dispose()
{
_disposed = true;
_layout?.Stop();
if (_video is not null) _video.MediaPlayer = null;
}
private sealed class ReplayLayout : FrameLayout
{
private readonly MediaPlayer _player;
private readonly ReplayControlsState _state = new();
private readonly Handler _handler = new(Looper.MainLooper!);
private readonly Java.Lang.Runnable _tick;
private readonly LinearLayout _bar;
private readonly Button _toggle;
private readonly SeekBar _seek;
private readonly TextView _time;
private bool _stopped;
public ReplayLayout(Context context, VideoView video, MediaPlayer player) : base(context)
{
_player = player;
AddView(video, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent));
_bar = new LinearLayout(context) { Orientation = Orientation.Horizontal };
_bar.SetGravity(GravityFlags.CenterVertical);
_bar.SetPadding(12, 8, 12, 8);
_bar.SetBackgroundColor(global::Android.Graphics.Color.Argb(210, 20, 20, 20));
_toggle = new Button(context) { Text = "Pause", ContentDescription = "Lecture ou pause du replay" };
_seek = new SeekBar(context) { Max = 10000, ContentDescription = "Position dans le replay" };
_time = new TextView(context);
_time.SetTextColor(global::Android.Graphics.Color.White);
_bar.AddView(_toggle);
_bar.AddView(_seek, new LinearLayout.LayoutParams(0, LayoutParams.WrapContent, 1));
_bar.AddView(_time);
AddView(_bar, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.WrapContent, GravityFlags.Bottom));
_toggle.Click += Toggle;
_seek.StartTrackingTouch += BeginSeek;
_seek.StopTrackingTouch += EndSeek;
_seek.ProgressChanged += ProgressChanged;
_tick = new Java.Lang.Runnable(Update);
_handler.Post(_tick);
}
public override bool DispatchTouchEvent(MotionEvent? e)
{
_state.Show();
_bar.Visibility = ViewStates.Visible;
return base.DispatchTouchEvent(e);
}
private void Update()
{
if (_stopped) return;
var duration = Math.Max(0, _player.Length);
var position = Math.Max(0, _player.Time);
_bar.Visibility = _state.IsVisible(_player.IsPlaying, _bar.HasFocus) ? ViewStates.Visible : ViewStates.Gone;
_toggle.Text = _player.IsPlaying ? "Pause" : "Lire";
_seek.Enabled = duration > 0 && _player.IsSeekable;
if (!_state.IsInteracting)
_seek.Progress = duration > 0 ? (int)Math.Clamp(position * 10000d / duration, 0, 10000) : 0;
_time.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)(duration * (_seek.Progress / 10000d)) : position)} / {ReplayControlsState.FormatTime(duration)}";
_handler.PostDelayed(_tick, 250);
}
private void Toggle(object? sender, EventArgs e)
{
if (_player.IsPlaying) _player.Pause();
else _player.Play();
_state.Show();
}
private void BeginSeek(object? sender, SeekBar.StartTrackingTouchEventArgs e) => _state.IsInteracting = true;
private void EndSeek(object? sender, SeekBar.StopTrackingTouchEventArgs e)
{
Seek();
_state.IsInteracting = false;
}
private void ProgressChanged(object? sender, SeekBar.ProgressChangedEventArgs e)
{
if (e.FromUser && !_state.IsInteracting) Seek();
}
private void Seek()
{
if (_player.IsSeekable) _player.Time = (long)(_player.Length * (_seek.Progress / 10000d));
_state.Show();
}
public void Stop()
{
if (_stopped) return;
_stopped = true;
_handler.RemoveCallbacks(_tick);
_toggle.Click -= Toggle;
_seek.StartTrackingTouch -= BeginSeek;
_seek.StopTrackingTouch -= EndSeek;
_seek.ProgressChanged -= ProgressChanged;
_tick.Dispose();
_handler.Dispose();
}
}
}

View file

@ -0,0 +1,199 @@
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using Avalonia.Controls;
using Avalonia.Headless;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastReplayTests
{
private static readonly Guid Id = Guid.NewGuid();
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
public static bool HasNativeFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY"));
[Fact(Skip = "Set POSTIT_TEST_REPLAY to a local MP4 (8+ seconds) and install LibVLC.", SkipUnless = nameof(HasNativeFixture))]
public async Task LibVLC_decodes_authenticated_MP4_ranges_and_seeks_in_both_directions()
{
using var transport = new RangeTransport
{
Bytes = await File.ReadAllBytesAsync(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY")!, _ct)
};
var api = new PodcastApiClient(transport, () => "https://api.example/");
Core.Initialize();
using var vlc = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
using var media = new Media(vlc, input);
using var player = new MediaPlayer(vlc);
string? failure = null;
input.Failed += message => failure = message;
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new IOException("LibVLC MP4 decoding failed."));
Assert.True(player.Play(media));
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
while (player.Length <= 0 || !player.IsSeekable || player.Time <= 0)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Length, 8000, 30000);
var forward = player.Length / 2;
player.Time = forward;
while (player.Time < forward - 300)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, forward - 300, forward + 1000);
player.Time = 1000;
while (player.Time > 2500)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, 500, 2500);
await ended.Task.WaitAsync(timeout.Token);
Assert.Null(failure);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Video && t.Codec == 0x34363268);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Audio && t.Codec == 0x6134706d);
}
finally
{
input.Cancel();
player.Stop();
}
}
[Fact]
public Task Desktop_overlay_has_real_position_pause_and_duration_controls()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastReplayTests).Assembly).Dispatch(() =>
{
var overlay = new PostIt.Controls.ReplayControls();
var window = new Window { Content = overlay, Width = 640, Height = 360 };
try
{
window.Show();
Assert.NotNull(overlay.FindControl<Slider>("PositionSlider"));
Assert.NotNull(overlay.FindControl<Button>("ToggleButton"));
Assert.Equal("0:00 / 0:00", overlay.FindControl<TextBlock>("TimeLabel")?.Text);
}
finally { window.Close(); }
}, _ct);
[Fact]
public async Task Ranges_use_authenticated_factory_with_bounded_exact_content_and_no_token_in_URL()
{
await using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
Assert.Equal(transport.Bytes.Skip(12).Take(3), await api.DownloadReplayRangeAsync(Id, 12, 3, transport.Bytes.Length, _ct));
Assert.Equal("bytes=12-14", transport.Range);
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}/replay.mp4", transport.Url);
Assert.Equal(transport.Bytes.Skip(transport.Bytes.Length - 2),
await api.DownloadReplayRangeAsync(Id, transport.Bytes.Length - 2, 256 * 1024, transport.Bytes.Length, _ct));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
api.DownloadReplayRangeAsync(Id, 0, 256 * 1024 + 1, transport.Bytes.Length, _ct));
}
[Theory]
[InlineData("full")]
[InlineData("range")]
[InlineData("size")]
[InlineData("extra")]
public async Task Untrusted_or_unbounded_responses_fail_instead_of_playing_wrong_bytes(string failure)
{
await using var transport = new RangeTransport { Failure = failure };
var api = new PodcastApiClient(transport, () => "https://api.example/");
await Assert.ThrowsAsync<InvalidDataException>(() => api.DownloadReplayRangeAsync(Id, 0, 8, transport.Bytes.Length, _ct));
}
[Fact]
public void Native_input_supports_backward_and_forward_seek_EOF_and_cancellation()
{
using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
Assert.True(input.CanSeek);
var buffer = Marshal.AllocHGlobal(16);
try
{
Assert.True(input.Open(out var size));
Assert.Equal((ulong)transport.Bytes.Length, size);
Assert.True(input.CanSeek);
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(0, Marshal.ReadByte(buffer));
Assert.True(input.Seek(300000));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(transport.Bytes[300000], Marshal.ReadByte(buffer));
Assert.True(input.Seek(2));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(2, Marshal.ReadByte(buffer));
Assert.True(input.Seek(size - 1));
Assert.Equal(1, input.Read(buffer, 16));
Assert.Equal(0, input.Read(buffer, 16));
Assert.False(input.Seek(size + 1));
string? error = null;
input.Failed += message => error = message;
input.Cancel();
Assert.Equal(-1, input.Read(buffer, 16));
Assert.Null(error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Controls_hide_at_three_seconds_but_not_during_drag_pause_or_keyboard_focus()
{
var clock = new Clock();
var controls = new ReplayControlsState(clock);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(2999);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(1);
Assert.False(controls.IsVisible(true, false));
Assert.True(controls.IsVisible(false, false));
Assert.True(controls.IsVisible(true, true));
controls.IsInteracting = true;
Assert.True(controls.IsVisible(true, false));
controls.IsInteracting = false;
controls.Show();
Assert.True(controls.IsVisible(true, false));
Assert.Equal("0:00", ReplayControlsState.FormatTime(-1));
Assert.Equal("1:02", ReplayControlsState.FormatTime(62000));
Assert.Equal("1:01:02", ReplayControlsState.FormatTime(3662000));
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class RangeTransport : IYavscApiClient, IDisposable
{
public byte[] Bytes { get; init; } = Enumerable.Range(0, 600000).Select(i => (byte)i).ToArray();
public string? Failure { get; init; }
public string? Range { get; private set; }
public string? Url { get; private set; }
public HttpClient Http { get; } = new();
public Task<HttpResponseMessage> SendAuthenticatedAsync(Func<HttpRequestMessage> factory, CancellationToken ct = default)
{
using var request = factory();
Range = request.Headers.Range?.ToString();
Url = request.RequestUri!.AbsoluteUri;
var item = request.Headers.Range!.Ranges.Single();
var from = item.From!.Value;
var to = item.To!.Value;
var response = new HttpResponseMessage(Failure == "full" ? HttpStatusCode.OK : HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(Bytes[(int)from..(int)(to + 1 + (Failure == "extra" ? 1 : 0))])
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(
Failure == "range" ? from + 1 : from, to, Failure == "size" ? Bytes.Length + 1 : Bytes.Length);
return Task.FromResult(response);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
public void Dispose() => Http.Dispose();
}
}

View file

@ -17,6 +17,38 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Authenticated_streaming_refreshes_tokens_and_recreates_range_requests(bool expired)
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(forceFirstRequest: !expired);
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
if (expired) ExpireCachedAccessToken(tokensPath);
await using var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var requests = new List<HttpRequestMessage>();
using var response = await client.SendAuthenticatedAsync(() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, apiServer.BaseUrl + "/posts");
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(10, 20);
requests.Add(request);
return request;
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(expired ? 1 : 2, requests.Count);
Assert.All(requests, request => Assert.Equal("bytes=10-20", request.Headers.Range?.ToString()));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEmpty(apiServer.SeenBearers);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{

View file

@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Controls.ReplayControls"
Background="Transparent">
<Border x:Name="ControlBar" Background="#CC151515" Padding="12"
VerticalAlignment="Bottom">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button x:Name="ToggleButton" Content="Pause" Click="Toggle_Click"
AutomationProperties.Name="Lecture ou pause du replay" />
<Slider x:Name="PositionSlider" Grid.Column="1" Minimum="0" Maximum="1"
ValueChanged="Position_Changed" VerticalAlignment="Center"
AutomationProperties.Name="Position dans le replay" />
<TextBlock x:Name="TimeLabel" Grid.Column="2" Foreground="White"
VerticalAlignment="Center" Text="0:00 / 0:00" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,92 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Controls;
public partial class ReplayControls : UserControl
{
private readonly ReplayControlsState _state = new();
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(250) };
private MediaPlayer? _player;
private bool _updating;
public ReplayControls()
{
InitializeComponent();
_timer.Tick += Update;
PointerMoved += (_, _) => _state.Show();
PointerPressed += (_, _) => _state.Show();
KeyDown += (_, _) => _state.Show();
PositionSlider.AddHandler(PointerPressedEvent, BeginSeek, RoutingStrategies.Tunnel);
PositionSlider.AddHandler(PointerReleasedEvent, EndSeek, RoutingStrategies.Bubble, handledEventsToo: true);
PositionSlider.PointerCaptureLost += (_, _) =>
{
if (_state.IsInteracting) CommitSeek();
};
}
public ReplayControls(MediaPlayer player) : this() => _player = player;
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_state.Show();
_timer.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
base.OnDetachedFromVisualTree(e);
}
private void Update(object? sender, EventArgs e)
{
if (_player is not { } player) return;
var duration = Math.Max(0, player.Length);
var position = Math.Max(0, player.Time);
ControlBar.IsVisible = _state.IsVisible(player.IsPlaying, ControlBar.IsKeyboardFocusWithin);
ToggleButton.Content = player.IsPlaying ? "Pause" : "Lire";
PositionSlider.IsEnabled = duration > 0 && player.IsSeekable;
_updating = true;
PositionSlider.Maximum = Math.Max(1, duration);
if (!_state.IsInteracting) PositionSlider.Value = Math.Min(position, duration);
_updating = false;
TimeLabel.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)PositionSlider.Value : position)} / {ReplayControlsState.FormatTime(duration)}";
}
private void BeginSeek(object? sender, PointerPressedEventArgs e)
{
_state.IsInteracting = true;
_state.Show();
}
private void EndSeek(object? sender, PointerReleasedEventArgs e) => CommitSeek();
private void CommitSeek()
{
if (_player is { IsSeekable: true } player) player.Time = (long)PositionSlider.Value;
_state.IsInteracting = false;
_state.Show();
}
private void Position_Changed(object? sender, RangeBaseValueChangedEventArgs e)
{
if (!_updating && !_state.IsInteracting) CommitSeek();
}
private void Toggle_Click(object? sender, RoutedEventArgs e)
{
if (_player is not { } player) return;
if (player.IsPlaying) player.Pause();
else player.Play();
_state.Show();
}
}

View file

@ -17,7 +17,7 @@ public static class Platform
public static Func<string, string?, string, CancellationToken, Task<IPodcastCapture>>? CreatePodcastCaptureAsync { get; set; }
/// <summary>Platform-specific video surface inside the shared video page.</summary>
public static Func<MediaPlayer, Control>? CreateVideoSurface { get; set; }
public static Func<MediaPlayer, bool, Control>? CreateVideoSurface { get; set; }
public static event Action? PlaybackSuspending;
public static event Action? PlaybackResuming;

View file

@ -0,0 +1,90 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using LibVLCSharp.Shared;
using Yavsc.Api.Client;
namespace PostIt.Services;
public sealed class PodcastReplayMediaInput : MediaInput
{
private readonly PodcastApiClient _api;
private readonly Guid _id;
private readonly long _length;
private readonly CancellationTokenSource _cancellation = new();
private byte[] _buffer = [];
private long _bufferStart;
private long _position;
public event Action<string>? Failed;
public PodcastReplayMediaInput(PodcastApiClient api, Guid id, long length)
{
_api = api;
_id = id;
_length = length;
CanSeek = true;
}
public override bool Open(out ulong size)
{
if (_length <= 0)
{
size = 0;
Failed?.Invoke("Le MP4 du replay est vide.");
return false;
}
_position = 0;
_buffer = [];
size = (ulong)_length;
return true;
}
public override int Read(IntPtr buffer, uint lengthRequested)
{
if (lengthRequested == 0) return 0;
try
{
_cancellation.Token.ThrowIfCancellationRequested();
if (_position == _length) return 0;
if (_position < _bufferStart || _position >= _bufferStart + _buffer.Length)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_cancellation.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
_buffer = _api.DownloadReplayRangeAsync(_id, _position, 256 * 1024, _length, timeout.Token)
.GetAwaiter().GetResult();
_bufferStart = _position;
}
var offset = checked((int)(_position - _bufferStart));
var count = (int)Math.Min(lengthRequested, (uint)(_buffer.Length - offset));
Marshal.Copy(_buffer, offset, buffer, count);
_position += count;
return count;
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
catch (Exception ex)
{
Failed?.Invoke($"Lecture du replay interrompue : {ex.Message}");
return -1;
}
}
public override bool Seek(ulong offset)
{
if (offset > (ulong)_length || _cancellation.IsCancellationRequested) return false;
_position = (long)offset;
return true;
}
public override void Close() { }
public void Cancel() => _cancellation.Cancel();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cancellation.Cancel();
_cancellation.Dispose();
}
base.Dispose(disposing);
}
}

View file

@ -0,0 +1,20 @@
using System;
namespace PostIt.Services;
public sealed class ReplayControlsState(TimeProvider? clock = null)
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private DateTimeOffset _lastInteraction = (clock ?? TimeProvider.System).GetUtcNow();
public bool IsInteracting { get; set; }
public void Show() => _lastInteraction = _clock.GetUtcNow();
public bool IsVisible(bool playing, bool focused)
=> !playing || focused || IsInteracting || _clock.GetUtcNow() - _lastInteraction < TimeSpan.FromSeconds(3);
public static string FormatTime(long milliseconds)
{
var time = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
return time.TotalHours >= 1 ? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
}
}

View file

@ -519,6 +519,14 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
HttpMethod method,
string path,
CancellationToken ct = default)
{
using var response = await SendAuthenticatedAsync(() => new HttpRequestMessage(method, path), ct)
.ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public virtual async Task<HttpResponseMessage> SendAuthenticatedAsync(
Func<HttpRequestMessage> requestFactory, CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -527,8 +535,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
using var request = requestFactory();
return await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
@ -540,10 +548,15 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
using (response)
try
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
return response;
}
catch
{
response.Dispose();
throw;
}
}

View file

@ -79,6 +79,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand RetryReplayCommand { get; }
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
@ -100,6 +101,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
RetryReplayCommand = new AsyncRelayCommand(RetryReplayAsync,
() => SelectedPodcast is { State: PodcastState.Completed, Replay.State: PodcastReplayState.Failed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
}
private void UpdateCommands()
@ -111,6 +115,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
RetryReplayCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(CanNavigatePrevious));
}
@ -287,4 +292,17 @@ public partial class PodcastsPageViewModel : ViewModelBase
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private async Task RetryReplayAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.RetryReplayAsync(podcast.Id);
await RefreshAsync();
}
catch (Exception ex) { Status = $"Relance du MP4 impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
}

View file

@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@ -15,6 +17,8 @@ public partial class VideoPage : ContentPage
private LibVLC? _libVlc;
private MediaPlayer? _player;
private PodcastMediaInput? _podcastInput;
private PodcastReplayMediaInput? _replayInput;
private CancellationTokenSource? _initialization;
public VideoPage() => InitializeComponent();
@ -26,28 +30,62 @@ public partial class VideoPage : ContentPage
InitializePlayer();
}
private void InitializePlayer()
private async void InitializePlayer()
{
ReleasePlayer();
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
var cancellation = new CancellationTokenSource();
_initialization = cancellation;
var ct = cancellation.Token;
try
{
var replay = false;
var podcast = vm.Podcast;
if (podcast is not null && vm.PodcastClient is { } replayClient)
{
while (true)
{
var manifest = await replayClient.GetAsync(podcast.Id, ct);
ct.ThrowIfCancellationRequested();
podcast = manifest.Podcast;
if (podcast.State != PodcastState.Completed) break;
PlaybackStatus.Text = "Preparation du replay MP4...";
if (manifest.Podcast.Replay is { State: PodcastReplayState.Ready, Bytes: > 0 } ready)
{
_replayInput = new PodcastReplayMediaInput(replayClient, podcast.Id, ready.Bytes);
_replayInput.Failed += PodcastFailed;
replay = true;
break;
}
if (manifest.Podcast.Replay is { State: PodcastReplayState.Failed } failed)
{
PlaybackStatus.Text = failed.Error ?? "Preparation du MP4 en echec.";
return;
}
await Task.Delay(1000, ct);
}
}
ct.ThrowIfCancellationRequested();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
if (!replay && podcast is { } retained && vm.PodcastClient is { } client)
{
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
_podcastInput = new PodcastMediaInput(client, retained.Id, retained.State == PodcastState.Live);
_podcastInput.Failed += PodcastFailed;
}
using var media = _podcastInput is null
? new Media(_libVlc, vm.Source)
: new Media(_libVlc, _podcastInput);
using var media = _replayInput is not null ? new Media(_libVlc, _replayInput)
: _podcastInput is not null ? new Media(_libVlc, _podcastInput)
: new Media(_libVlc, vm.Source);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
var surface = Platform.CreateVideoSurface?.Invoke(_player)
?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
var surface = Platform.CreateVideoSurface?.Invoke(_player, replay)
?? new LibVLCSharp.Avalonia.VideoView
{
MediaPlayer = _player,
Content = replay ? new PostIt.Controls.ReplayControls(_player) : null
};
VideoSurface.Content = surface;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
@ -57,14 +95,16 @@ public partial class VideoPage : ContentPage
desktopVideo.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
PlaybackStatus.Text = replay ? "video/mp4" : vm.MimeType;
if (!_player.Play(media))
PlaybackStatus.Text = vm.Podcast is null
? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."
: "Impossible de demarrer la lecture du podcast.";
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex)
{
if (!ReferenceEquals(_initialization, cancellation)) return;
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
@ -88,8 +128,12 @@ public partial class VideoPage : ContentPage
private void ReleasePlayer()
{
_initialization?.Cancel();
_initialization?.Dispose();
_initialization = null;
PlayButton.IsEnabled = false;
_podcastInput?.Cancel();
_replayInput?.Cancel();
if (VideoSurface.Content is LibVLCSharp.Avalonia.VideoView desktopVideo)
desktopVideo.MediaPlayer = null;
if (VideoSurface.Content is IDisposable nativeSurface)
@ -110,6 +154,12 @@ public partial class VideoPage : ContentPage
}
_libVlc?.Dispose();
_libVlc = null;
if (_replayInput is not null)
{
_replayInput.Failed -= PodcastFailed;
_replayInput.Dispose();
_replayInput = null;
}
}
private void SuspendPlayback()

View file

@ -38,7 +38,9 @@
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
<TextBlock Text="{Binding Replay.State, StringFormat='MP4 : {0}'}" />
</StackPanel>
<TextBlock Text="{Binding Replay.Error}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
@ -46,6 +48,7 @@
<WrapPanel>
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
<Button Content="Relancer la preparation MP4" Command="{Binding RetryReplayCommand}" Margin="0,0,8,8" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />

View file

@ -7,6 +7,13 @@ namespace Yavsc.Abstract.Streaming;
[JsonConverter(typeof(JsonStringEnumConverter<PodcastState>))]
public enum PodcastState { Live, Completed, Interrupted }
/// <summary>Preparation state of the seekable MP4 copy of a completed broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastReplayState>))]
public enum PodcastReplayState { Pending, Processing, Ready, Failed }
/// <summary>Persistent MP4 preparation result; source segments are retained.</summary>
public sealed record PodcastReplayResponse(PodcastReplayState State, long Bytes = 0, string? Error = null);
/// <summary>Creates an authenticated live broadcast with a retained replay.</summary>
public sealed record CreatePodcastRequest
{
@ -18,7 +25,10 @@ public sealed record CreatePodcastRequest
public sealed record PodcastResponse(
Guid Id, string Title, string OwnerId, string OwnerName,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, PodcastState State,
int SegmentCount, long Bytes, double DurationSeconds);
int SegmentCount, long Bytes, double DurationSeconds)
{
public PodcastReplayResponse? Replay { get; init; }
}
/// <summary>An independently decodable MPEG-TS HLS segment.</summary>
public sealed record PodcastSegmentResponse(int Sequence, double DurationSeconds, int Bytes);

View file

@ -42,6 +42,11 @@ public interface IYavscApiClient : IAsyncDisposable
/// </summary>
HttpClient Http { get; }
/// <summary>Authenticated streaming request with token refresh and one 401 retry.
/// The caller owns the returned response; the factory must return a fresh request.</summary>
Task<HttpResponseMessage> SendAuthenticatedAsync(Func<HttpRequestMessage> requestFactory, CancellationToken ct = default)
=> throw new NotSupportedException("Authenticated streaming is not supported by this transport.");
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
@ -35,6 +37,36 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func<string> baseAddre
public Task<PodcastResponse> CompleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute($"{Prefix}/{id}/complete"), ct: ct);
public Task RetryReplayAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Post, Absolute($"{Prefix}/{id}/replay/retry"), ct: ct);
public async Task<byte[]> DownloadReplayRangeAsync(Guid id, long offset, int count, long size,
CancellationToken ct = default)
{
if (offset < 0 || count <= 0 || count > 256 * 1024 || offset >= size)
throw new ArgumentOutOfRangeException(nameof(offset), "Invalid replay byte range.");
var end = offset + Math.Min(count, size - offset) - 1;
using var response = await api.SendAuthenticatedAsync(() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, Absolute($"{Prefix}/{id}/replay.mp4"));
request.Headers.Range = new RangeHeaderValue(offset, end);
return request;
}, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var range = response.Content.Headers.ContentRange;
if (response.StatusCode != HttpStatusCode.PartialContent || range?.Unit != "bytes"
|| range.From != offset || range.To != end || range.Length != size)
throw new InvalidDataException("Le serveur n'a pas respecte la plage demandee pour le replay.");
var length = checked((int)(end - offset + 1));
var bytes = new byte[length];
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await stream.ReadExactlyAsync(bytes, ct).ConfigureAwait(false);
var extra = new byte[1];
if (await stream.ReadAsync(extra.AsMemory(), ct).ConfigureAwait(false) != 0)
throw new InvalidDataException("Reponse MP4 plus grande que la plage demandee.");
return bytes;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Delete, Absolute($"{Prefix}/{id}"), ct: ct);

View file

@ -58,7 +58,7 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
return Ok(await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct));
}
/// <summary>Ends the broadcast and publishes its retained HLS replay.</summary>
/// <summary>Ends the broadcast and schedules preparation of its MP4 replay.</summary>
[HttpPost("{id:guid}/complete")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<PodcastResponse>> Complete(Guid id, CancellationToken ct)
@ -68,6 +68,30 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
return Ok(await processor.CompleteAsync(id, ownerId, ct));
}
/// <summary>Serves a completed MP4 replay with authenticated byte-range support.</summary>
[HttpGet("{id:guid}/replay.mp4")]
[HttpHead("{id:guid}/replay.mp4")]
[Produces("video/mp4")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Replay(Guid id, CancellationToken ct)
{
Response.Headers.CacheControl = "private, no-store";
return File(await processor.OpenReplayAsync(id, ct), "video/mp4", enableRangeProcessing: true);
}
/// <summary>Retries failed MP4 preparation. Only the broadcast owner can retry.</summary>
[HttpPost("{id:guid}/replay/retry")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> RetryReplay(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
await processor.RetryReplayAsync(id, ownerId, ct);
return AcceptedAtAction(nameof(Get), new { id }, value: null);
}
/// <summary>Serves the authenticated event playlist, terminated for completed or interrupted replays.</summary>
[HttpGet("{id:guid}/index.m3u8")]
[Produces("application/vnd.apple.mpegurl")]

View file

@ -36,6 +36,19 @@ Authorization: Bearer {{token}}
POST {{baseUrl}}/api/v1/podcasts/{{id}}/complete
Authorization: Bearer {{token}}
### Ready MP4: authenticated seek range (206; 409 until replay.state is Ready)
GET {{baseUrl}}/api/v1/podcasts/{{id}}/replay.mp4
Authorization: Bearer {{token}}
Range: bytes=0-65535
### MP4 size and range support without downloading the body
HEAD {{baseUrl}}/api/v1/podcasts/{{id}}/replay.mp4
Authorization: Bearer {{token}}
### Retry failed MP4 preparation (owner only; 202)
POST {{baseUrl}}/api/v1/podcasts/{{id}}/replay/retry
Authorization: Bearer {{token}}
### Delete an ended podcast (owner only)
DELETE {{baseUrl}}/api/v1/podcasts/{{id}}
Authorization: Bearer {{token}}

View file

@ -0,0 +1,25 @@
using Yavsc.Services;
namespace Yavsc.Api.Services;
public sealed class PodcastReplayWorker(
ILiveProcessor processor, IPodcastReplayRemuxer remuxer, ILogger<PodcastReplayWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (await processor.PrepareNextReplayAsync(remuxer, stoppingToken)) continue;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
logger.LogError(ex, "Podcast MP4 worker failed; retrying the scan");
}
try { await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
}
}
}

View file

@ -8,6 +8,8 @@ public static class PodcastServiceCollectionExtensions
{
services.Configure<PodcastOptions>(configuration.GetSection("Podcast"));
services.AddSingleton<ILiveProcessor, LiveProcessor>();
services.AddSingleton<IPodcastReplayRemuxer, FfmpegPodcastReplayRemuxer>();
services.AddHostedService<PodcastReplayWorker>();
services.AddOpenApi("podcasts", options => options.ShouldInclude = description => description.GroupName == "podcasts");
return services;
}

View file

@ -27,6 +27,9 @@ namespace Yavsc.Services
double durationSeconds, Stream input, CancellationToken ct);
Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct);
Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct);
Task<Stream> OpenReplayAsync(Guid id, CancellationToken ct);
Task<bool> PrepareNextReplayAsync(IPodcastReplayRemuxer remuxer, CancellationToken ct);
Task RetryReplayAsync(Guid id, string ownerId, CancellationToken ct);
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Services;
public interface IPodcastReplayRemuxer
{
Task RemuxAsync(string directory, int segmentCount, string outputPath, long maxBytes, CancellationToken ct);
}

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

View file

@ -8,4 +8,6 @@ public sealed class PodcastOptions
public int MaxSegments { get; set; } = 7200;
public int MaxOwnerPodcasts { get; set; } = 100;
public int IdleTimeoutSeconds { get; set; } = 60;
public string FfmpegPath { get; set; } = "ffmpeg";
public int ReplayTimeoutSeconds { get; set; } = 600;
}

View file

@ -30,13 +30,16 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000")]
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/index.m3u8")]
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/segments/0.ts")]
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/replay.mp4")]
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/replay/retry")]
public async Task Metadata_playlists_and_segments_are_never_anonymous(string path)
{
using var anonymous = Client(null);
using var response = await anonymous.GetAsync(path, _ct);
var method = path.EndsWith("/retry", StringComparison.Ordinal) ? HttpMethod.Post : HttpMethod.Get;
using var response = await anonymous.SendAsync(new HttpRequestMessage(method, path), _ct);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
using var wrongScope = Client("bob", "profile");
using var forbidden = await wrongScope.GetAsync(path, _ct);
using var forbidden = await wrongScope.SendAsync(new HttpRequestMessage(method, path), _ct);
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
}
@ -64,6 +67,13 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
using var completed = await owner.PostAsync($"{path}/complete", null, _ct);
Assert.Equal(HttpStatusCode.OK, completed.StatusCode);
await WaitReplayAsync(owner, path, PodcastReplayState.Failed);
using var retryDenied = await listener.PostAsync($"{path}/replay/retry", null, _ct);
Assert.Equal(HttpStatusCode.Forbidden, retryDenied.StatusCode);
using var retry = await owner.PostAsync($"{path}/replay/retry", null, _ct);
Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode);
Assert.NotNull(retry.Headers.Location);
await WaitReplayAsync(owner, path, PodcastReplayState.Failed);
var replay = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
Assert.Contains("#EXT-X-ENDLIST", replay);
using var deleteDenied = await listener.DeleteAsync(path, _ct);
@ -74,6 +84,77 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
}
[Fact]
public async Task Worker_publishes_authenticated_seekable_MP4_with_GET_HEAD_and_416_ranges()
{
using var owner = Client($"mp4-owner-{Guid.NewGuid():N}");
using var listener = Client("mp4-listener");
var directory = Path.Combine(Path.GetTempPath(), $"yavsc-http-replay-{Guid.NewGuid():N}");
string? path = null;
try
{
var files = await PodcastMediaFixture.CreateAsync(directory, _ct);
using var created = await owner.PostAsJsonAsync("/api/v1/podcasts", new CreatePodcastRequest { Title = "Synthetic MP4" }, _ct);
var podcast = await created.Content.ReadFromJsonAsync<PodcastResponse>(_ct);
Assert.NotNull(podcast);
path = $"/api/v1/podcasts/{podcast.Id}";
using var unavailable = await listener.GetAsync($"{path}/replay.mp4", _ct);
Assert.Equal(HttpStatusCode.Conflict, unavailable.StatusCode);
for (var i = 0; i < files.Length; i++)
{
using var content = new ByteArrayContent(await File.ReadAllBytesAsync(files[i], _ct));
content.Headers.ContentType = new MediaTypeHeaderValue("video/mp2t");
using var appended = await owner.PutAsync($"{path}/segments/{i}?duration=1", content, _ct);
appended.EnsureSuccessStatusCode();
}
using var completed = await owner.PostAsync($"{path}/complete", null, _ct);
completed.EnsureSuccessStatusCode();
var ready = await WaitReplayAsync(owner, path, PodcastReplayState.Ready);
using var full = await listener.GetAsync($"{path}/replay.mp4", _ct);
Assert.Equal(HttpStatusCode.OK, full.StatusCode);
Assert.Equal("video/mp4", full.Content.Headers.ContentType?.MediaType);
Assert.True(full.Headers.CacheControl?.NoStore);
Assert.Contains("bytes", full.Headers.AcceptRanges);
var bytes = await full.Content.ReadAsByteArrayAsync(_ct);
Assert.Equal(ready.Replay!.Bytes, bytes.LongLength);
using var request = new HttpRequestMessage(HttpMethod.Get, $"{path}/replay.mp4");
request.Headers.Range = new RangeHeaderValue(7, 25);
using var range = await listener.SendAsync(request, _ct);
Assert.Equal(HttpStatusCode.PartialContent, range.StatusCode);
Assert.Equal($"bytes 7-25/{bytes.Length}", range.Content.Headers.ContentRange?.ToString());
Assert.Equal(bytes[7..26], await range.Content.ReadAsByteArrayAsync(_ct));
using var head = await listener.SendAsync(new HttpRequestMessage(HttpMethod.Head, $"{path}/replay.mp4"), _ct);
Assert.Equal(HttpStatusCode.OK, head.StatusCode);
Assert.Equal(bytes.Length, head.Content.Headers.ContentLength);
Assert.Empty(await head.Content.ReadAsByteArrayAsync(_ct));
using var beyond = new HttpRequestMessage(HttpMethod.Get, $"{path}/replay.mp4");
beyond.Headers.Range = new RangeHeaderValue(bytes.Length, null);
using var invalid = await listener.SendAsync(beyond, _ct);
Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, invalid.StatusCode);
Assert.Equal(bytes.Length, invalid.Content.Headers.ContentRange?.Length);
}
finally
{
if (path is not null) using (await owner.DeleteAsync(path, _ct)) { }
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
private async Task<PodcastResponse> WaitReplayAsync(HttpClient client, string path, PodcastReplayState expected)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
while (true)
{
var manifest = await client.GetFromJsonAsync<PodcastManifestResponse>(path, timeout.Token);
Assert.NotNull(manifest);
if (manifest.Podcast.Replay?.State == expected) return manifest.Podcast;
if (expected == PodcastReplayState.Ready)
Assert.NotEqual(PodcastReplayState.Failed, manifest.Podcast.Replay?.State);
await Task.Delay(100, timeout.Token);
}
}
[Fact]
public async Task Invalid_title_is_a_validation_problem()
{
@ -97,5 +178,9 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
Assert.True(paths.TryGetProperty("/api/v1/podcasts", out _));
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/segments/{sequence}", out _));
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/index.m3u8", out _));
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/replay.mp4", out var replay));
Assert.True(replay.TryGetProperty("get", out _));
Assert.True(replay.TryGetProperty("head", out _));
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/replay/retry", out _));
}
}

View file

@ -0,0 +1,248 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Streaming;
using Yavsc.Services;
namespace Yavsc.Api.Test;
public sealed class PodcastReplayTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"yavsc-replay-{Guid.NewGuid():N}");
private readonly List<LiveProcessor> _processors = [];
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
private LiveProcessor New(long quota = 1024 * 1024)
{
var processor = new LiveProcessor(NullLoggerFactory.Instance,
Options.Create(new PodcastOptions { RootPath = _root, MaxSegmentBytes = 1024 * 1024, MaxOwnerBytes = quota }));
_processors.Add(processor);
return processor;
}
private async Task<PodcastResponse> Complete(LiveProcessor processor, string owner = "alice")
{
var podcast = await processor.CreateAsync(owner, owner, "Test", _ct);
await processor.AppendAsync(podcast.Id, owner, 0, 2, new MemoryStream(LiveProcessorTests.Packets()), _ct);
return await processor.CompleteAsync(podcast.Id, owner, _ct);
}
[Fact]
public async Task Ready_is_atomic_retains_TS_and_accounts_both_formats_once_across_restart()
{
var processor = New();
var podcast = await Complete(processor);
Assert.Equal(PodcastReplayState.Pending, podcast.Replay?.State);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.OpenReplayAsync(podcast.Id, _ct))).StatusCode);
var remuxer = new Remuxer(async (_, _, output, allowance, ct) =>
{
Assert.Equal(1024 * 1024 - podcast.Bytes, allowance);
Assert.Equal(PodcastReplayState.Processing, (await processor.GetAsync(podcast.Id, ct)).Podcast.Replay?.State);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.DeleteAsync(podcast.Id, "alice", ct))).StatusCode);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.OpenReplayAsync(podcast.Id, ct))).StatusCode);
await File.WriteAllBytesAsync(output, [1, 2, 3, 4], ct);
});
Assert.True(await processor.PrepareNextReplayAsync(remuxer, _ct));
Assert.False(await processor.PrepareNextReplayAsync(remuxer, _ct));
var restored = New();
var ready = (await restored.GetAsync(podcast.Id, _ct)).Podcast;
Assert.Equal(new PodcastReplayResponse(PodcastReplayState.Ready, 4), ready.Replay);
Assert.Equal(podcast.Bytes + 4, ready.Bytes);
Assert.Equal(ready, await restored.CompleteAsync(podcast.Id, "alice", _ct));
await using (var mp4 = await restored.OpenReplayAsync(podcast.Id, _ct))
{
Assert.True(mp4.CanSeek);
mp4.Position = 2;
Assert.Equal(3, mp4.ReadByte());
}
await using (var ts = await restored.OpenSegmentAsync(podcast.Id, 0, _ct))
Assert.Equal(podcast.Bytes, ts.Length);
await restored.DeleteAsync(podcast.Id, "alice", _ct);
Assert.False(Directory.Exists(Path.Combine(_root, podcast.Id.ToString("N"))));
}
[Fact]
public async Task Failed_remux_cleans_partial_file_and_only_owner_can_retry()
{
var processor = New();
var podcast = await Complete(processor);
await processor.PrepareNextReplayAsync(new Remuxer(async (_, _, output, _, ct) =>
{
await File.WriteAllBytesAsync(output, [1, 2], ct);
throw new IOException("Synthetic failure");
}), _ct);
var failed = (await processor.GetAsync(podcast.Id, _ct)).Podcast;
Assert.Equal(PodcastReplayState.Failed, failed.Replay?.State);
Assert.NotEmpty(failed.Replay!.Error!);
Assert.Equal(podcast.Bytes, failed.Bytes);
Assert.False(File.Exists(Path.Combine(_root, podcast.Id.ToString("N"), "replay.tmp.mp4")));
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
() => processor.RetryReplayAsync(podcast.Id, "bob", _ct))).StatusCode);
await processor.RetryReplayAsync(podcast.Id, "alice", _ct);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.RetryReplayAsync(podcast.Id, "alice", _ct))).StatusCode);
Assert.True(await processor.PrepareNextReplayAsync(Bytes(4), _ct));
Assert.Equal(podcast.Bytes + 4, (await processor.GetAsync(podcast.Id, _ct)).Podcast.Bytes);
}
[Fact]
public async Task Quota_rechecked_after_concurrent_upload_and_failed_output_is_not_published()
{
const long quota = 1024 * 1024;
var processor = New(quota);
var podcast = await Complete(processor);
var other = await processor.CreateAsync("alice", "alice", "Next", _ct);
await processor.PrepareNextReplayAsync(new Remuxer(async (_, _, output, _, ct) =>
{
await File.WriteAllBytesAsync(output, new byte[quota - podcast.Bytes], ct);
await processor.AppendAsync(other.Id, "alice", 0, 2, new MemoryStream(LiveProcessorTests.Packets()), ct);
}), _ct);
Assert.Equal(PodcastReplayState.Failed, (await processor.GetAsync(podcast.Id, _ct)).Podcast.Replay?.State);
Assert.Equal(podcast.Bytes, (await processor.GetAsync(podcast.Id, _ct)).Podcast.Bytes);
Assert.False(File.Exists(Path.Combine(_root, podcast.Id.ToString("N"), "replay.mp4")));
}
[Fact]
public async Task Host_cancellation_requeues_and_releases_processing_guard()
{
var processor = New();
var podcast = await Complete(processor);
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_ct);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => processor.PrepareNextReplayAsync(
new Remuxer((_, _, _, _, ct) =>
{
cancellation.Cancel();
ct.ThrowIfCancellationRequested();
return Task.CompletedTask;
}), cancellation.Token));
Assert.Equal(PodcastReplayState.Pending, (await processor.GetAsync(podcast.Id, _ct)).Podcast.Replay?.State);
Assert.True(await processor.PrepareNextReplayAsync(Bytes(4), _ct));
await processor.DeleteAsync(podcast.Id, "alice", _ct);
}
[Theory]
[InlineData(null)]
[InlineData("Processing")]
public async Task Legacy_completed_and_in_progress_indexes_are_requeued_after_restart(string? state)
{
var processor = New();
var podcast = await Complete(processor);
var directory = Path.Combine(_root, podcast.Id.ToString("N"));
var index = Path.Combine(directory, "index.json");
var document = JsonNode.Parse(await File.ReadAllTextAsync(index, _ct))!;
document["Manifest"]!["Podcast"]!["Replay"] = state is null ? null
: JsonSerializer.SerializeToNode(new PodcastReplayResponse(PodcastReplayState.Processing));
await File.WriteAllTextAsync(index, document.ToJsonString(), _ct);
// Simulate a crash between rename and metadata publication.
await File.WriteAllBytesAsync(Path.Combine(directory, "replay.mp4"), [9, 9], _ct);
var restarted = New();
Assert.Equal(PodcastReplayState.Pending, (await restarted.GetAsync(podcast.Id, _ct)).Podcast.Replay?.State);
Assert.True(await restarted.PrepareNextReplayAsync(Bytes(4), _ct));
Assert.Equal(podcast.Bytes + 4, (await restarted.GetAsync(podcast.Id, _ct)).Podcast.Bytes);
}
[Fact]
public async Task Interrupted_is_not_queued_until_owner_explicitly_completes_it()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "alice", "Interrupted", _ct);
await processor.AppendAsync(podcast.Id, "alice", 0, 2, new MemoryStream(LiveProcessorTests.Packets()), _ct);
var restarted = New();
Assert.False(await restarted.PrepareNextReplayAsync(Bytes(4), _ct));
Assert.Equal(PodcastState.Interrupted, (await restarted.GetAsync(podcast.Id, _ct)).Podcast.State);
await restarted.CompleteAsync(podcast.Id, "alice", _ct);
Assert.True(await restarted.PrepareNextReplayAsync(Bytes(4), _ct));
}
[Fact]
public async Task Ffmpeg_remux_preserves_duration_tracks_faststart_and_decodable_time_seek()
{
var processor = New();
var source = Path.Combine(_root, "fixture");
var segments = await PodcastMediaFixture.CreateAsync(source, _ct);
var podcast = await processor.CreateAsync("alice", "Alice", "Synthetic", _ct);
for (var i = 0; i < segments.Length; i++)
await processor.AppendAsync(podcast.Id, "alice", i, 1,
new MemoryStream(await File.ReadAllBytesAsync(segments[i], _ct)), _ct);
await processor.CompleteAsync(podcast.Id, "alice", _ct);
var remuxer = new FfmpegPodcastReplayRemuxer(Options.Create(new PodcastOptions()));
Assert.True(await processor.PrepareNextReplayAsync(remuxer, _ct));
var ready = (await processor.GetAsync(podcast.Id, _ct)).Podcast;
Assert.Equal(PodcastReplayState.Ready, ready.Replay?.State);
var mp4 = Path.Combine(_root, podcast.Id.ToString("N"), "replay.mp4");
var probe = await PodcastMediaFixture.RunAsync("ffprobe",
["-v", "error", "-show_format", "-show_streams", "-of", "json", mp4], _ct);
using var json = JsonDocument.Parse(probe);
Assert.Equal(["h264", "aac"], json.RootElement.GetProperty("streams").EnumerateArray()
.Select(s => s.GetProperty("codec_name").GetString()!).ToArray());
var duration = double.Parse(json.RootElement.GetProperty("format").GetProperty("duration").GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
Assert.InRange(duration, 3.9, 4.3);
var ascii = Encoding.Latin1.GetString(await File.ReadAllBytesAsync(mp4, _ct));
Assert.True(ascii.IndexOf("moov", StringComparison.Ordinal) < ascii.IndexOf("mdat", StringComparison.Ordinal));
var frame = Path.Combine(source, "seek.rgb");
await PodcastMediaFixture.RunAsync("ffmpeg",
["-v", "error", "-ss", "2", "-i", mp4, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", frame], _ct);
Assert.Equal(160 * 120 * 3, new FileInfo(frame).Length);
await PodcastMediaFixture.RunAsync("ffmpeg", ["-v", "error", "-i", mp4, "-f", "null", "-"], _ct);
await Assert.ThrowsAsync<IOException>(() => remuxer.RemuxAsync(source, segments.Length,
Path.Combine(source, "too-big.mp4"), 10, _ct));
}
private static Remuxer Bytes(int count) => new((_, _, output, _, ct) => File.WriteAllBytesAsync(output, new byte[count], ct));
private sealed class Remuxer(Func<string, int, string, long, CancellationToken, Task> remux) : IPodcastReplayRemuxer
{
public Task RemuxAsync(string directory, int count, string output, long max, CancellationToken ct)
=> remux(directory, count, output, max, ct);
}
public void Dispose()
{
foreach (var processor in _processors) processor.Dispose();
if (Directory.Exists(_root)) Directory.Delete(_root, true);
}
}
internal static class PodcastMediaFixture
{
public static async Task<string[]> CreateAsync(string directory, CancellationToken ct)
{
Directory.CreateDirectory(directory);
await RunAsync("ffmpeg", ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "4",
"-c:v", "libx264", "-threads", "1", "-g", "10", "-c:a", "aac",
"-f", "segment", "-segment_time", "1", "-reset_timestamps", "0",
Path.Combine(directory, "%08d.ts")], ct);
return Directory.GetFiles(directory, "*.ts").Order(StringComparer.Ordinal).ToArray();
}
public static async Task<string> RunAsync(string tool, string[] arguments, CancellationToken ct)
{
var start = new ProcessStartInfo(tool) { RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new IOException($"Cannot start {tool}.");
var output = process.StandardOutput.ReadToEndAsync(ct);
var errors = process.StandardError.ReadToEndAsync(ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await process.WaitForExitAsync(timeout.Token);
var error = await errors;
Assert.True(process.ExitCode == 0, $"{tool} failed: {error}");
Assert.True(string.IsNullOrWhiteSpace(error), $"{tool} reported media errors: {error}");
return await output;
}
finally
{
if (!process.HasExited) process.Kill(true);
await process.WaitForExitAsync(CancellationToken.None);
}
}
}