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

@ -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 _));
}
}