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

@ -1,6 +1,7 @@
# Changelog # Changelog
## [1.0.8-rc27] - unstable ## [1.0.8-rc28] - unstable
### Added ### Added
@ -10,6 +11,14 @@
L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur
disque, quotas dedies et conservation des segments recus apres interruption. 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 * [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans
elevation : publication autonome, raccourci du menu Demarrer, elevation : publication autonome, raccourci du menu Demarrer,
protocole `postit://` pour la connexion et desinstallation via Windows. protocole `postit://` pour la connexion et desinstallation via Windows.
@ -52,12 +61,6 @@
### Fixed ### 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 * [Yavsc.Server] Reception WebSocket historique : correction du tampon
reutilise, suppression de la file non synchronisee et liberation des reutilise, suppression de la file non synchronisee et liberation des
connexions. Les podcasts utilisent desormais une ingestion HTTP bornee. connexions. Les podcasts utilisent desormais une ingestion HTTP bornee.

View file

@ -5,8 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="DotMarkdown" Version="0.3.0" /> <PackageVersion Include="DotMarkdown" Version="0.3.0" />
<PackageVersion Include="Markdig" Version="1.3.2" /> <PackageVersion Include="Markdig" Version="1.4.1" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" /> <PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
@ -15,35 +15,35 @@
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" /> <PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" /> <PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" /> <PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.13" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" /> <PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" /> <PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.3.0" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
@ -53,15 +53,15 @@
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" /> <PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" /> <PackageVersion Include="Google.Apis.Calendar.v3" Version="1.77.0.4285" />
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" /> <PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="15.0.0" />
<PackageVersion Include="MailKit" Version="4.18.0" /> <PackageVersion Include="MailKit" Version="4.18.1" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" /> <PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" /> <PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.13" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" /> <PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.5.1" />
<PackageVersion Include="MimeKit" Version="4.18.1" /> <PackageVersion Include="MimeKit" Version="4.18.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />

View file

@ -76,6 +76,70 @@ login callback, updating and uninstallation on Windows with a standard
(non-administrator) account. Installer compilation alone does not validate (non-administrator) account. Installer compilation alone does not validate
these Windows runtime behaviours. 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 ## Resources
System resources needed to install. System resources needed to install.

View file

@ -19,5 +19,6 @@
<clear /> <clear />
<add key="isn" value="https://isn.pschneider.fr/api/v3/index.json" /> <add key="isn" value="https://isn.pschneider.fr/api/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="videolan-preview" value="https://f.feedz.io/videolan/preview/nuget/index.json" />
</packageSources> </packageSources>
</configuration> </configuration>

View file

@ -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) : Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) :
- ☐ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔) - ✔ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔)
- ☐ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile) - ✔ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile)
- ☐ **Création d'une annonce** - ◐ **Création d'une annonce**
- ☐ **Transformation annonce ↔ demande nominative** - ☐ **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** - ☐ **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 de lieu** côté mobile
- ☐ **Géocodeur d'adresse postale** (Web + Mobile) - ◐ **Géocodeur d'adresse postale** (Web + Mobile)
- ☐ **Évaluation des trajets** - ☐ **Évaluation des trajets**
- ☐ **Interface de gestion des collaborateurs** (sous-traitance — déjà amorcée dans `doc/Architecture.md`) - ☐ **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) - ☐ **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 - ☐ **Aide à la rencontre physique** : carte interactive, geofencing, alertes
- ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE) - ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE)
- ☐ **Streaming vidéo pair-à-pair** - ☐ **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. **Critère de sortie** : un événement géolocalisé notifie ses participants confirmés à l'approche du point de rendez-vous.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View file

@ -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. Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
une action d'ouverture externe. Le lecteur et ses ressources natives sont 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 sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
bibliothèques natives adaptées sont nécessaires sous Windows/macOS). 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. 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, 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 les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
(images liées, PDF, téléchargements) et les liens externes. (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 de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash. "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`) ## Composition root (`App.axaml.cs`)
`App.OnFrameworkInitializationCompleted` est le seul endroit où `App.OnFrameworkInitializationCompleted` est le seul endroit où

View file

@ -73,6 +73,30 @@ HTTP, on stub `IAuthorizationService` directement (cf.
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`) `BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
pour éviter de monter un host complet. 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 ## Quand ne PAS écrire de test
Un test qui ne détecte rien n'est pas un test. Si l'invariant Un test qui ne détecte rien n'est pas un test. Si l'invariant

View file

@ -17,16 +17,16 @@
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="DirectShowLib" Version="1.0.0" /> <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="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" /> <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="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <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="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="PvRecorder" Version="1.2.13" /> <PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" /> <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.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" 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="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> </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 resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl); var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl); var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
@ -46,6 +47,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainView>(); services.AddTransient<MainView>();
services.AddTransient<BlogsPage>(); services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>(); services.AddTransient<VideoPage>();
services.AddTransient<PodcastsPage>();
// Models // Models
@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton(resourceUsageClient); services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient); services.AddSingleton(administrationClient);
services.AddSingleton(notificationsClient); services.AddSingleton(notificationsClient);
services.AddSingleton(podcastClient);
services.AddTransient<PodcastsPageViewModel>();
services.AddSingleton(userFilesClient); services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);

View file

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

View file

@ -20,27 +20,14 @@ public class CameraStreamer
"--aout=dummy", "--aout=dummy",
"--intf=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() public static LibVLC CreatePreferredLibVlc()
{ {
if (!OperatingSystem.IsLinux()) if (!OperatingSystem.IsLinux())
return new LibVLC(); return new LibVLC();
if (ShouldDisableHardwareAcceleration())
return CreateHeadlessLibVlc();
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
@ -50,6 +37,10 @@ public class CameraStreamer
return CreateHeadlessLibVlc(); return CreateHeadlessLibVlc();
} }
private static bool ShouldDisableHardwareAcceleration()
=> string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1",
StringComparison.OrdinalIgnoreCase);
private static bool IsVaapiUsable() private static bool IsVaapiUsable()
{ {
var vaapiDrivers = new[] 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,9 +540,12 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false); response = await sendOnce().ConfigureAwait(false);
} }
using (response)
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
} }
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {

View file

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

View file

@ -1,4 +1,6 @@
using System; using System;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -6,7 +8,10 @@ public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelB
{ {
public Uri Source { get; } = source; public Uri Source { get; } = source;
public string MimeType { get; } = mimeType; 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 CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } } public override bool CanNavigatePrevious { get => true; protected set { } }
} }

View file

@ -74,10 +74,19 @@ public class HomePageViewModel : ViewModelBase
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync); OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator); OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
OpenChat = new AsyncRelayCommand(OpenChatAsync); 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 OpenChat { get; }
public IAsyncRelayCommand OpenPodcasts { get; }
public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; } public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { 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"> x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" /> <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" /> <vlc:VideoView x:Name="Video" />
</Border> </Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12"> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" /> <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> </StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" /> <TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid> </Grid>

View file

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

View file

@ -57,6 +57,11 @@
Command="{Binding OpenChat}" Command="{Binding OpenChat}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> 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" <Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center" 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 // Binds the "Site" section (Bills, TempDir, Avatars, …) used by
// the document-generation flow and BillingController. // the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site")); services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
services.AddPodcasts(builder.Configuration);
// accepts any access token issued by identity server // accepts any access token issued by identity server
services.AddAuthentication("Bearer") 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!"); }, $"{userName} is starting a stream!");
await liveProcessor.AcceptStream(HttpContext, user, destDir, shortFileName); 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 Microsoft.AspNetCore.Http;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.ViewModels.Streaming; using Yavsc.ViewModels.Streaming;
using Yavsc.Abstract.Streaming;
namespace Yavsc.Services namespace Yavsc.Services
{ {
@ -19,6 +20,14 @@ namespace Yavsc.Services
/// <returns></returns> /// <returns></returns>
Task<bool> AcceptStream (HttpContext context, ApplicationUser user, string destDir, string fileName); 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.Collections.Concurrent;
using System.Net.WebSockets; 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.Models;
using Yavsc.ViewModels.Streaming; 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();
{
private readonly ILogger _logger;
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new ConcurrentDictionary<string, LiveCastHandler>(); public LiveProcessor(ILoggerFactory loggerFactory, IOptions<PodcastOptions> options, TimeProvider? clock = null)
public LiveProcessor(ILoggerFactory loggerFactory)
{ {
_logger = loggerFactory.CreateLogger<LiveProcessor>(); _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 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
{
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<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await LoadAsync(ct);
await ExpireAsync(ct);
return _documents.Values.Select(d => d.Manifest.Podcast)
.OrderByDescending(p => p.CreatedAt).ToArray();
}
finally { _gate.Release(); }
}
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
{
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)
{
if (document.Hashes[sequence] != hash || segments[sequence].DurationSeconds != durationSeconds)
throw new PodcastException(409, "Un autre contenu existe deja pour cette sequence.");
return podcast;
}
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
{
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;
}
finally { _gate.Release(); }
}
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) public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
{ {
// TODO defer request handling var userName = user.UserName ?? throw new InvalidOperationException("A stream owner must have a user name.");
string uname = user.UserName; var handler = new LiveCastHandler();
LiveCastHandler liveHandler = null; if (!Casters.TryAdd(userName, handler))
if (Casters.ContainsKey(uname))
{ {
_logger.LogWarning($"Casters.ContainsKey({uname})"); handler.Dispose();
liveHandler = Casters[uname]; context.Response.StatusCode = StatusCodes.Status409Conflict;
if (liveHandler.Socket.State == WebSocketState.Open || liveHandler.Socket.State == WebSocketState.Connecting) return false;
{
_logger.LogWarning($"Closing cx");
// FIXME loosed connexion should be detected & disposed else where
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "one by user", CancellationToken.None);
} }
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
try try
{ {
if (liveHandler.Socket != null && liveHandler.Socket.State == WebSocketState.Open) handler.Socket = await context.WebSockets.AcceptWebSocketAsync();
{ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
Casters[uname] = liveHandler; context.RequestAborted, handler.TokenSource.Token);
// TODO: Handle the socket here. var ct = cancellation.Token;
// Find receivers: others in the chat room var path = Path.Combine(destDir, Path.GetFileName(fileName));
// send them the flow 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]; var buffer = new byte[Constants.WebSocketsMaxBufLen];
var sBuffer = new ArraySegment<byte>(buffer); while (handler.Socket.State == WebSocketState.Open)
_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 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)
{ {
_logger.LogInformation($"Echoing {received.Count} bytes received in a {received.MessageType} message; Fin={received.EndOfMessage}"); await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "Binary data and available quota required.", ct);
// Echo anything we receive return false;
// 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) await file.WriteAsync(buffer.AsMemory(0, received.Count), ct);
{ usage += received.Count;
ToClose.Push(cliItem.Key); 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),
if (!received.CloseStatus.HasValue) WebSocketMessageType.Binary, received.EndOfMessage, ct);
{
_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();
}
else
{
// 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);
}
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);
} }
if (handler.Socket.State == WebSocketState.CloseReceived)
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Stream complete.", ct);
return true; return true;
} }
void RemoveLiveInfo(string userName) catch (Exception ex) when (ex is IOException or WebSocketException or OperationCanceledException)
{ {
LiveCastHandler caster; _logger.LogWarning(ex, "Legacy stream interrupted for {UserName}", user.UserName);
if (Casters.TryRemove(userName, out caster)) throw;
_logger.LogInformation("removed live info"); }
else finally
_logger.LogError("could not remove live info"); {
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,12 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; 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 string UserName { get; set; }
@ -19,67 +15,19 @@ namespace Yavsc.ViewModels.Streaming
public string FlowId { get; set; } public string FlowId { get; set; }
} }
public class LiveCastHandler : IDisposable public sealed class LiveCastHandler : IDisposable
{ {
private int _disposed;
public WebSocket Socket { get; set; } public WebSocket Socket { get; set; }
public ConcurrentDictionary<string, WebSocket> Listeners { get; set; } = new ConcurrentDictionary<string, WebSocket>(); public ConcurrentDictionary<string, WebSocket> Listeners { get; } = new();
public CancellationTokenSource TokenSource { get; } = new();
public CancellationTokenSource TokenSource { get; set; } = new CancellationTokenSource();
public void Dispose() public void Dispose()
{ {
} if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
TokenSource.Cancel();
public async Task<FileReceivedInfo> ReceiveUserFile(ApplicationUser user, ILogger logger, string root, Queue<ArraySegment<byte>> queue, string destFileName, Func<bool> isEndOfInput) Socket?.Dispose();
{ foreach (var listener in Listeners.Values) listener.Dispose();
// TODO lock user's disk usage for this scope, TokenSource.Dispose();
// 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;
}
}
}

View file

@ -72,6 +72,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType) options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
.AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>(); .AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddSiteAdministration(); 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<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test"); builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>(); builder.Services.AddTransient<IBillingService, BillingService>();
@ -82,6 +85,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>(); builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
{ {
options.AddPolicy("ApiScope", policy => policy.RequireAuthenticatedUser().RequireClaim("scope", "api"));
options.AddPolicy("AdministratorOnly", policy => options.AddPolicy("AdministratorOnly", policy =>
{ {
policy.RequireAuthenticatedUser(); 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) 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 _));
}
}