feat: add authenticated live podcasts and simplify native video playback
All checks were successful
Forgejo Release / release (push) Successful in 8m6s
Dotnet build and test / build (pull_request) Successful in 7m50s
Dotnet build and test / build (push) Successful in 9m29s

Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 15:22:34 +01:00
commit 4fc91cec3e
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
45 changed files with 2330 additions and 378 deletions

View file

@ -72,6 +72,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
.AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddSiteAdministration();
var podcastRoot = Path.Combine(Path.GetTempPath(), $"yavsc-api-podcasts-{Guid.NewGuid():N}");
builder.Services.AddPodcasts(builder.Configuration);
builder.Services.Configure<PodcastOptions>(options => options.RootPath = podcastRoot);
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>();
@ -82,6 +85,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy => policy.RequireAuthenticatedUser().RequireClaim("scope", "api"));
options.AddPolicy("AdministratorOnly", policy =>
{
policy.RequireAuthenticatedUser();
@ -107,7 +111,12 @@ public sealed class ApiWebServerFixture : WebHostFixture
};
});
return builder.Build();
var app = builder.Build();
app.Lifetime.ApplicationStopped.Register(() =>
{
if (Directory.Exists(podcastRoot)) Directory.Delete(podcastRoot, recursive: true);
});
return app;
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)

View file

@ -0,0 +1,267 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using System.Net.WebSockets;
using Yavsc.Models;
using Yavsc.Abstract.Streaming;
using Yavsc.Services;
namespace Yavsc.Api.Test;
public sealed class LiveProcessorTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"yavsc-podcast-unit-{Guid.NewGuid():N}");
private readonly List<LiveProcessor> _processors = new();
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
private LiveProcessor New(Action<PodcastOptions>? configure = null, TimeProvider? clock = null)
{
var options = new PodcastOptions { RootPath = _root };
configure?.Invoke(options);
var processor = new LiveProcessor(NullLoggerFactory.Instance, Options.Create(options), clock);
_processors.Add(processor);
return processor;
}
internal static byte[] Packets(int count = 2, byte marker = 1)
{
var bytes = Enumerable.Repeat(marker, 188 * count).ToArray();
for (var i = 0; i < bytes.Length; i += 188) bytes[i] = 0x47;
return bytes;
}
private Task<PodcastResponse> Append(LiveProcessor processor, Guid id, int sequence, byte[]? bytes = null,
double duration = 2, string owner = "alice")
=> processor.AppendAsync(id, owner, sequence, duration, new MemoryStream(bytes ?? Packets()), _ct);
[Fact]
public async Task Complete_and_restart_preserve_order_bytes_and_metadata()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", " Concert ", _ct);
var first = Packets(2, 11);
var second = Packets(3, 12);
await Append(processor, podcast.Id, 0, first);
await Append(processor, podcast.Id, 1, second, 2.5);
var completed = await processor.CompleteAsync(podcast.Id, "alice", _ct);
Assert.Equal(PodcastState.Completed, completed.State);
Assert.Equal(4.5, completed.DurationSeconds);
Assert.Equal(first.Length + second.Length, completed.Bytes);
var restored = New();
var manifest = await restored.GetAsync(podcast.Id, _ct);
Assert.Equal("Concert", manifest.Podcast.Title);
Assert.Equal(PodcastState.Completed, manifest.Podcast.State);
Assert.Equal(new[] { 0, 1 }, manifest.Segments.Select(s => s.Sequence));
await using var stream = await restored.OpenSegmentAsync(podcast.Id, 1, _ct);
using var actual = new MemoryStream();
await stream.CopyToAsync(actual, _ct);
Assert.Equal(second, actual.ToArray());
}
[Fact]
public async Task Ordered_ingestion_is_idempotent_and_conflicting_retries_are_rejected()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 1))).StatusCode);
var first = await Append(processor, podcast.Id, 0);
Assert.Equal(first, await Append(processor, podcast.Id, 0));
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, podcast.Id, 0, Packets(marker: 2)))).StatusCode);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, podcast.Id, 0, duration: 3))).StatusCode);
await processor.CompleteAsync(podcast.Id, "alice", _ct);
Assert.Equal(1, (await Append(processor, podcast.Id, 0)).SegmentCount);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 1))).StatusCode);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(15.01)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public async Task Invalid_durations_never_commit(double duration)
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
Assert.Equal(400, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, podcast.Id, 0, duration: duration))).StatusCode);
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
}
[Fact]
public async Task Invalid_empty_and_oversized_transport_streams_never_commit()
{
var processor = New(o => { o.MaxSegmentBytes = 376; o.MaxOwnerBytes = 752; });
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
foreach (var bytes in new[] { Array.Empty<byte>(), new byte[188], Packets()[..^1] })
Assert.Equal(400, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 0, bytes))).StatusCode);
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, podcast.Id, 0, Packets(3)))).StatusCode);
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
Assert.Equal(376, (await Append(processor, podcast.Id, 0, Packets(2))).Bytes);
}
[Fact]
public async Task Owner_quota_is_exact_shared_across_replays_and_released_on_delete()
{
var processor = New(o => { o.MaxSegmentBytes = 376; o.MaxOwnerBytes = 564; });
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
await Append(processor, first.Id, 0, Packets(2));
await processor.CompleteAsync(first.Id, "alice", _ct);
var second = await processor.CreateAsync("alice", "Alice", "Two", _ct);
await Append(processor, second.Id, 0, Packets(1));
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, second.Id, 1, Packets(1)))).StatusCode);
await processor.DeleteAsync(first.Id, "alice", _ct);
Assert.Equal(376, (await Append(processor, second.Id, 1, Packets(1))).Bytes);
}
[Fact]
public async Task Ownership_and_one_live_per_owner_are_enforced()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.CreateAsync("alice", "Alice", "Two", _ct))).StatusCode);
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
() => Append(processor, podcast.Id, 0, owner: "bob"))).StatusCode);
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
() => processor.CompleteAsync(podcast.Id, "bob", _ct))).StatusCode);
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
() => processor.DeleteAsync(podcast.Id, "bob", _ct))).StatusCode);
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
() => processor.DeleteAsync(podcast.Id, "alice", _ct))).StatusCode);
await processor.CreateAsync("bob", "Bob", "Other user", _ct);
Assert.Equal(2, (await processor.ListAsync(_ct)).Count);
}
[Fact]
public async Task Concurrent_identical_uploads_commit_only_once()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Append(processor, podcast.Id, 0)));
var manifest = await processor.GetAsync(podcast.Id, _ct);
Assert.Single(manifest.Segments);
Assert.Equal(376, manifest.Podcast.Bytes);
}
[Fact]
public async Task Restart_or_idle_timeout_ends_live_broadcast_but_keeps_its_segments()
{
var clock = new Clock();
var processor = New(clock: clock);
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
await Append(processor, first.Id, 0);
clock.Now += TimeSpan.FromSeconds(61);
Assert.Equal(PodcastState.Interrupted, (await processor.GetAsync(first.Id, _ct)).Podcast.State);
var second = await processor.CreateAsync("alice", "Alice", "Two", _ct);
await Append(processor, second.Id, 0);
var restarted = New(clock: clock);
var replay = await restarted.GetAsync(second.Id, _ct);
Assert.Equal(PodcastState.Interrupted, replay.Podcast.State);
Assert.Single(replay.Segments);
await using var segment = await restarted.OpenSegmentAsync(second.Id, 0, _ct);
Assert.Equal(376, segment.Length);
}
[Fact]
public async Task Segment_and_podcast_count_limits_are_enforced()
{
var processor = New(o => { o.MaxSegments = 1; o.MaxOwnerPodcasts = 1; });
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
await Append(processor, first.Id, 0);
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, first.Id, 1))).StatusCode);
await processor.CompleteAsync(first.Id, "alice", _ct);
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
() => processor.CreateAsync("alice", "Alice", "Two", _ct))).StatusCode);
}
[Fact]
public async Task Cancelled_upload_does_not_publish_partial_bytes_and_can_be_retried()
{
var processor = New();
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
using var cancelled = new CancellationTokenSource();
using var input = new CancellingStream(Packets(), cancelled);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => processor.AppendAsync(
podcast.Id, "alice", 0, 2, input, cancelled.Token));
Assert.Equal(188, input.Position);
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
Assert.Equal(1, (await Append(processor, podcast.Id, 0)).SegmentCount);
}
[Fact]
public async Task Legacy_websocket_preserves_different_frame_lengths_and_cleans_up()
{
var processor = New();
Directory.CreateDirectory(_root);
var first = new byte[] { 1, 2, 3 };
var second = new byte[] { 4, 5, 6, 7, 8 };
using var socket = new IncomingSocket(first, second);
var context = new DefaultHttpContext();
context.Features.Set<IHttpWebSocketFeature>(socket);
var user = new ApplicationUser { UserName = "legacy", DiskQuota = 1024, DiskUsage = 0 };
Assert.True(await processor.AcceptStream(context, user, _root, "legacy.ts"));
Assert.Equal(first.Concat(second), await File.ReadAllBytesAsync(Path.Combine(_root, "legacy.ts"), _ct));
Assert.Empty(processor.Casters);
Assert.Equal(8, user.DiskUsage);
}
public void Dispose()
{
foreach (var processor in _processors) processor.Dispose();
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class CancellingStream(byte[] bytes, CancellationTokenSource cancellation) : MemoryStream(bytes)
{
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var count = Read(buffer.Span[..Math.Min(188, buffer.Length)]);
cancellation.Cancel();
return ValueTask.FromResult(count);
}
}
private sealed class IncomingSocket(params byte[][] frames) : WebSocket, IHttpWebSocketFeature
{
private int _index;
private WebSocketState _state = WebSocketState.Open;
public bool IsWebSocketRequest => true;
public Task<WebSocket> AcceptAsync(WebSocketAcceptContext context) => Task.FromResult<WebSocket>(this);
public override WebSocketCloseStatus? CloseStatus => _state == WebSocketState.CloseReceived ? WebSocketCloseStatus.NormalClosure : null;
public override string? CloseStatusDescription => null;
public override WebSocketState State => _state;
public override string? SubProtocol => null;
public override void Abort() => _state = WebSocketState.Aborted;
public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
{ _state = WebSocketState.Closed; return Task.CompletedTask; }
public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
=> CloseAsync(closeStatus, statusDescription, cancellationToken);
public override void Dispose() => _state = WebSocketState.Closed;
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
{
if (_index == frames.Length)
{
_state = WebSocketState.CloseReceived;
return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true, WebSocketCloseStatus.NormalClosure, null));
}
var frame = frames[_index++];
frame.CopyTo(buffer.AsSpan());
return Task.FromResult(new WebSocketReceiveResult(frame.Length, WebSocketMessageType.Binary, true));
}
public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}

View file

@ -0,0 +1,101 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
private HttpClient Client(string? user = "alice", string scope = "api")
{
var client = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
}) { BaseAddress = new Uri(fixture.BaseAddress) };
if (user is not null)
client.DefaultRequestHeaders.Authorization = new("Bearer", TestTokenIssuer.Issue(user, scope));
return client;
}
[Theory]
[InlineData("/api/v1/podcasts")]
[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")]
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);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
using var wrongScope = Client("bob", "profile");
using var forbidden = await wrongScope.GetAsync(path, _ct);
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task Live_ingestion_playback_completion_and_deletion_work_over_http()
{
using var owner = Client($"podcast-owner-{Guid.NewGuid():N}");
using var listener = Client("podcast-listener");
using var created = await owner.PostAsJsonAsync("/api/v1/podcasts", new CreatePodcastRequest { Title = "Concert" }, _ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var podcast = await created.Content.ReadFromJsonAsync<PodcastResponse>(_ct);
Assert.NotNull(podcast);
Assert.NotNull(created.Headers.Location);
var path = $"/api/v1/podcasts/{podcast.Id}";
using var content = new ByteArrayContent(LiveProcessorTests.Packets());
content.Headers.ContentType = new MediaTypeHeaderValue("video/mp2t");
using var appended = await owner.PutAsync($"{path}/segments/0?duration=2.5", content, _ct);
Assert.Equal(HttpStatusCode.OK, appended.StatusCode);
var live = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
Assert.StartsWith("#EXTM3U", live);
Assert.Contains("#EXTINF:2.5,\nsegments/0.ts", live);
Assert.DoesNotContain("#EXT-X-ENDLIST", live);
Assert.Equal(LiveProcessorTests.Packets(), await listener.GetByteArrayAsync($"{path}/segments/0.ts", _ct));
using var denied = await listener.PostAsync($"{path}/complete", null, _ct);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
using var completed = await owner.PostAsync($"{path}/complete", null, _ct);
Assert.Equal(HttpStatusCode.OK, completed.StatusCode);
var replay = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
Assert.Contains("#EXT-X-ENDLIST", replay);
using var deleteDenied = await listener.DeleteAsync(path, _ct);
Assert.Equal(HttpStatusCode.Forbidden, deleteDenied.StatusCode);
using var deleted = await owner.DeleteAsync(path, _ct);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
using var missing = await listener.GetAsync(path, _ct);
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
}
[Fact]
public async Task Invalid_title_is_a_validation_problem()
{
using var owner = Client();
using var response = await owner.PostAsJsonAsync("/api/v1/podcasts", new { title = " " }, _ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("application/problem+json", response.Content.Headers.ContentType?.ToString());
}
[Fact]
public async Task OpenApi_exposes_podcast_routes_without_breaking_document_generation()
{
using var admin = Client("podcast-admin");
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
TestTokenIssuer.Issue("podcast-admin", "api",
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminGroupName) }));
using var response = await admin.GetAsync("/openapi/podcasts.json", _ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(_ct));
var paths = document.RootElement.GetProperty("paths");
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 _));
}
}