From 1dcbfe159a8bf4a75260d06204aa8005d03c6c68 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 25 Oct 2026 00:48:53 +0100 Subject: [PATCH 01/15] 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> --- CHANGELOG.md | 7 + Dockerfile | 1 + INSTALL.md | 40 ++- doc/testing.md | 23 +- .../PostIt.Android/PlatformBootstrap.cs | 2 +- .../Services/AndroidVideoSurface.cs | 114 +++++++- src/PostIt/PostIt.Tests/PodcastReplayTests.cs | 199 ++++++++++++++ .../PostIt.Tests/YavscApiClientTests.cs | 32 +++ .../PostIt/Controls/ReplayControls.axaml | 17 ++ .../PostIt/Controls/ReplayControls.axaml.cs | 92 +++++++ src/PostIt/PostIt/Services/Platform.cs | 2 +- .../Services/PodcastReplayMediaInput.cs | 90 +++++++ .../PostIt/Services/ReplayControlsState.cs | 20 ++ src/PostIt/PostIt/Services/YavscApiClient.cs | 21 +- .../ViewModels/PodcastsPageViewModel.cs | 18 ++ .../PostIt/Views/Blogs/VideoPage.axaml.cs | 70 ++++- src/PostIt/PostIt/Views/PodcastsPage.axaml | 3 + src/Yavsc.Abstract/Streaming/Podcast.cs | 12 +- src/Yavsc.Api.Client/IYavscApiClient.cs | 5 + src/Yavsc.Api.Client/PodcastApiClient.cs | 32 +++ .../Controllers/Musical/PodcastController.cs | 26 +- src/Yavsc.Api/Podcast.http | 13 + src/Yavsc.Api/Services/PodcastReplayWorker.cs | 25 ++ .../PodcastServiceCollectionExtensions.cs | 2 + src/Yavsc.Server/Interfaces/ILiveProcessor.cs | 3 + .../Interfaces/IPodcastReplayRemuxer.cs | 6 + .../Services/FfmpegPodcastReplayRemuxer.cs | 78 ++++++ src/Yavsc.Server/Services/LiveProcessor.cs | 130 ++++++++- src/Yavsc.Server/Settings/PodcastOptions.cs | 2 + .../Yavsc.Api.Tests/PodcastControllerTests.cs | 89 ++++++- .../Yavsc.Api.Tests/PodcastReplayTests.cs | 248 ++++++++++++++++++ 31 files changed, 1386 insertions(+), 36 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/PodcastReplayTests.cs create mode 100644 src/PostIt/PostIt/Controls/ReplayControls.axaml create mode 100644 src/PostIt/PostIt/Controls/ReplayControls.axaml.cs create mode 100644 src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs create mode 100644 src/PostIt/PostIt/Services/ReplayControlsState.cs create mode 100644 src/Yavsc.Api/Services/PodcastReplayWorker.cs create mode 100644 src/Yavsc.Server/Interfaces/IPodcastReplayRemuxer.cs create mode 100644 src/Yavsc.Server/Services/FfmpegPodcastReplayRemuxer.cs create mode 100644 src/test/Yavsc.Api.Tests/PodcastReplayTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 224008f7e..3341cada5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 8ebbb73f0..9d6687b2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/INSTALL.md b/INSTALL.md index 5cea2b6f0..50f8f8d4b 100644 --- a/INSTALL.md +++ b/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. diff --git a/doc/testing.md b/doc/testing.md index 7b5de174f..081c8757f 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -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 diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index 2648b0f94..27efb918d 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -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; diff --git a/src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs b/src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs index 523a666e4..c405503b2 100644 --- a/src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs +++ b/src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs @@ -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(); + } + } } diff --git a/src/PostIt/PostIt.Tests/PodcastReplayTests.cs b/src/PostIt/PostIt.Tests/PodcastReplayTests.cs new file mode 100644 index 000000000..7242bd6a6 --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastReplayTests.cs @@ -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("PositionSlider")); + Assert.NotNull(overlay.FindControl