diff --git a/CHANGELOG.md b/CHANGELOG.md index 970efdda5..7fd31746e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog -## [1.0.8-rc27] - unstable +## [1.0.8-rc28] - unstable + ### Added @@ -10,6 +11,14 @@ L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur disque, quotas dedies et conservation des segments recus apres interruption. +### Changed + +### Fixed + +## [1.0.8-rc27] - unstable + +### Added + * [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans elevation : publication autonome, raccourci du menu Demarrer, protocole `postit://` pour la connexion et desinstallation via Windows. @@ -52,12 +61,6 @@ ### Fixed -* [PostIt] Le playback Linux revient sur un chemin logiciel sûr quand - VAAPI/VDPAU n'est pas validé, sans bloquer la lecture si le flux reste - lisible. Le fallback navigateur reste actif pour les médias impossibles - à lire dans le contrôle VLC, sans se baser sur l'absence de `vainfo` - ou de pilotes VAAPI. - * [Yavsc.Server] Reception WebSocket historique : correction du tampon reutilise, suppression de la file non synchronisee et liberation des connexions. Les podcasts utilisent desormais une ingestion HTTP bornee. diff --git a/Directory.Packages.props b/Directory.Packages.props index 1e3577c72..12028b161 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ - - + + @@ -15,35 +15,35 @@ - - - - + + + + - + - - - + + + - - - - - + + + + + - - + + - - - + + + - - - - + + + + @@ -53,15 +53,15 @@ - - - + + + - + - + diff --git a/INSTALL.md b/INSTALL.md index a836c6611..62b7c15b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -76,6 +76,70 @@ login callback, updating and uninstallation on Windows with a standard (non-administrator) account. Installer compilation alone does not validate these Windows runtime behaviours. +## Podcast live broadcasts and replays + +PostIt Desktop's home page provides **Podcasts : directs et replays**. +Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the +Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the +default PulseAudio microphone. FFmpeg is **not** a runtime dependency. +Select a camera and optionally a microphone, enter a title, then start. +Stopping publishes the retained replay; navigating away or logging out +stops capture too. A second signed-in PostIt instance can watch the live +broadcast while it is being captured. + +Both upload and playback require a bearer token with the `api` scope. +All authenticated API users can watch the catalogue, live broadcasts and +replays; only the broadcaster can upload, complete or delete their podcast. +Media is not anonymous and no access token is included in a playback URL. + +Configure Yavsc.Api's `Podcast` section (or environment variables such as +`Podcast__RootPath`). No database migration or server-side encoder is needed: + +```json +{ + "Podcast": { + "RootPath": "/srv/yavsc-private/podcasts", + "MaxSegmentBytes": 8388608, + "MaxOwnerBytes": 1073741824, + "MaxSegments": 7200, + "MaxOwnerPodcasts": 100, + "IdleTimeoutSeconds": 60 + } +} +``` + +`RootPath` must be absolute, writable by Yavsc.Api and **outside any static +file directory**. The default is `DataDir/podcasts` under the API application +base directory. Use a persistent volume and back up this entire directory +(indexes and segments together). This implementation supports **one API +process per storage root**, not multiple writers or replicas. + +Defaults allow 8 MiB per segment (also the HTTP endpoint's hard limit), +1 GiB retained per owner, 7,200 segments per episode and 100 episodes per +owner. At the target two-second segment duration, this is roughly four hours +per episode; the byte quota can be reached earlier. Podcast quotas are +separate from the legacy user-files `DiskQuota`. Deleting an ended podcast +releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body +and forward Authorization on every API request. + +A broadcast without a new committed segment for 60 seconds, or present +when the API restarts, becomes an interrupted replay on the next catalogue, +manifest or upload request. Already committed media remains playable. +The broadcaster retries transient upload errors twice using the same +sequence and content; it stops if capture fails or the pending local media +exceeds 64 MiB. Unacknowledged media is retained under +`%LOCALAPPDATA%\PostIt\podcast-capture` (Linux: the corresponding local +application data directory), with `podcast.json` and the capture playlist +for manual recovery. There is no automatic resumption after an application +restart; these private local recordings may be removed manually when no +longer needed. + +The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated +player joins a live broadcast up to three segments behind the edge and +reads a replay from its beginning; seeking is not implemented. Native +camera/microphone permissions, drivers and codecs must still be verified +on each target Windows/Linux machine. + ## Resources System resources needed to install. diff --git a/NuGet.config b/NuGet.config index c601d09b5..3f45ad414 100644 --- a/NuGet.config +++ b/NuGet.config @@ -19,5 +19,6 @@ + diff --git a/ROADMAP.md b/ROADMAP.md index 364b98bda..0a46fff5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,14 +109,14 @@ Sous-jalons du `TODO.fr.md.archive` repris ici : Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) : -- ☐ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔) -- ☐ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile) -- ☐ **Création d'une annonce** +- ✔ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔) +- ✔ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile) +- ◐ **Création d'une annonce** - ☐ **Transformation annonce ↔ demande nominative** -- ☐ **Sélection d'une star** depuis le **mobile** (Web ✔) +- ◐ **Sélection d'une star** depuis le **mobile** (Web ✔) - ☐ **Sélection DJ / chanteur / formation** (recherche par critères : date, lieu, tendance, taille, répertoire) depuis le **mobile** - ☐ **Géocodeur de lieu** côté mobile -- ☐ **Géocodeur d'adresse postale** (Web + Mobile) +- ◐ **Géocodeur d'adresse postale** (Web + Mobile) - ☐ **Évaluation des trajets** - ☐ **Interface de gestion des collaborateurs** (sous-traitance — déjà amorcée dans `doc/Architecture.md`) - ☐ **Migration de l'identifiant utilisateur** : `string` GUID → `long` auto-incrémenté (Npgsql) @@ -163,7 +163,7 @@ Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) : - ☐ **Aide à la rencontre physique** : carte interactive, geofencing, alertes - ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE) - ☐ **Streaming vidéo pair-à-pair** -- ☐ **Streaming vidéo public** +- ✔ **Streaming vidéo public** **Critère de sortie** : un événement géolocalisé notifie ses participants confirmés à l'approche du point de rendez-vous. diff --git a/contrib/blog-edit.png b/contrib/blog-edit.png deleted file mode 100644 index 4175f5153..000000000 Binary files a/contrib/blog-edit.png and /dev/null differ diff --git a/contrib/blog-view.png b/contrib/blog-view.png deleted file mode 100644 index 04625dbc9..000000000 Binary files a/contrib/blog-view.png and /dev/null differ diff --git a/contrib/Windows/postit.nsi b/contrib/mswin/postit.nsi similarity index 100% rename from contrib/Windows/postit.nsi rename to contrib/mswin/postit.nsi diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 2ba6cb812..625cc1875 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -175,10 +175,19 @@ affichées dans la barre de statut, sans les masquer par une déduction MIME. Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop. Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et une action d'ouverture externe. Le lecteur et ses ressources natives sont -arrêtés et libérés à la sortie de la page. **LibVLC doit être installé** +arrêtés et libérés à la sortie de la page. La lecture utilise directement +`new LibVLC()`, `new MediaPlayer(libVlc)` et `Play(media)`, sans options +spécifiques à Linux ni détection VAAPI/VDPAU. Le média est libéré après +`Play` comme dans l'exemple officiel ; le lecteur conserve sa référence native. +**LibVLC doit être installé** sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les bibliothèques natives adaptées sont nécessaires sous Windows/macOS). Une absence de LibVLC ou une erreur de décodage est signalée dans la page. +Le navigateur ne s'ouvre que sur demande explicite, jamais en repli automatique. +Après la navigation, `TryUpdateNativeControlPosition()` synchronise la surface +native à la priorité `Loaded`, une fois la mise en page effectuée : +les transformations de transition ne déclenchent pas de changement de `Bounds`. +Le rappel est ignoré si le lecteur a été libéré ou remplacé entre-temps. Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, les vidéos sont ouvertes dans le navigateur, comme les autres fichiers (images liées, PDF, téléchargements) et les liens externes. @@ -231,6 +240,58 @@ faut *deux* enregistrements : la page en `AddTransient` (ou de `ViewLocator.Build`. Si l'un manque, l'app affiche "No view for X" sans crash. +## Podcasts : capture, direct et replay + +`PodcastsPageViewModel` est accessible depuis l'accueil Desktop Windows/Linux. +`CameraStreamer` detecte les cameras DirectShow/V4L2 et les microphones +(DirectShow sous Windows, source PulseAudio par defaut sous Linux). +`PodcastBroadcaster` utilise uniquement LibVLC : encodage H.264/AAC, sortie +`livehttp`, segments MPEG-TS d'environ deux secondes. Un segment n'est +envoye que lorsque l'index local LibVLC le declare complet. + +`PodcastApiClient` reutilise le transport bearer et son rafraichissement +des jetons. Les PUT sont numerotes a partir de zero ; une nouvelle tentative +doit avoir exactement les memes octets et la meme duree. Le client supprime +un segment local seulement apres acquittement, et envoie le dernier segment +ferme par LibVLC avant de terminer le direct. La navigation et la deconnexion +arretent la camera. Les erreurs restent visibles ; les fichiers non envoyes +restent disponibles localement pour recuperation manuelle. + +`ILiveProcessor` est un singleton dans Yavsc.Api. Il stocke les metadonnees +et empreintes SHA-256 dans un index JSON remplace atomiquement, avec des +segments immuables accessibles uniquement via les endpoints authentifies. +Deux receptions au plus sont tamponnees en parallele ; les validations de +sequence, de proprietaire et de quota sont serialisees avant commit. +Un index ne reference jamais un segment en cours d'ecriture. + +| Route sous `api/v1/podcasts` | Role | +| --- | --- | +| `GET /`, `POST /` | Catalogue ou creation d'une emission | +| `GET /{id}` | Metadonnees et segments ordonnes | +| `PUT /{id}/segments/{sequence}?duration=...` | Ingestion `video/mp2t`, auteur uniquement | +| `POST /{id}/complete` | Finalisation idempotente du replay | +| `GET /{id}/index.m3u8` | Playlist HLS EVENT, ENDLIST apres fin/interruption | +| `GET /{id}/segments/{sequence}.ts` | Segment engage, lecture authentifiee | +| `DELETE /{id}` | Suppression d'une emission terminee, auteur uniquement | + +Toutes les routes requierent la politique `ApiScope`. La lecture est ouverte +a tous les utilisateurs authentifies avec ce scope, pas seulement a l'auteur. +`PodcastMediaInput` alimente LibVLC en octets MPEG-TS via les requetes +authentifiees existantes, sans transmettre de jeton dans une URL ou un +processus externe. Les rappels natifs ne laissent echapper aucune exception : +l'erreur est remontee a la page de lecture. + +Un direct inactif devient `Interrupted` lors de la prochaine lecture de +l'index ; au redemarrage, tous les anciens directs sont interrompus. +Les segments engages constituent le replay, sans transcodage serveur. +Le stockage exige un seul processus par racine et les quotas podcast sont +independants de ceux des fichiers utilisateur. Voir +[INSTALL.md](../../INSTALL.md#podcast-live-broadcasts-and-replays) pour +les limites et le deploiement ; [Podcast.http](../../src/Yavsc.Api/Podcast.http) +pour les requetes. +Le document OpenAPI dedie est `/openapi/podcasts.json`, accessible en +developpement aux administrateurs comme les autres documents de l'API. + ## Composition root (`App.axaml.cs`) `App.OnFrameworkInitializationCompleted` est le seul endroit où diff --git a/doc/testing.md b/doc/testing.md index ef80cda9b..8b5e8eff1 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -73,6 +73,30 @@ HTTP, on stub `IAuthorizationService` directement (cf. `BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`) pour éviter de monter un host complet. +## Tests cibles des podcasts + +Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) : + +```bash +dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \ + --filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' +dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ + --filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests' +``` + +Les tests serveur couvrent HTTP/authentification, proprietaire, sequences, +reprises idempotentes, limites exactes, concurrence et persistance des replays +apres interruption/redemarrage, dans des dossiers temporaires isoles. + +Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`. +Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir +cette variable sur un fichier video local d'environ huit secondes contenant +une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la +reprise apres acquittement perdu, le dernier segment apres arret et le +decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni +par l'application (le fichier d'entree peut provenir de n'importe quelle source). +Les tests materiels camera/microphone Windows/Linux restent manuels. + ## Quand ne PAS écrire de test Un test qui ne détecte rien n'est pas un test. Si l'invariant diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index fbfaa1f38..3b23fc8fb 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -17,16 +17,16 @@ - + - + - + @@ -36,6 +36,6 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs new file mode 100644 index 000000000..8fc0c7cca --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs @@ -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 Uploaded { get; } = new(); + private readonly List _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 CallAsync(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 CallAsync(HttpMethod method, string path, Func 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(method, path, body, ct); + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, contentFactory, ct); + public Task 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; } + } +} diff --git a/src/PostIt/PostIt.Tests/PodcastTests.cs b/src/PostIt/PostIt.Tests/PodcastTests.cs new file mode 100644 index 000000000..76a75a352 --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastTests.cs @@ -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(() => 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? 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 CallAsync(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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + { + ContentFactory = contentFactory; + return CallAsync(method, path, ct: ct); + } + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => CallAsync(method, path, body, ct); + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, contentFactory, ct); + public Task 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; } + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index d6a0f6fed..d764507de 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -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(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Models @@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers services.AddSingleton(resourceUsageClient); services.AddSingleton(administrationClient); services.AddSingleton(notificationsClient); + services.AddSingleton(podcastClient); + services.AddTransient(); services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 27bdf298c..e080b9a26 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -12,6 +12,7 @@ + diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs index d78a84349..322331772 100644 --- a/src/PostIt/PostIt/Services/CameraStreamer.cs +++ b/src/PostIt/PostIt/Services/CameraStreamer.cs @@ -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[] diff --git a/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs b/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs new file mode 100644 index 000000000..5e4a02826 --- /dev/null +++ b/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs @@ -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 Parse(string playlist) + { + var segments = new List(); + 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; + } +} diff --git a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs new file mode 100644 index 000000000..ac402f6e0 --- /dev/null +++ b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs @@ -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? 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 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 StartAsync(PodcastApiClient api, string title, + Func 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 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); + } + } +} diff --git a/src/PostIt/PostIt/Services/PodcastMediaInput.cs b/src/PostIt/PostIt/Services/PodcastMediaInput.cs new file mode 100644 index 000000000..1cd5657eb --- /dev/null +++ b/src/PostIt/PostIt/Services/PodcastMediaInput.cs @@ -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(); + private int _offset; + private int _sequence = -1; + public event Action? 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); + } +} diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 296e8b3b1..c19b61f20 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -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() diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 5807df93a..eff0094a5 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate { BlogsViewModel => services.GetRequiredService(), VideoPageViewModel => services.GetRequiredService(), + PodcastsPageViewModel => services.GetRequiredService(), Settings => services.GetRequiredService(), AboutPageViewModel => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs index 9ce6e8f17..e8fc1e535 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs @@ -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 { } } } diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 253ac17c0..d2e023fd4 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -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() + ?? 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; } diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs new file mode 100644 index 000000000..e5375b2d0 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs @@ -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 Podcasts { get; } = new(); + public ObservableCollection Cameras { get; } = new(); + public ObservableCollection 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(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; } + } +} diff --git a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml index c00cdf644..4f40934d8 100644 --- a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml @@ -7,12 +7,12 @@ x:DataType="vm:VideoPageViewModel"> - +