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

@ -17,16 +17,16 @@
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.2" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.2" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
@ -36,6 +36,6 @@
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,166 @@
using System.Net;
using System.Net.Http;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastNativeCaptureTests
{
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder()
{
var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
Core.Initialize();
using var vlc = new LibVLC();
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
using var player = new MediaPlayer(vlc);
PodcastBroadcaster.ConfigureOutput(media, root);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed."));
Assert.True(player.Play(media));
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
player.Stop();
var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(playlist);
Assert.True(segments.Count >= 2, playlist);
Assert.Equal(0, segments[0].Sequence);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(root, segment.FileName), TestContext.Current.CancellationToken);
Assert.True(bytes.Length > 188);
Assert.Equal(0, bytes.Length % 188);
Assert.InRange(segment.DurationSeconds, 0.001, 15);
for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]);
}
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task Broadcaster_uploads_final_segment_retries_safely_and_replay_decodes_in_LibVLC()
{
var ct = TestContext.Current.CancellationToken;
await using var transport = new CaptureTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/api/v1/");
await using (var broadcaster = await PodcastBroadcaster.StartAsync(client, "Native test",
vlc => new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!))), ct))
{
await transport.FirstTwo.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
var livePlayback = PlayThroughAsync(client, transport.Podcast.Id, live: true, ct);
await transport.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
await broadcaster.StopAsync().WaitAsync(TimeSpan.FromSeconds(45), ct);
Assert.True(broadcaster.Completion.IsCompletedSuccessfully);
await livePlayback;
}
Assert.Equal(PodcastState.Completed, transport.Podcast.State);
Assert.True(transport.Uploaded.Count >= 3);
Assert.Equal(2, transport.FirstSegmentAttempts);
await PlayThroughAsync(client, transport.Podcast.Id, live: false, ct);
}
private static async Task PlayThroughAsync(PodcastApiClient client, Guid id, bool live, CancellationToken ct)
{
using var vlcPlayer = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
using var input = new PodcastMediaInput(client, id, live);
using var replay = new Media(vlcPlayer, input);
replay.AddOption(":demux=ts");
using var player = new MediaPlayer(vlcPlayer);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
input.Failed += message => ended.TrySetException(new InvalidOperationException(message));
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("Replay decode failed."));
Assert.True(player.Play(replay));
try
{
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Video && track.Codec == 0x34363268);
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Audio && track.Codec == 0x6134706d);
}
finally
{
input.Cancel();
player.Stop();
}
}
private sealed class CaptureTransport : IYavscApiClient
{
private readonly object _sync = new();
public HttpClient Http { get; } = new();
public Dictionary<int, byte[]> Uploaded { get; } = new();
private readonly List<PodcastSegmentResponse> _segments = new();
public TaskCompletionSource FirstTwo { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int FirstSegmentAttempts { get; private set; }
public PodcastResponse Podcast { get; private set; } = new(Guid.NewGuid(), "Test", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
lock (_sync)
{
object result;
if (typeof(T) == typeof(PodcastManifestResponse))
result = new PodcastManifestResponse(Podcast, _segments.ToArray());
else
{
if (path.EndsWith("/complete", StringComparison.Ordinal))
Podcast = Podcast with { State = PodcastState.Completed };
result = Podcast;
}
return Task.FromResult((T)result);
}
}
public async Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
var uri = new Uri(path);
var sequence = int.Parse(uri.Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
var duration = double.Parse(uri.Query["?duration=".Length..], System.Globalization.CultureInfo.InvariantCulture);
using var content = contentFactory();
var bytes = await content.ReadAsByteArrayAsync(ct);
if (sequence >= 2) await ReadStarted.Task.WaitAsync(ct);
lock (_sync)
{
if (Uploaded.TryGetValue(sequence, out var existing))
Assert.Equal(existing, bytes);
else
{
Assert.Equal(_segments.Count, sequence);
Uploaded.Add(sequence, bytes);
_segments.Add(new(sequence, duration, bytes.Length));
Podcast = Podcast with { SegmentCount = _segments.Count, Bytes = Podcast.Bytes + bytes.Length };
}
if (sequence == 0 && ++FirstSegmentAttempts == 1)
throw new HttpRequestException("Acknowledgement lost", null, HttpStatusCode.ServiceUnavailable);
if (Uploaded.Count >= 2) FirstTwo.TrySetResult();
return (T)(object)Podcast;
}
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, body, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
var sequence = int.Parse(Path.GetFileNameWithoutExtension(new Uri(path).AbsolutePath),
System.Globalization.CultureInfo.InvariantCulture);
lock (_sync)
{
ReadStarted.TrySetResult();
return Task.FromResult(Uploaded[sequence]);
}
}
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -0,0 +1,163 @@
using System.Globalization;
using System.Net.Http;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastTests
{
private static readonly Guid Id = Guid.Parse("9e2f6d47-2b9a-47ae-a870-073980bf2628");
[Fact]
public void Parses_only_complete_ordered_capture_segments_with_invariant_durations()
{
var segments = HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2.5,\nsegment-00000000.ts\n#EXTINF:1.75,\nsegment-00000001.ts\n");
Assert.Equal(2, segments.Count);
Assert.Equal(2.5, segments[0].DurationSeconds);
Assert.Equal(1, segments[1].Sequence);
Assert.Empty(HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2,\n"));
}
[Theory]
[InlineData("#EXTINF:NaN,\nsegment-00000000.ts\n")]
[InlineData("#EXTINF:20,\nsegment-00000000.ts\n")]
[InlineData("#EXTINF:2,\n../escape.ts\n")]
[InlineData("#EXTINF:2,\nhttps://other.test/segment-00000000.ts\n")]
[InlineData("#EXTINF:2,\nsegment-00000000.ts\n#EXTINF:2,\nsegment-00000002.ts\n")]
public void Rejects_invalid_or_nonlocal_capture_playlist(string text)
=> Assert.Throws<FormatException>(() => HlsCapturePlaylist.Parse(text));
[Fact]
public async Task Upload_uses_raw_transport_stream_and_replayable_content_factory()
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
await client.UploadAsync(Id, 0, 2.5, new byte[] { 0x47, 1 }, TestContext.Current.CancellationToken);
Assert.EndsWith($"/api/v1/podcasts/{Id}/segments/0?duration=2.5", transport.Path);
Assert.Equal(HttpMethod.Put, transport.Method);
Assert.NotNull(transport.ContentFactory);
using var first = transport.ContentFactory();
using var retry = transport.ContentFactory();
Assert.Equal("video/mp2t", first.Headers.ContentType?.MediaType);
Assert.Equal(new byte[] { 0x47, 1 }, await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
Assert.Equal(await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken),
await retry.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}
finally { CultureInfo.CurrentCulture = previous; }
}
[Theory]
[InlineData("https://api.example/")]
[InlineData("https://api.example/api/v1/")]
[InlineData("https://api.example/api/v1")]
public async Task Configured_api_url_never_duplicates_the_api_prefix(string baseUrl)
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => baseUrl);
await client.GetAsync(Id, TestContext.Current.CancellationToken);
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}", transport.Path);
}
[Fact]
public void Authenticated_playback_reads_segments_in_order_and_reports_end_of_replay()
{
var transport = new RecordingTransport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: false);
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.True(input.Open(out var size));
Assert.Equal(ulong.MaxValue, size);
Assert.Equal(2, input.Read(buffer, 4));
Assert.Equal(0x47, Marshal.ReadByte(buffer));
Assert.Equal(0, input.Read(buffer, 4));
Assert.EndsWith($"/{Id}/segments/0.ts", transport.DownloadPath);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Playback_errors_are_reported_without_crossing_the_native_callback_boundary()
{
var transport = new RecordingTransport { FailDownload = true };
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: false);
string? error = null;
input.Failed += message => error = message;
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.Equal(-1, input.Read(buffer, 4));
Assert.Contains("401", error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Live_playback_joins_three_segments_behind_the_edge_and_cancellation_is_quiet()
{
var transport = new RecordingTransport { SegmentCount = 8 };
var client = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastMediaInput(client, Id, live: true);
string? error = null;
input.Failed += message => error = message;
var buffer = Marshal.AllocHGlobal(4);
try
{
Assert.Equal(2, input.Read(buffer, 4));
Assert.EndsWith($"/{Id}/segments/5.ts", transport.DownloadPath);
input.Cancel();
Assert.Equal(-1, input.Read(buffer, 4));
Assert.Null(error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
private sealed class RecordingTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? Method { get; private set; }
public string? Path { get; private set; }
public string? DownloadPath { get; private set; }
public Func<HttpContent>? ContentFactory { get; private set; }
public bool FailDownload { get; init; }
public int SegmentCount { get; init; } = 1;
private static PodcastResponse Podcast => new(Id, "Live", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Completed, 1, 2, 2);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Method = method;
Path = path;
object response = typeof(T) == typeof(PodcastManifestResponse)
? new PodcastManifestResponse(Podcast with { SegmentCount = SegmentCount },
Enumerable.Range(0, SegmentCount).Select(i => new PodcastSegmentResponse(i, 2, 2)).ToArray()) : Podcast;
return Task.FromResult((T)response);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
ContentFactory = contentFactory;
return CallAsync<T>(method, path, ct: ct);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, body, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
DownloadPath = path;
if (FailDownload) throw new HttpRequestException("401 Unauthorized");
return Task.FromResult(new byte[] { 0x47, 1 });
}
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -38,6 +38,7 @@ public static class ServiceCollectionHelpers
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -46,6 +47,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
services.AddTransient<PodcastsPage>();
// Models
@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(podcastClient);
services.AddTransient<PodcastsPageViewModel>();
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);

View file

@ -12,6 +12,7 @@
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<InternalsVisibleTo Include="PostIt.Tests" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">

View file

@ -20,27 +20,14 @@ public class CameraStreamer
"--aout=dummy",
"--intf=dummy");
public static LibVLC CreatePlaybackLibVlc()
{
if (!OperatingSystem.IsLinux())
return new LibVLC();
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
if (preferHardware && IsVaapiUsable())
return new LibVLC();
// On Linux, a non-hardware display path is still valid for in-app playback as long as
// the platform is not explicitly forced into a broken VAAPI/VDPAU stack.
return new LibVLC("--avcodec-hw=none");
}
public static LibVLC CreatePreferredLibVlc()
{
if (!OperatingSystem.IsLinux())
return new LibVLC();
if (ShouldDisableHardwareAcceleration())
return CreateHeadlessLibVlc();
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
@ -50,6 +37,10 @@ public class CameraStreamer
return CreateHeadlessLibVlc();
}
private static bool ShouldDisableHardwareAcceleration()
=> string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
private static bool IsVaapiUsable()
{
var vaapiDrivers = new[]

View file

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
namespace PostIt.Services;
public sealed record CaptureSegment(int Sequence, double DurationSeconds, string FileName);
public static partial class HlsCapturePlaylist
{
[GeneratedRegex(@"^segment-(\d{8})\.ts$", RegexOptions.CultureInvariant)]
private static partial Regex SegmentName();
public static IReadOnlyList<CaptureSegment> Parse(string playlist)
{
var segments = new List<CaptureSegment>();
double? duration = null;
foreach (var line in playlist.Split('\n'))
{
var text = line.Trim();
if (text.StartsWith("#EXTINF:", StringComparison.Ordinal))
{
if (!double.TryParse(text[8..].Split(',')[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|| !double.IsFinite(value) || value <= 0 || value > 15)
throw new FormatException("Duree du segment LibVLC invalide.");
duration = value;
}
else if (text.Length > 0 && !text.StartsWith('#'))
{
var match = SegmentName().Match(text);
if (!match.Success || duration is null)
throw new FormatException("Index HLS LibVLC invalide.");
var sequence = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
if (segments.Count > 0 && sequence != segments[^1].Sequence + 1)
throw new FormatException("Segments HLS non consecutifs.");
segments.Add(new(sequence, duration.Value, text));
duration = null;
}
}
return segments;
}
}

View file

@ -0,0 +1,231 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Services;
public sealed class PodcastBroadcaster : IAsyncDisposable
{
private readonly PodcastApiClient _api;
private readonly LibVLC _vlc;
private readonly MediaPlayer _player;
private readonly Media _media;
private readonly string _directory;
private readonly CancellationTokenSource _cancellation = new();
private readonly SemaphoreSlim _stopGate = new(1);
private volatile bool _stopped;
private volatile bool _encoderFailed;
private bool _disposed;
public PodcastResponse Podcast { get; private set; }
public Task Completion { get; private set; } = Task.CompletedTask;
public event Action<PodcastResponse>? Progress;
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
LibVLC vlc, MediaPlayer player, Media media, string directory)
{
_api = api;
Podcast = podcast;
_vlc = vlc;
_player = player;
_media = media;
_directory = directory;
_player.EncounteredError += OnEncoderError;
}
public static void ConfigureOutput(Media media, string directory)
{
var path = directory.Replace('\\', '/').Replace("\"", "\\\"");
media.AddOption(":sout=#transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30,"
+ "acodec=mp4a,ab=128,channels=2,samplerate=48000}:std{"
+ $"access=livehttp{{seglen=2,delsegs=false,numsegs=0,initial-segment-number=0,index=\"{path}/index.m3u8\","
+ $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}");
}
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
string camera, string? microphone, CancellationToken ct)
=> await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct);
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<LibVLC, Media> createMedia, CancellationToken ct)
{
Core.Initialize();
var vlc = CameraStreamer.CreatePreferredLibVlc();
Media? media = null;
MediaPlayer? player = null;
PodcastBroadcaster? broadcaster = null;
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
PodcastResponse? podcast = null;
try
{
Directory.CreateDirectory(directory);
media = createMedia(vlc);
ConfigureOutput(media, directory);
player = new MediaPlayer(vlc);
podcast = await api.CreateAsync(title, ct);
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
ct.ThrowIfCancellationRequested();
broadcaster = new PodcastBroadcaster(api, podcast, vlc, player, media, directory);
if (!player.Play(media))
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
return broadcaster;
}
catch (Exception startupError)
{
if (broadcaster is not null)
{
broadcaster._player.EncounteredError -= broadcaster.OnEncoderError;
broadcaster._cancellation.Dispose();
broadcaster._stopGate.Dispose();
}
player?.Dispose();
media?.Dispose();
vlc.Dispose();
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
if (podcast is not null)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await api.CompleteAsync(podcast.Id, timeout.Token);
}
catch (Exception finishError)
{
throw new AggregateException("Capture impossible ; le direct serveur expirera automatiquement.", startupError, finishError);
}
}
throw;
}
}
public async Task StopAsync()
{
await StopEncoderAsync();
await Completion;
}
private async Task StopEncoderAsync()
{
await _stopGate.WaitAsync();
try
{
if (!_stopped)
{
await Task.Run(_player.Stop);
_stopped = true;
}
}
finally { _stopGate.Release(); }
}
private async Task UploadAsync(CancellationToken ct)
{
var next = 0;
var lastSegment = Stopwatch.StartNew();
try
{
while (true)
{
ct.ThrowIfCancellationRequested();
var finalPass = _stopped;
if (_encoderFailed)
throw new InvalidOperationException("La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC.");
var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length);
if (pendingBytes > 64L * 1024 * 1024)
throw new IOException("Le reseau est trop lent : la capture depasse 64 Mio de segments en attente.");
var playlist = Path.Combine(_directory, "index.m3u8");
if (File.Exists(playlist))
{
using var reader = new StreamReader(new FileStream(playlist, FileMode.Open,
FileAccess.Read, FileShare.ReadWrite | FileShare.Delete));
var segments = HlsCapturePlaylist.Parse(await reader.ReadToEndAsync(ct));
foreach (var segment in segments.Where(s => s.Sequence >= next))
{
if (segment.Sequence != next)
throw new InvalidDataException("Un segment manque dans la capture.");
var path = Path.Combine(_directory, segment.FileName);
if (new FileInfo(path).Length > 8 * 1024 * 1024)
throw new IOException("Un segment de capture depasse 8 Mio.");
var bytes = await File.ReadAllBytesAsync(path, ct);
var response = await UploadWithRetryAsync(segment, bytes, ct);
Podcast = response;
File.Delete(path);
next++;
lastSegment.Restart();
Progress?.Invoke(response);
}
}
if (finalPass)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
Podcast = await _api.CompleteAsync(Podcast.Id, timeout.Token);
return;
}
if (lastSegment.Elapsed > TimeSpan.FromSeconds(25))
throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la capture et les encodeurs LibVLC.");
await Task.Delay(400, ct);
}
}
finally
{
// Stop the camera even when uploading or parsing fails.
await StopEncoderAsync();
}
}
private async Task<PodcastResponse> UploadWithRetryAsync(CaptureSegment segment, byte[] bytes, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
return await _api.UploadAsync(Podcast.Id, segment.Sequence, segment.DurationSeconds, bytes, timeout.Token);
}
catch (HttpRequestException ex) when (attempt < 2 && (ex.StatusCode is null
or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)ex.StatusCode >= 500))
{
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && attempt < 2)
{
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
}
}
}
private void OnEncoderError(object? sender, EventArgs e) => _encoderFailed = true;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_cancellation.Cancel();
try { await Completion; }
finally
{
_player.EncounteredError -= OnEncoderError;
_player.Dispose();
_media.Dispose();
_vlc.Dispose();
_cancellation.Dispose();
_stopGate.Dispose();
// Keep failed, unacknowledged segments for manual recovery.
if (Completion.IsCompletedSuccessfully && Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
}
}

View file

@ -0,0 +1,78 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Services;
// LibVLC consumes a sequential transport stream; credentials stay in the existing HTTP transport.
public sealed class PodcastMediaInput(PodcastApiClient api, Guid id, bool live) : MediaInput
{
private readonly CancellationTokenSource _cancellation = new();
private byte[] _segment = Array.Empty<byte>();
private int _offset;
private int _sequence = -1;
public event Action<string>? Failed;
public override bool Open(out ulong size)
{
size = ulong.MaxValue;
return true;
}
public override int Read(IntPtr buffer, uint length)
{
if (length == 0) return 0;
try
{
var ct = _cancellation.Token;
ct.ThrowIfCancellationRequested();
while (_offset == _segment.Length)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
var manifest = api.GetAsync(id, timeout.Token).GetAwaiter().GetResult();
if (_sequence < 0)
_sequence = live ? Math.Max(0, manifest.Segments.Count - 3) : 0;
if (_sequence < manifest.Segments.Count)
{
_segment = api.DownloadSegmentAsync(id, _sequence, timeout.Token).GetAwaiter().GetResult();
if (_segment.Length == 0 || _segment.Length != manifest.Segments[_sequence].Bytes)
throw new InvalidOperationException("Segment podcast incomplet.");
_sequence++;
_offset = 0;
break;
}
if (manifest.Podcast.State != PodcastState.Live) return 0;
if (ct.WaitHandle.WaitOne(700)) ct.ThrowIfCancellationRequested();
}
var count = (int)Math.Min(length, (uint)(_segment.Length - _offset));
Marshal.Copy(_segment, _offset, buffer, count);
_offset += count;
return count;
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
// No managed exception may escape a native LibVLC callback.
catch (Exception ex)
{
Failed?.Invoke($"Lecture du podcast interrompue : {ex.Message}");
return -1;
}
}
public override bool Seek(ulong offset) => false;
public override void Close() { }
public void Cancel() => _cancellation.Cancel();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cancellation.Cancel();
_cancellation.Dispose();
}
base.Dispose(disposing);
}
}

View file

@ -540,8 +540,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
using (response)
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
}
public async Task LogoutAsync()

View file

@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate
{
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
PodcastsPageViewModel => services.GetRequiredService<PodcastsPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),

View file

@ -1,4 +1,6 @@
using System;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@ -6,7 +8,10 @@ public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelB
{
public Uri Source { get; } = source;
public string MimeType { get; } = mimeType;
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public PodcastApiClient? PodcastClient { get; init; }
public PodcastResponse? Podcast { get; init; }
public bool CanOpenExternal => Podcast is null;
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } }
}

View file

@ -74,10 +74,19 @@ public class HomePageViewModel : ViewModelBase
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
OpenPodcasts = new AsyncRelayCommand(async () =>
{
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
var vm = app.ServiceProvider?.GetRequiredService<PodcastsPageViewModel>()
?? throw new InvalidOperationException("Page podcasts indisponible.");
await app.PushPageAsync(vm);
});
}
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenPodcasts { get; }
public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }

View file

@ -0,0 +1,276 @@
using System;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed record CaptureDevice(string Name, string Value);
public partial class PodcastsPageViewModel : ViewModelBase
{
private readonly PodcastApiClient _client;
private readonly YavscApiClient _api;
private readonly SessionStatusViewModel _session;
private CancellationTokenSource _pageCancellation = new();
private PodcastBroadcaster? _broadcaster;
private string? _ownerId;
private string _title = "";
private string _status = "";
private bool _isBroadcasting;
private bool _busy;
private CaptureDevice? _camera;
private CaptureDevice? _microphone;
private PodcastResponse? _selected;
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
public ObservableCollection<CaptureDevice> Microphones { get; } = new();
public bool IsDesktop => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
public string Status { get => _status; private set => SetProperty(ref _status, value); }
public bool IsBroadcasting
{
get => _isBroadcasting;
private set { if (SetProperty(ref _isBroadcasting, value)) UpdateCommands(); }
}
public bool IsBusy
{
get => _busy;
private set { if (SetProperty(ref _busy, value)) UpdateCommands(); }
}
public CaptureDevice? SelectedCamera
{
get => _camera;
set { if (SetProperty(ref _camera, value)) UpdateCommands(); }
}
public CaptureDevice? SelectedMicrophone
{
get => _microphone;
set { if (SetProperty(ref _microphone, value)) UpdateCommands(); }
}
public PodcastResponse? SelectedPodcast
{
get => _selected;
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
}
public IAsyncRelayCommand RefreshCommand { get; }
public IRelayCommand DetectDevicesCommand { get; }
public IAsyncRelayCommand StartCommand { get; }
public IAsyncRelayCommand StopCommand { get; }
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
public PodcastsPageViewModel(PodcastApiClient client, YavscApiClient api, SessionStatusViewModel session)
{
_client = client;
_api = api;
_session = session;
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
DetectDevicesCommand = new RelayCommand(DetectDevices, () => IsDesktop && !IsBroadcasting && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => IsDesktop && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
FinishCommand = new AsyncRelayCommand(FinishAsync,
() => SelectedPodcast is { State: not PodcastState.Completed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
}
private void UpdateCommands()
{
RefreshCommand.NotifyCanExecuteChanged();
DetectDevicesCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
StopCommand.NotifyCanExecuteChanged();
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(CanNavigatePrevious));
}
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var profileUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me");
_ownerId = (await _api.CallAsync<Me>(HttpMethod.Get, profileUrl.AbsoluteUri)).UserId;
var podcasts = await _client.ListAsync();
Podcasts.Clear();
foreach (var podcast in podcasts) Podcasts.Add(podcast);
Status = "Directs et replays reserves aux utilisateurs connectes.";
}
catch (Exception ex) { Status = $"Chargement impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
public async Task ActivateAsync()
{
if (_pageCancellation.IsCancellationRequested)
{
_pageCancellation.Dispose();
_pageCancellation = new();
}
await RefreshAsync();
}
public async Task DeactivateAsync()
{
_pageCancellation.Cancel();
if (_broadcaster is not null) await StopAsync();
}
private void DetectDevices()
{
try
{
Core.Initialize();
using var vlc = CameraStreamer.CreatePreferredLibVlc();
var capture = new CameraStreamer(vlc);
Cameras.Clear();
foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value));
Microphones.Clear();
Microphones.Add(new("Sans microphone", ""));
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
}
catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; }
}
private async Task StartAsync()
{
IsBusy = true;
try
{
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
_broadcaster = await PodcastBroadcaster.StartAsync(_client, Title, SelectedCamera.Value,
string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value, _pageCancellation.Token);
_broadcaster.Progress += OnProgress;
_session.LogoutCompleted += OnLogout;
IsBroadcasting = true;
Status = "Capture en cours ; attente du premier segment HLS.";
_ = ObserveAsync(_broadcaster);
}
catch (Exception ex) { Status = $"Demarrage impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private void OnProgress(PodcastResponse podcast)
=> Dispatcher.UIThread.Post(() =>
{
if (_broadcaster?.Podcast.Id == podcast.Id)
Status = $"En direct : {podcast.SegmentCount} segments, {podcast.DurationSeconds:0} s envoyees.";
});
private async Task ObserveAsync(PodcastBroadcaster broadcaster)
{
try
{
await broadcaster.Completion;
Status = broadcaster.Podcast.SegmentCount > 0
? "Direct termine. Le replay est disponible."
: "Direct termine sans segment recu : aucun replay a lire.";
}
catch (Exception ex)
{
Status = $"Diffusion interrompue : {ex.Message}. Les segments recus restent disponibles ; les segments non envoyes sont conserves localement.";
}
finally
{
broadcaster.Progress -= OnProgress;
_session.LogoutCompleted -= OnLogout;
try { await broadcaster.DisposeAsync(); }
catch (Exception ex) { Status += $" Nettoyage : {ex.Message}"; }
_broadcaster = null;
IsBroadcasting = false;
}
}
public async Task StopAsync()
{
IsBusy = true;
try
{
if (_broadcaster is not null) await _broadcaster.StopAsync();
}
catch (Exception ex) { Status = $"Arret du direct : {ex.Message}"; }
finally { IsBusy = false; }
}
private async void OnLogout()
{
// Releasing capture is required even after the HTTP credentials have been cleared.
await StopAsync();
}
private async Task PlayAsync()
{
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8");
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
{
PodcastClient = _client, Podcast = podcast
});
}
catch (Exception ex) { Status = $"Lecture impossible : {ex.Message}"; }
}
private async Task FinishAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.CompleteAsync(podcast.Id);
await RefreshAsync();
}
catch (Exception ex) { Status = $"Finalisation impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private async Task DeleteAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.DeleteAsync(podcast.Id);
SelectedPodcast = null;
await RefreshAsync();
}
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
}

View file

@ -7,12 +7,12 @@
x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
<Border Grid.Row="1" Background="Black">
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
<vlc:VideoView x:Name="Video" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" IsVisible="{Binding CanOpenExternal}" />
</StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid>

View file

@ -1,5 +1,4 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@ -15,9 +14,7 @@ public partial class VideoPage : ContentPage
{
private LibVLC? _libVlc;
private MediaPlayer? _player;
private Media? _media;
private PodcastMediaInput? _podcastInput;
private bool _externalFallbackTriggered;
public VideoPage() => InitializeComponent();
@ -25,38 +22,42 @@ public partial class VideoPage : ContentPage
{
base.OnNavigatedTo(e);
ReleasePlayer();
if (DataContext is not VideoPageViewModel vm)
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
try
{
Core.Initialize();
_libVlc = CameraStreamer.CreatePlaybackLibVlc();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
{
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
_podcastInput.Failed += PodcastFailed;
_media = new Media(_libVlc, _podcastInput);
_media.AddOption(":demux=ts");
}
else
_media = new Media(_libVlc, vm.Source);
using var media = _podcastInput is null
? new Media(_libVlc, vm.Source)
: new Media(_libVlc, _podcastInput);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
Video.MediaPlayer = _player;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(_player, player))
Video.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
if (!_player.Play(_media))
if (!_player.Play(media))
PlaybackStatus.Text = vm.Podcast is null
? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."
: "Impossible de demarrer la lecture du podcast.";
}
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
or BadImageFormatException)
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
{
ReleasePlayer();
_externalFallbackTriggered = true;
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Ouverture du navigateur pour afficher le média.";
_ = OpenExternalFallbackAsync(vm.Source);
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
}
@ -84,8 +85,6 @@ public partial class VideoPage : ContentPage
_player.Dispose();
_player = null;
}
_media?.Dispose();
_media = null;
if (_podcastInput is not null)
{
_podcastInput.Failed -= PodcastFailed;
@ -100,55 +99,27 @@ public partial class VideoPage : ContentPage
private static bool CanLaunchExternally(Uri source)
=> source is { IsAbsoluteUri: true }
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Scheme, "https", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase);
private async Task OpenExternalFallbackAsync(Uri source)
{
try
{
if (!CanLaunchExternally(source))
{
PlaybackStatus.Text = "Lecture intégrée indisponible ; ce flux ne peut pas être ouvert dans le navigateur.";
return;
}
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur.";
return;
}
if (await topLevel.Launcher.LaunchUriAsync(source))
PlaybackStatus.Text = "Lecture intégrée indisponible ; le média a été ouvert dans le navigateur.";
else
PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur.";
}
catch (Exception ex)
{
PlaybackStatus.Text = $"Lecture intégrée indisponible : {ex.Message}. Ouvrez le média dans le navigateur.";
}
}
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
&& !source.AbsolutePath.Contains("/api/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(sender, _player) && !_externalFallbackTriggered)
{
_externalFallbackTriggered = true;
if (DataContext is VideoPageViewModel vm)
_ = OpenExternalFallbackAsync(vm.Source);
PlaybackStatus.Text = _podcastInput is null
? "Impossible de lire cette vidéo. Ouverture du navigateur…"
: "Impossible de lire le podcast. Ouverture du navigateur…";
}
if (!ReferenceEquals(sender, _player) || _player is null)
return;
PlaybackStatus.Text = _podcastInput is null
? "Impossible de lire cette vidéo."
: "Impossible de lire le podcast.";
});
private void PlayPause_Click(object? sender, RoutedEventArgs e)
{
if (_player is null || _media is null)
if (_player is null)
return;
if (_player.IsPlaying)
_player.Pause();

View file

@ -57,6 +57,11 @@
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Podcasts : directs et replays"
Command="{Binding OpenPodcasts}"
HorizontalAlignment="Center"
IsVisible="{Binding SupportsPodcasts}"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"

View file

@ -0,0 +1,55 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
x:Class="PostIt.Views.PodcastsPage"
x:DataType="vm:PodcastsPageViewModel"
Header="Podcasts">
<ScrollViewer>
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
<StackPanel Spacing="8" IsVisible="{Binding IsDesktop}" IsEnabled="{Binding !IsBroadcasting}">
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
</StackPanel>
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"
SelectionChanged="PodcastSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="streaming:PodcastResponse">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel>
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />
<Button Content="Supprimer mon replay" Command="{Binding DeleteCommand}" IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,25 @@
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class PodcastsPage : ContentPage
{
public PodcastsPage() => InitializeComponent();
private void PodcastSelectionChanged(object? sender, SelectionChangedEventArgs e) => ConfirmDelete.IsChecked = false;
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (DataContext is PodcastsPageViewModel vm && !vm.IsBroadcasting)
_ = vm.ActivateAsync();
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
if (DataContext is PodcastsPageViewModel vm)
_ = vm.DeactivateAsync();
base.OnNavigatedFrom(e);
}
}

View file

@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Yavsc.Abstract.Streaming;
/// <summary>The lifecycle of a retained live broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastState>))]
public enum PodcastState { Live, Completed, Interrupted }
/// <summary>Creates an authenticated live broadcast with a retained replay.</summary>
public sealed record CreatePodcastRequest
{
[Required, StringLength(160, MinimumLength = 1)]
public required string Title { get; init; }
}
/// <summary>A broadcast available to authenticated listeners.</summary>
public sealed record PodcastResponse(
Guid Id, string Title, string OwnerId, string OwnerName,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, PodcastState State,
int SegmentCount, long Bytes, double DurationSeconds);
/// <summary>An independently decodable MPEG-TS HLS segment.</summary>
public sealed record PodcastSegmentResponse(int Sequence, double DurationSeconds, int Bytes);
/// <summary>The ordered, committed segments of a live broadcast or replay.</summary>
public sealed record PodcastManifestResponse(
PodcastResponse Podcast, IReadOnlyList<PodcastSegmentResponse> Segments);

View file

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Streaming;
namespace Yavsc.Api.Client;
public sealed class PodcastApiClient(IYavscApiClient api, Func<string> baseAddress)
{
private const string Prefix = "podcasts";
public Task<List<PodcastResponse>> ListAsync(CancellationToken ct = default)
=> api.CallAsync<List<PodcastResponse>>(HttpMethod.Get, Absolute(Prefix), ct: ct);
public Task<PodcastResponse> CreateAsync(string title, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute(Prefix), new CreatePodcastRequest { Title = title }, ct);
public Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastManifestResponse>(HttpMethod.Get, Absolute($"{Prefix}/{id}"), ct: ct);
public Task<PodcastResponse> UploadAsync(Guid id, int sequence, double duration, byte[] bytes, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Put,
Absolute($"{Prefix}/{id}/segments/{sequence}?duration={duration.ToString("R", CultureInfo.InvariantCulture)}"),
() =>
{
var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new MediaTypeHeaderValue("video/mp2t");
return content;
}, ct);
public Task<PodcastResponse> CompleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute($"{Prefix}/{id}/complete"), ct: ct);
public Task DeleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Delete, Absolute($"{Prefix}/{id}"), ct: ct);
public Task<byte[]> DownloadSegmentAsync(Guid id, int sequence, CancellationToken ct = default)
=> api.DownloadAsync(HttpMethod.Get, Absolute($"{Prefix}/{id}/segments/{sequence}.ts"), ct);
private string Absolute(string path)
{
var address = new Uri(baseAddress().TrimEnd('/') + "/", UriKind.Absolute);
if (!address.AbsolutePath.EndsWith("/" + Constants.APIPrefix + "/", StringComparison.Ordinal))
address = new Uri(address, Constants.APIPrefix + "/");
return new Uri(address, path).AbsoluteUri;
}
}

View file

@ -1,8 +1,108 @@
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Yavsc.ApiControllers
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Middleware;
using Yavsc.Server.Helpers;
using Yavsc.Services;
namespace Yavsc.ApiControllers;
[ApiController]
[ApiExplorerSettings(GroupName = "podcasts")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[Authorize(Policy = "ApiScope")]
[Route(Constants.APIPrefix + "/podcasts")]
[TypeFilter(typeof(PodcastExceptionFilter))]
public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
public class PodcastController : Controller
/// <summary>Lists live broadcasts and retained replays for signed-in listeners.</summary>
[HttpGet]
[ProducesResponseType<IReadOnlyList<PodcastResponse>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PodcastResponse>>> List(CancellationToken ct)
=> Ok(await processor.ListAsync(ct));
/// <summary>Starts a broadcast owned by the current user.</summary>
[HttpPost]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PodcastResponse>> Create(CreatePodcastRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
var response = await processor.CreateAsync(ownerId, User.Identity?.Name ?? ownerId, request.Title, ct);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
/// <summary>Returns metadata and ordered committed segments.</summary>
[HttpGet("{id:guid}")]
[ProducesResponseType<PodcastManifestResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PodcastManifestResponse>> Get(Guid id, CancellationToken ct)
=> Ok(await processor.GetAsync(id, ct));
/// <summary>Commits an MPEG-TS segment. Identical retries are idempotent.</summary>
[HttpPut("{id:guid}/segments/{sequence:int}")]
[Consumes("video/mp2t")]
[RequestSizeLimit(8 * 1024 * 1024)]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status413PayloadTooLarge)]
public async Task<ActionResult<PodcastResponse>> Append(Guid id, int sequence,
[FromQuery] double duration, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct));
}
/// <summary>Ends the broadcast and publishes its retained HLS replay.</summary>
[HttpPost("{id:guid}/complete")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<PodcastResponse>> Complete(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.CompleteAsync(id, ownerId, ct));
}
/// <summary>Serves the authenticated event playlist, terminated for completed or interrupted replays.</summary>
[HttpGet("{id:guid}/index.m3u8")]
[Produces("application/vnd.apple.mpegurl")]
public async Task<IActionResult> Playlist(Guid id, CancellationToken ct)
{
var manifest = await processor.GetAsync(id, ct);
Response.Headers.CacheControl = "no-store";
var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:15\n");
text.Append("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:EVENT\n");
foreach (var segment in manifest.Segments)
{
text.Append(System.Globalization.CultureInfo.InvariantCulture, $"#EXTINF:{segment.DurationSeconds:0.######},\n");
text.Append(System.Globalization.CultureInfo.InvariantCulture, $"segments/{segment.Sequence}.ts\n");
}
if (manifest.Podcast.State != PodcastState.Live) text.Append("#EXT-X-ENDLIST\n");
return Content(text.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
}
/// <summary>Reads one immutable committed segment. Authentication is required on every request.</summary>
[HttpGet("{id:guid}/segments/{sequence:int}.ts")]
[Produces("video/mp2t")]
public async Task<IActionResult> Segment(Guid id, int sequence, CancellationToken ct)
{
Response.Headers.CacheControl = "private, no-store";
return File(await processor.OpenSegmentAsync(id, sequence, ct), "video/mp2t");
}
/// <summary>Deletes an ended broadcast and releases its owner's podcast quota.</summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
await processor.DeleteAsync(id, ownerId, ct);
return NoContent();
}
}

View file

@ -0,0 +1,21 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Yavsc.Services;
namespace Yavsc.Api.Middleware;
public sealed class PodcastExceptionFilter(ILogger<PodcastExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
var exception = context.Exception;
if (exception is not (PodcastException or IOException or JsonException))
return;
var status = exception is PodcastException podcast ? podcast.StatusCode : StatusCodes.Status503ServiceUnavailable;
var detail = exception is PodcastException ? exception.Message : "Le stockage des podcasts est indisponible.";
logger.LogWarning(exception, "Podcast request failed with status {Status}", status);
context.Result = new ObjectResult(new ProblemDetails { Status = status, Title = detail }) { StatusCode = status };
context.ExceptionHandled = true;
}
}

View file

@ -0,0 +1,49 @@
@baseUrl = https://localhost:5005
@token = REPLACE_WITH_ACCESS_TOKEN
@id = REPLACE_WITH_PODCAST_ID
### Create a live broadcast
POST {{baseUrl}}/api/v1/podcasts
Authorization: Bearer {{token}}
Content-Type: application/json
{"title":"Concert en direct"}
### List live broadcasts and replays
GET {{baseUrl}}/api/v1/podcasts
Authorization: Bearer {{token}}
### Read committed metadata
GET {{baseUrl}}/api/v1/podcasts/{{id}}
Authorization: Bearer {{token}}
### Upload the first complete MPEG-TS segment (file produced by LibVLC)
PUT {{baseUrl}}/api/v1/podcasts/{{id}}/segments/0?duration=4
Authorization: Bearer {{token}}
Content-Type: video/mp2t
< ./segment.ts
### Play the HLS event playlist (send Authorization on segment requests too)
GET {{baseUrl}}/api/v1/podcasts/{{id}}/index.m3u8
Authorization: Bearer {{token}}
### Read a committed segment
GET {{baseUrl}}/api/v1/podcasts/{{id}}/segments/0.ts
Authorization: Bearer {{token}}
### Finish and retain the replay
POST {{baseUrl}}/api/v1/podcasts/{{id}}/complete
Authorization: Bearer {{token}}
### Delete an ended podcast (owner only)
DELETE {{baseUrl}}/api/v1/podcasts/{{id}}
Authorization: Bearer {{token}}
### Unknown podcast: 404
GET {{baseUrl}}/api/v1/podcasts/00000000-0000-0000-0000-000000000000
Authorization: Bearer {{token}}
### OpenAPI in development (requires an Administrator token)
GET {{baseUrl}}/openapi/podcasts.json
Authorization: Bearer {{token}}

View file

@ -71,6 +71,7 @@ internal class Program
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
// the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
services.AddPodcasts(builder.Configuration);
// accepts any access token issued by identity server
services.AddAuthentication("Bearer")

View file

@ -0,0 +1,14 @@
using Yavsc.Services;
namespace Yavsc.Api.Services;
public static class PodcastServiceCollectionExtensions
{
public static IServiceCollection AddPodcasts(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<PodcastOptions>(configuration.GetSection("Podcast"));
services.AddSingleton<ILiveProcessor, LiveProcessor>();
services.AddOpenApi("podcasts", options => options.ShouldInclude = description => description.GroupName == "podcasts");
return services;
}
}

View file

@ -75,7 +75,8 @@ namespace Yavsc.Blogs.Controllers
}, $"{userName} is starting a stream!");
await liveProcessor.AcceptStream(HttpContext, user, destDir, shortFileName);
return Ok();
// The processor has already upgraded or rejected the HTTP response.
return new EmptyResult();
}
}
}

View file

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http;
using Yavsc.Models;
using Yavsc.ViewModels.Streaming;
using Yavsc.Abstract.Streaming;
namespace Yavsc.Services
{
@ -19,6 +20,14 @@ namespace Yavsc.Services
/// <returns></returns>
Task<bool> AcceptStream (HttpContext context, ApplicationUser user, string destDir, string fileName);
Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct);
Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct);
Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct);
Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
double durationSeconds, Stream input, CancellationToken ct);
Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct);
Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct);
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
}

View file

@ -1,204 +1,360 @@
#nullable enable annotations
#nullable enable
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Streaming;
using Yavsc.Models;
using Yavsc.ViewModels.Streaming;
using Newtonsoft.Json;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
namespace Yavsc.Services
namespace Yavsc.Services;
public sealed class LiveProcessor : ILiveProcessor, IDisposable
{
private sealed record Document(PodcastManifestResponse Manifest, string[] Hashes);
private readonly ILogger<LiveProcessor> _logger;
private readonly PodcastOptions _options;
private readonly TimeProvider _clock;
private readonly SemaphoreSlim _gate = new(1);
private readonly SemaphoreSlim _uploads = new(2);
private readonly Dictionary<Guid, Document> _documents = new();
private bool _loaded;
public class LiveProcessor : ILiveProcessor
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new();
public LiveProcessor(ILoggerFactory loggerFactory, IOptions<PodcastOptions> options, TimeProvider? clock = null)
{
private readonly ILogger _logger;
_logger = loggerFactory.CreateLogger<LiveProcessor>();
_options = options.Value;
_clock = clock ?? TimeProvider.System;
if (!Path.IsPathFullyQualified(_options.RootPath) || _options.MaxSegmentBytes < 188
|| _options.MaxSegmentBytes > 8 * 1024 * 1024
|| _options.MaxOwnerBytes < _options.MaxSegmentBytes || _options.MaxSegments < 1
|| _options.IdleTimeoutSeconds < 15 || _options.MaxOwnerPodcasts < 1)
throw new InvalidOperationException("Invalid Podcast storage options.");
}
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new ConcurrentDictionary<string, LiveCastHandler>();
public LiveProcessor(ILoggerFactory loggerFactory)
public async Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ownerId) || string.IsNullOrWhiteSpace(title) || title.Trim().Length > 160)
throw new PodcastException(400, "Un titre de 1 a 160 caracteres et un utilisateur sont requis.");
await _gate.WaitAsync(ct);
try
{
_logger = loggerFactory.CreateLogger<LiveProcessor>();
await LoadAsync(ct);
await ExpireAsync(ct);
if (_documents.Values.Any(d => d.Manifest.Podcast.OwnerId == ownerId && d.Manifest.Podcast.State == PodcastState.Live))
throw new PodcastException(409, "Vous avez deja un direct en cours.");
if (OwnerBytes(ownerId) >= _options.MaxOwnerBytes)
throw new PodcastException(413, "Quota de stockage des podcasts atteint.");
if (_documents.Values.Count(d => d.Manifest.Podcast.OwnerId == ownerId) >= _options.MaxOwnerPodcasts)
throw new PodcastException(413, "Nombre maximal de podcasts atteint. Supprimez un ancien replay.");
var now = _clock.GetUtcNow();
var podcast = new PodcastResponse(Guid.NewGuid(), title.Trim(), ownerId, ownerName,
now, now, PodcastState.Live, 0, 0, 0);
var document = new Document(new(podcast, Array.Empty<PodcastSegmentResponse>()), Array.Empty<string>());
Directory.CreateDirectory(DirectoryFor(podcast.Id));
await SaveAsync(document, ct);
_logger.LogInformation("Podcast {PodcastId} created by {OwnerId}", podcast.Id, ownerId);
return podcast;
}
finally { _gate.Release(); }
}
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
public async Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
// TODO defer request handling
string uname = user.UserName;
LiveCastHandler liveHandler = null;
if (Casters.ContainsKey(uname))
{
_logger.LogWarning($"Casters.ContainsKey({uname})");
liveHandler = Casters[uname];
if (liveHandler.Socket.State == WebSocketState.Open || liveHandler.Socket.State == WebSocketState.Connecting)
{
_logger.LogWarning($"Closing cx");
// FIXME loosed connexion should be detected & disposed else where
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "one by user", CancellationToken.None);
await LoadAsync(ct);
await ExpireAsync(ct);
return _documents.Values.Select(d => d.Manifest.Podcast)
.OrderByDescending(p => p.CreatedAt).ToArray();
}
finally { _gate.Release(); }
}
}
if (!liveHandler.TokenSource.IsCancellationRequested)
{
liveHandler.TokenSource.Cancel();
}
liveHandler.Socket.Dispose();
liveHandler.Socket = await context.WebSockets.AcceptWebSocketAsync();
liveHandler.TokenSource = new CancellationTokenSource();
}
else
{
_logger.LogInformation($"new caster");
// Accept the socket
liveHandler = new LiveCastHandler { Socket = await context.WebSockets.AcceptWebSocketAsync() };
}
_logger.LogInformation("Accepted web socket");
// Dispatch the flow
public async Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
await ExpireAsync(ct);
var manifest = Find(id).Manifest;
return manifest with { Segments = manifest.Segments.ToArray() };
}
finally { _gate.Release(); }
}
public async Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
double durationSeconds, Stream input, CancellationToken ct)
{
if (sequence < 0 || !double.IsFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 15)
throw new PodcastException(400, "Sequence ou duree du segment invalide (maximum 15 secondes).");
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
await ExpireAsync(ct);
_ = Owned(id, ownerId);
}
finally { _gate.Release(); }
// Bound concurrent body buffering without holding the metadata lock during network reads.
await _uploads.WaitAsync(ct);
try
{
var bytes = await ReadSegmentAsync(input, ct);
await _gate.WaitAsync(ct);
try
{
if (liveHandler.Socket != null && liveHandler.Socket.State == WebSocketState.Open)
var document = Owned(id, ownerId);
var podcast = document.Manifest.Podcast;
var segments = document.Manifest.Segments;
if (sequence > segments.Count)
throw new PodcastException(409, $"Le prochain segment attendu est {segments.Count}.");
if (sequence == segments.Count && podcast.State != PodcastState.Live)
throw new PodcastException(409, "Ce direct est termine.");
var hash = Convert.ToHexString(SHA256.HashData(bytes));
if (sequence < segments.Count)
{
Casters[uname] = liveHandler;
// TODO: Handle the socket here.
// Find receivers: others in the chat room
// send them the flow
var buffer = new byte[Constants.WebSocketsMaxBufLen];
var sBuffer = new ArraySegment<byte>(buffer);
_logger.LogInformation("Receiving bytes...");
WebSocketReceiveResult received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
_logger.LogInformation($"Received bytes : {received.Count}");
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
var fsInputQueue = new Queue<ArraySegment<byte>>();
bool endOfInput = false;
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
fsInputQueue.Enqueue(sBuffer);
var taskWritingToFs = liveHandler.ReceiveUserFile(user, _logger, destDir, fsInputQueue, fileName, () => endOfInput);
Stack<string> ToClose = new Stack<string>();
try
{
do
{
_logger.LogInformation($"Echoing {received.Count} bytes received in a {received.MessageType} message; Fin={received.EndOfMessage}");
// Echo anything we receive
// and send to all listner found
_logger.LogInformation($"{liveHandler.Listeners.Count} listeners");
foreach (var cliItem in liveHandler.Listeners)
{
var listenningSocket = cliItem.Value;
if (listenningSocket.State == WebSocketState.Open)
{
_logger.LogInformation(cliItem.Key);
await listenningSocket.SendAsync(
sBuffer, received.MessageType, received.EndOfMessage, liveHandler.TokenSource.Token);
}
else if (listenningSocket.State == WebSocketState.CloseReceived || listenningSocket.State == WebSocketState.CloseSent)
{
ToClose.Push(cliItem.Key);
}
}
if (!received.CloseStatus.HasValue)
{
_logger.LogInformation("try and receive new bytes");
buffer = new byte[Constants.WebSocketsMaxBufLen];
received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
_logger.LogInformation($"Received bytes : {received.Count}");
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
_logger.LogInformation($"segment : offset: {sBuffer.Offset} count: {sBuffer.Count}");
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
if (received.CloseStatus.HasValue)
{
endOfInput=true;
_logger.LogInformation($"received a close status: {received.CloseStatus.Value}: {received.CloseStatusDescription}");
}
else fsInputQueue.Enqueue(sBuffer);
}
else endOfInput=true;
while (ToClose.Count > 0)
{
string no = ToClose.Pop();
_logger.LogInformation("Closing follower connection");
WebSocket listenningSocket;
if (liveHandler.Listeners.TryRemove(no, out listenningSocket))
{
await listenningSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable,
"State != WebSocketState.Open", CancellationToken.None);
listenningSocket.Dispose();
}
}
}
while (liveHandler.Socket.State == WebSocketState.Open);
_logger.LogInformation("Closing connection");
taskWritingToFs.Wait();
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, received.CloseStatusDescription, liveHandler.TokenSource.Token);
liveHandler.TokenSource.Cancel();
liveHandler.Dispose();
_logger.LogInformation("Resulting file : " + JsonConvert.SerializeObject(taskWritingToFs.Result));
}
catch (Exception ex)
{
_logger.LogError($"Exception occured : {ex.Message}");
_logger.LogError(ex.StackTrace);
liveHandler.TokenSource.Cancel();
throw;
}
taskWritingToFs.Dispose();
if (document.Hashes[sequence] != hash || segments[sequence].DurationSeconds != durationSeconds)
throw new PodcastException(409, "Un autre contenu existe deja pour cette sequence.");
return podcast;
}
else
if (segments.Count >= _options.MaxSegments || OwnerBytes(ownerId) + bytes.Length > _options.MaxOwnerBytes)
throw new PodcastException(413, "Quota de stockage ou duree maximale du podcast atteint.");
var path = SegmentPath(id, sequence);
await File.WriteAllBytesAsync(path + ".tmp", bytes, ct);
File.Move(path + ".tmp", path, true);
var updated = podcast with
{
// Socket was not accepted open ...
// not (meta.Socket != null && meta.Socket.State == WebSocketState.Open)
if (liveHandler.Socket != null)
{
_logger.LogError($"meta.Socket.State not Open: {liveHandler.Socket.State} ");
liveHandler.Socket.Dispose();
}
else
_logger.LogError("socket object is null");
}
RemoveLiveInfo(uname);
UpdatedAt = _clock.GetUtcNow(), SegmentCount = segments.Count + 1,
Bytes = podcast.Bytes + bytes.Length, DurationSeconds = podcast.DurationSeconds + durationSeconds
};
await SaveAsync(new(new(updated,
segments.Append(new PodcastSegmentResponse(sequence, durationSeconds, bytes.Length)).ToArray()),
document.Hashes.Append(hash).ToArray()), ct);
return updated;
}
catch (IOException ex)
{
if (ex.Message == "Unexpected end of stream")
{
_logger.LogError($"Unexpected end of stream");
}
else
{
_logger.LogError($"Really unexpected end of stream");
await liveHandler.Socket?.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, ex.Message, CancellationToken.None);
}
liveHandler.Socket?.Dispose();
RemoveLiveInfo(uname);
}
return true;
finally { _gate.Release(); }
}
void RemoveLiveInfo(string userName)
{
LiveCastHandler caster;
if (Casters.TryRemove(userName, out caster))
_logger.LogInformation("removed live info");
else
_logger.LogError("could not remove live info");
finally { _uploads.Release(); }
}
private async Task<byte[]> ReadSegmentAsync(Stream input, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
try
{
using var buffer = new MemoryStream();
var chunk = new byte[64 * 1024];
int count;
while ((count = await input.ReadAsync(chunk, timeout.Token)) != 0)
{
if (buffer.Length + count > _options.MaxSegmentBytes)
throw new PodcastException(413, "Segment trop volumineux.");
await buffer.WriteAsync(chunk.AsMemory(0, count), timeout.Token);
}
var bytes = buffer.ToArray();
ValidateTransportStream(bytes);
return bytes;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new PodcastException(408, "Delai de reception du segment depasse.");
}
}
public async Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var document = Owned(id, ownerId);
var podcast = document.Manifest.Podcast;
if (podcast.State == PodcastState.Completed)
return podcast;
var updated = podcast with { State = PodcastState.Completed, UpdatedAt = _clock.GetUtcNow() };
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = updated } }, ct);
_logger.LogInformation("Podcast {PodcastId} completed with {Segments} segments", id, updated.SegmentCount);
return updated;
}
finally { _gate.Release(); }
}
public async Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var document = Find(id);
if (sequence < 0 || sequence >= document.Manifest.Segments.Count)
throw new PodcastException(404, "Segment introuvable.");
return new FileStream(SegmentPath(id, sequence), FileMode.Open, FileAccess.Read,
FileShare.Read | FileShare.Delete, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
}
finally { _gate.Release(); }
}
public async Task DeleteAsync(Guid id, string ownerId, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
var document = Owned(id, ownerId);
if (document.Manifest.Podcast.State == PodcastState.Live)
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
Directory.Delete(DirectoryFor(id), recursive: true);
_documents.Remove(id);
_logger.LogInformation("Podcast {PodcastId} deleted by {OwnerId}", id, ownerId);
}
finally { _gate.Release(); }
}
private async Task LoadAsync(CancellationToken ct)
{
if (_loaded) return;
Directory.CreateDirectory(_options.RootPath);
foreach (var directory in Directory.EnumerateDirectories(_options.RootPath))
{
if (!Guid.TryParseExact(Path.GetFileName(directory), "N", out var id)) continue;
var path = Path.Combine(directory, "index.json");
if (!File.Exists(path)) continue; // An uncommitted create may leave an empty directory.
await using var file = File.OpenRead(path);
var document = await JsonSerializer.DeserializeAsync<Document>(file, cancellationToken: ct)
?? throw new InvalidDataException($"Invalid podcast index {id}.");
if (document.Manifest.Podcast.Id != id || document.Hashes.Length != document.Manifest.Segments.Count)
throw new InvalidDataException($"Inconsistent podcast index {id}.");
_documents[id] = document;
}
// A restarted process cannot know whether its old encoder is still running.
foreach (var document in _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Live).ToArray())
await InterruptAsync(document, ct);
_loaded = true;
}
private async Task ExpireAsync(CancellationToken ct)
{
var deadline = _clock.GetUtcNow().AddSeconds(-_options.IdleTimeoutSeconds);
foreach (var document in _documents.Values.Where(d =>
d.Manifest.Podcast.State == PodcastState.Live && d.Manifest.Podcast.UpdatedAt < deadline).ToArray())
await InterruptAsync(document, ct);
}
private async Task InterruptAsync(Document document, CancellationToken ct)
{
var podcast = document.Manifest.Podcast with { State = PodcastState.Interrupted, UpdatedAt = _clock.GetUtcNow() };
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = podcast } }, ct);
_logger.LogWarning("Podcast {PodcastId} interrupted; committed segments remain playable", podcast.Id);
}
private async Task SaveAsync(Document document, CancellationToken ct)
{
var path = Path.Combine(DirectoryFor(document.Manifest.Podcast.Id), "index.json");
await using (var file = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(file, document, cancellationToken: ct);
await file.FlushAsync(ct);
}
File.Move(path + ".tmp", path, true);
_documents[document.Manifest.Podcast.Id] = document;
}
private Document Find(Guid id) => _documents.TryGetValue(id, out var document)
? document : throw new PodcastException(404, "Podcast introuvable.");
private Document Owned(Guid id, string ownerId)
{
var document = Find(id);
if (document.Manifest.Podcast.OwnerId != ownerId)
throw new PodcastException(403, "Seul l'auteur peut modifier ce podcast.");
return document;
}
private long OwnerBytes(string ownerId) => _documents.Values
.Where(d => d.Manifest.Podcast.OwnerId == ownerId).Sum(d => d.Manifest.Podcast.Bytes);
private string DirectoryFor(Guid id) => Path.Combine(_options.RootPath, id.ToString("N"));
private string SegmentPath(Guid id, int sequence) => Path.Combine(DirectoryFor(id), $"{sequence:D8}.ts");
private static void ValidateTransportStream(byte[] bytes)
{
if (bytes.Length == 0 || bytes.Length % 188 != 0)
throw new PodcastException(400, "Un segment MPEG-TS complet est requis.");
for (var i = 0; i < bytes.Length; i += 188)
if (bytes[i] != 0x47)
throw new PodcastException(400, "Synchronisation MPEG-TS invalide.");
}
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
{
var userName = user.UserName ?? throw new InvalidOperationException("A stream owner must have a user name.");
var handler = new LiveCastHandler();
if (!Casters.TryAdd(userName, handler))
{
handler.Dispose();
context.Response.StatusCode = StatusCodes.Status409Conflict;
return false;
}
try
{
handler.Socket = await context.WebSockets.AcceptWebSocketAsync();
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
context.RequestAborted, handler.TokenSource.Token);
var ct = cancellation.Token;
var path = Path.Combine(destDir, Path.GetFileName(fileName));
var oldLength = File.Exists(path) ? new FileInfo(path).Length : 0;
var usage = user.DiskUsage - oldLength;
await using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
var buffer = new byte[Constants.WebSocketsMaxBufLen];
while (handler.Socket.State == WebSocketState.Open)
{
var received = await handler.Socket.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
if (received.MessageType == WebSocketMessageType.Close) break;
if (received.MessageType != WebSocketMessageType.Binary || usage + received.Count > user.DiskQuota)
{
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "Binary data and available quota required.", ct);
return false;
}
await file.WriteAsync(buffer.AsMemory(0, received.Count), ct);
usage += received.Count;
user.DiskUsage = usage;
foreach (var listener in handler.Listeners.Values)
if (listener.State == WebSocketState.Open)
await listener.SendAsync(new ArraySegment<byte>(buffer, 0, received.Count),
WebSocketMessageType.Binary, received.EndOfMessage, ct);
}
if (handler.Socket.State == WebSocketState.CloseReceived)
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Stream complete.", ct);
return true;
}
catch (Exception ex) when (ex is IOException or WebSocketException or OperationCanceledException)
{
_logger.LogWarning(ex, "Legacy stream interrupted for {UserName}", user.UserName);
throw;
}
finally
{
Casters.TryRemove(userName, out _);
handler.Dispose();
}
}
public void Dispose()
{
foreach (var handler in Casters.Values) handler.Dispose();
_gate.Dispose();
_uploads.Dispose();
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Services;
public sealed class PodcastException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}

View file

@ -0,0 +1,11 @@
namespace Yavsc.Services;
public sealed class PodcastOptions
{
public string RootPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "DataDir", "podcasts");
public int MaxSegmentBytes { get; set; } = 8 * 1024 * 1024;
public long MaxOwnerBytes { get; set; } = 1024L * 1024 * 1024;
public int MaxSegments { get; set; } = 7200;
public int MaxOwnerPodcasts { get; set; } = 100;
public int IdleTimeoutSeconds { get; set; } = 60;
}

View file

@ -1,85 +1,33 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using Microsoft.Extensions.Logging;
using Yavsc.Models;
using Yavsc.Server.Helpers;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ViewModels.Streaming
namespace Yavsc.ViewModels.Streaming;
public class LiveCastClient
{
public class LiveCastClient
{
public string UserName { get; set; }
public WebSocket Socket { get; set; }
}
public class LiveEntryViewModel
{
public string UserName { get; set; }
public string FlowId { get; set; }
}
public class LiveCastHandler : IDisposable
{
public WebSocket Socket { get; set; }
public ConcurrentDictionary<string, WebSocket> Listeners { get; set; } = new ConcurrentDictionary<string, WebSocket>();
public CancellationTokenSource TokenSource { get; set; } = new CancellationTokenSource();
public void Dispose()
{
}
public async Task<FileReceivedInfo> ReceiveUserFile(ApplicationUser user, ILogger logger, string root, Queue<ArraySegment<byte>> queue, string destFileName, Func<bool> isEndOfInput)
{
// TODO lock user's disk usage for this scope,
// this process is not safe at concurrent access.
long usage = user.DiskUsage;
var item = new FileReceivedInfo
(root, AbstractFileSystemHelpers.FilterFileName(destFileName));
var fi = new FileInfo(Path.Combine(root, item.FileName));
if (fi.Exists)
{
item.Overridden = true;
usage -= fi.Length;
}
logger.LogInformation("Opening the file");
using (var dest = fi.Open(FileMode.Create, FileAccess.Write, FileShare.Read))
{
logger.LogInformation("Appening to file");
while (!isEndOfInput() || queue.Count > 0)
{
if (queue.Count > 0)
{
var buffer = queue.Dequeue();
logger.LogInformation($"writing {buffer.Count} bytes...");
await dest.WriteAsync(buffer.Array, buffer.Offset, buffer.Count);
logger.LogInformation($"done.");
usage += buffer.Count;
}
if (usage >= user.DiskQuota) break;
if (queue.Count == 0 && !isEndOfInput())
{
await Task.Delay(100);
}
}
user.DiskUsage = usage;
dest.Close();
}
if (usage >= user.DiskQuota)
{
item.QuotaOffense = true;
}
user.DiskUsage = usage;
return item;
}
}
public string UserName { get; set; }
public WebSocket Socket { get; set; }
}
public class LiveEntryViewModel
{
public string UserName { get; set; }
public string FlowId { get; set; }
}
public sealed class LiveCastHandler : IDisposable
{
private int _disposed;
public WebSocket Socket { get; set; }
public ConcurrentDictionary<string, WebSocket> Listeners { get; } = new();
public CancellationTokenSource TokenSource { get; } = new();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
TokenSource.Cancel();
Socket?.Dispose();
foreach (var listener in Listeners.Values) listener.Dispose();
TokenSource.Dispose();
}
}

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