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

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

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

View file

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

View file

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