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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue