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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
40
INSTALL.md
40
INSTALL.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
src/PostIt/PostIt.Tests/PodcastReplayTests.cs
Normal file
199
src/PostIt/PostIt.Tests/PodcastReplayTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
17
src/PostIt/PostIt/Controls/ReplayControls.axaml
Normal file
17
src/PostIt/PostIt/Controls/ReplayControls.axaml
Normal 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>
|
||||
92
src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
Normal file
92
src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
90
src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
Normal file
90
src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/PostIt/PostIt/Services/ReplayControlsState.cs
Normal file
20
src/PostIt/PostIt/Services/ReplayControlsState.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
25
src/Yavsc.Api/Services/PodcastReplayWorker.cs
Normal file
25
src/Yavsc.Api/Services/PodcastReplayWorker.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
6
src/Yavsc.Server/Interfaces/IPodcastReplayRemuxer.cs
Normal file
6
src/Yavsc.Server/Interfaces/IPodcastReplayRemuxer.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Services;
|
||||
|
||||
public interface IPodcastReplayRemuxer
|
||||
{
|
||||
Task RemuxAsync(string directory, int segmentCount, string outputPath, long maxBytes, CancellationToken ct);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 _));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248
src/test/Yavsc.Api.Tests/PodcastReplayTests.cs
Normal file
248
src/test/Yavsc.Api.Tests/PodcastReplayTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue