feat: add authenticated live podcasts and simplify native video playback
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:
parent
d9b183c500
commit
4fc91cec3e
45 changed files with 2330 additions and 378 deletions
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal file
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue