feat: add authenticated live podcasts and simplify native video playback
Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d9b183c500
commit
4fc91cec3e
45 changed files with 2330 additions and 378 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -1,6 +1,7 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.8-rc27] - unstable
|
||||
## [1.0.8-rc28] - unstable
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
|
|
@ -10,6 +11,14 @@
|
|||
L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur
|
||||
disque, quotas dedies et conservation des segments recus apres interruption.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [1.0.8-rc27] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans
|
||||
elevation : publication autonome, raccourci du menu Demarrer,
|
||||
protocole `postit://` pour la connexion et desinstallation via Windows.
|
||||
|
|
@ -52,12 +61,6 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
* [PostIt] Le playback Linux revient sur un chemin logiciel sûr quand
|
||||
VAAPI/VDPAU n'est pas validé, sans bloquer la lecture si le flux reste
|
||||
lisible. Le fallback navigateur reste actif pour les médias impossibles
|
||||
à lire dans le contrôle VLC, sans se baser sur l'absence de `vainfo`
|
||||
ou de pilotes VAAPI.
|
||||
|
||||
* [Yavsc.Server] Reception WebSocket historique : correction du tampon
|
||||
reutilise, suppression de la file non synchronisee et liberation des
|
||||
connexions. Les podcasts utilisent desormais une ingestion HTTP bornee.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
|
||||
<PackageVersion Include="Markdig" Version="1.4.1" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
||||
|
|
@ -15,35 +15,35 @@
|
|||
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.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.Identity.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<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.OpenApi" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
|
||||
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.3.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
|
||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
|
|
@ -53,15 +53,15 @@
|
|||
|
||||
|
||||
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
|
||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.77.0.4285" />
|
||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="15.0.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
||||
<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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
|
|
|
|||
64
INSTALL.md
64
INSTALL.md
|
|
@ -76,6 +76,70 @@ login callback, updating and uninstallation on Windows with a standard
|
|||
(non-administrator) account. Installer compilation alone does not validate
|
||||
these Windows runtime behaviours.
|
||||
|
||||
## Podcast live broadcasts and replays
|
||||
|
||||
PostIt Desktop's home page provides **Podcasts : directs et replays**.
|
||||
Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the
|
||||
Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the
|
||||
default PulseAudio microphone. FFmpeg is **not** a runtime dependency.
|
||||
Select a camera and optionally a microphone, enter a title, then start.
|
||||
Stopping publishes the retained replay; navigating away or logging out
|
||||
stops capture too. A second signed-in PostIt instance can watch the live
|
||||
broadcast while it is being captured.
|
||||
|
||||
Both upload and playback require a bearer token with the `api` scope.
|
||||
All authenticated API users can watch the catalogue, live broadcasts and
|
||||
replays; only the broadcaster can upload, complete or delete their podcast.
|
||||
Media is not anonymous and no access token is included in a playback URL.
|
||||
|
||||
Configure Yavsc.Api's `Podcast` section (or environment variables such as
|
||||
`Podcast__RootPath`). No database migration or server-side encoder is needed:
|
||||
|
||||
```json
|
||||
{
|
||||
"Podcast": {
|
||||
"RootPath": "/srv/yavsc-private/podcasts",
|
||||
"MaxSegmentBytes": 8388608,
|
||||
"MaxOwnerBytes": 1073741824,
|
||||
"MaxSegments": 7200,
|
||||
"MaxOwnerPodcasts": 100,
|
||||
"IdleTimeoutSeconds": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RootPath` must be absolute, writable by Yavsc.Api and **outside any static
|
||||
file directory**. The default is `DataDir/podcasts` under the API application
|
||||
base directory. Use a persistent volume and back up this entire directory
|
||||
(indexes and segments together). This implementation supports **one API
|
||||
process per storage root**, not multiple writers or replicas.
|
||||
|
||||
Defaults allow 8 MiB per segment (also the HTTP endpoint's hard limit),
|
||||
1 GiB retained per owner, 7,200 segments per episode and 100 episodes per
|
||||
owner. At the target two-second segment duration, this is roughly four hours
|
||||
per episode; the byte quota can be reached earlier. Podcast quotas are
|
||||
separate from the legacy user-files `DiskQuota`. Deleting an ended podcast
|
||||
releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body
|
||||
and forward Authorization on every API request.
|
||||
|
||||
A broadcast without a new committed segment for 60 seconds, or present
|
||||
when the API restarts, becomes an interrupted replay on the next catalogue,
|
||||
manifest or upload request. Already committed media remains playable.
|
||||
The broadcaster retries transient upload errors twice using the same
|
||||
sequence and content; it stops if capture fails or the pending local media
|
||||
exceeds 64 MiB. Unacknowledged media is retained under
|
||||
`%LOCALAPPDATA%\PostIt\podcast-capture` (Linux: the corresponding local
|
||||
application data directory), with `podcast.json` and the capture playlist
|
||||
for manual recovery. There is no automatic resumption after an application
|
||||
restart; these private local recordings may be removed manually when no
|
||||
longer needed.
|
||||
|
||||
The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated
|
||||
player joins a live broadcast up to three segments behind the edge and
|
||||
reads a replay from its beginning; seeking is not implemented. Native
|
||||
camera/microphone permissions, drivers and codecs must still be verified
|
||||
on each target Windows/Linux machine.
|
||||
|
||||
## Resources
|
||||
|
||||
System resources needed to install.
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
<clear />
|
||||
<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="videolan-preview" value="https://f.feedz.io/videolan/preview/nuget/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -109,14 +109,14 @@ Sous-jalons du `TODO.fr.md.archive` repris ici :
|
|||
|
||||
Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) :
|
||||
|
||||
- ☐ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔)
|
||||
- ☐ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile)
|
||||
- ☐ **Création d'une annonce**
|
||||
- ✔ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔)
|
||||
- ✔ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile)
|
||||
- ◐ **Création d'une annonce**
|
||||
- ☐ **Transformation annonce ↔ demande nominative**
|
||||
- ☐ **Sélection d'une star** depuis le **mobile** (Web ✔)
|
||||
- ◐ **Sélection d'une star** depuis le **mobile** (Web ✔)
|
||||
- ☐ **Sélection DJ / chanteur / formation** (recherche par critères : date, lieu, tendance, taille, répertoire) depuis le **mobile**
|
||||
- ☐ **Géocodeur de lieu** côté mobile
|
||||
- ☐ **Géocodeur d'adresse postale** (Web + Mobile)
|
||||
- ◐ **Géocodeur d'adresse postale** (Web + Mobile)
|
||||
- ☐ **Évaluation des trajets**
|
||||
- ☐ **Interface de gestion des collaborateurs** (sous-traitance — déjà amorcée dans `doc/Architecture.md`)
|
||||
- ☐ **Migration de l'identifiant utilisateur** : `string` GUID → `long` auto-incrémenté (Npgsql)
|
||||
|
|
@ -163,7 +163,7 @@ Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) :
|
|||
- ☐ **Aide à la rencontre physique** : carte interactive, geofencing, alertes
|
||||
- ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE)
|
||||
- ☐ **Streaming vidéo pair-à-pair**
|
||||
- ☐ **Streaming vidéo public**
|
||||
- ✔ **Streaming vidéo public**
|
||||
|
||||
**Critère de sortie** : un événement géolocalisé notifie ses participants confirmés à l'approche du point de rendez-vous.
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
|
|
@ -175,10 +175,19 @@ affichées dans la barre de statut, sans les masquer par une déduction MIME.
|
|||
Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
|
||||
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
|
||||
une action d'ouverture externe. Le lecteur et ses ressources natives sont
|
||||
arrêtés et libérés à la sortie de la page. **LibVLC doit être installé**
|
||||
arrêtés et libérés à la sortie de la page. La lecture utilise directement
|
||||
`new LibVLC()`, `new MediaPlayer(libVlc)` et `Play(media)`, sans options
|
||||
spécifiques à Linux ni détection VAAPI/VDPAU. Le média est libéré après
|
||||
`Play` comme dans l'exemple officiel ; le lecteur conserve sa référence native.
|
||||
**LibVLC doit être installé**
|
||||
sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
|
||||
bibliothèques natives adaptées sont nécessaires sous Windows/macOS).
|
||||
Une absence de LibVLC ou une erreur de décodage est signalée dans la page.
|
||||
Le navigateur ne s'ouvre que sur demande explicite, jamais en repli automatique.
|
||||
Après la navigation, `TryUpdateNativeControlPosition()` synchronise la surface
|
||||
native à la priorité `Loaded`, une fois la mise en page effectuée :
|
||||
les transformations de transition ne déclenchent pas de changement de `Bounds`.
|
||||
Le rappel est ignoré si le lecteur a été libéré ou remplacé entre-temps.
|
||||
Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible,
|
||||
les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
|
||||
(images liées, PDF, téléchargements) et les liens externes.
|
||||
|
|
@ -231,6 +240,58 @@ faut *deux* enregistrements : la page en `AddTransient` (ou
|
|||
de `ViewLocator.Build`. Si l'un manque, l'app affiche
|
||||
"No view for X" sans crash.
|
||||
|
||||
## Podcasts : capture, direct et replay
|
||||
|
||||
`PodcastsPageViewModel` est accessible depuis l'accueil Desktop Windows/Linux.
|
||||
`CameraStreamer` detecte les cameras DirectShow/V4L2 et les microphones
|
||||
(DirectShow sous Windows, source PulseAudio par defaut sous Linux).
|
||||
`PodcastBroadcaster` utilise uniquement LibVLC : encodage H.264/AAC, sortie
|
||||
`livehttp`, segments MPEG-TS d'environ deux secondes. Un segment n'est
|
||||
envoye que lorsque l'index local LibVLC le declare complet.
|
||||
|
||||
`PodcastApiClient` reutilise le transport bearer et son rafraichissement
|
||||
des jetons. Les PUT sont numerotes a partir de zero ; une nouvelle tentative
|
||||
doit avoir exactement les memes octets et la meme duree. Le client supprime
|
||||
un segment local seulement apres acquittement, et envoie le dernier segment
|
||||
ferme par LibVLC avant de terminer le direct. La navigation et la deconnexion
|
||||
arretent la camera. Les erreurs restent visibles ; les fichiers non envoyes
|
||||
restent disponibles localement pour recuperation manuelle.
|
||||
|
||||
`ILiveProcessor` est un singleton dans Yavsc.Api. Il stocke les metadonnees
|
||||
et empreintes SHA-256 dans un index JSON remplace atomiquement, avec des
|
||||
segments immuables accessibles uniquement via les endpoints authentifies.
|
||||
Deux receptions au plus sont tamponnees en parallele ; les validations de
|
||||
sequence, de proprietaire et de quota sont serialisees avant commit.
|
||||
Un index ne reference jamais un segment en cours d'ecriture.
|
||||
|
||||
| Route sous `api/v1/podcasts` | Role |
|
||||
| --- | --- |
|
||||
| `GET /`, `POST /` | Catalogue ou creation d'une emission |
|
||||
| `GET /{id}` | Metadonnees et segments ordonnes |
|
||||
| `PUT /{id}/segments/{sequence}?duration=...` | Ingestion `video/mp2t`, auteur uniquement |
|
||||
| `POST /{id}/complete` | Finalisation idempotente du replay |
|
||||
| `GET /{id}/index.m3u8` | Playlist HLS EVENT, ENDLIST apres fin/interruption |
|
||||
| `GET /{id}/segments/{sequence}.ts` | Segment engage, lecture authentifiee |
|
||||
| `DELETE /{id}` | Suppression d'une emission terminee, auteur uniquement |
|
||||
|
||||
Toutes les routes requierent la politique `ApiScope`. La lecture est ouverte
|
||||
a tous les utilisateurs authentifies avec ce scope, pas seulement a l'auteur.
|
||||
`PodcastMediaInput` alimente LibVLC en octets MPEG-TS via les requetes
|
||||
authentifiees existantes, sans transmettre de jeton dans une URL ou un
|
||||
processus externe. Les rappels natifs ne laissent echapper aucune exception :
|
||||
l'erreur est remontee a la page de lecture.
|
||||
|
||||
Un direct inactif devient `Interrupted` lors de la prochaine lecture de
|
||||
l'index ; au redemarrage, tous les anciens directs sont interrompus.
|
||||
Les segments engages constituent le replay, sans transcodage serveur.
|
||||
Le stockage exige un seul processus par racine et les quotas podcast sont
|
||||
independants de ceux des fichiers utilisateur. Voir
|
||||
[INSTALL.md](../../INSTALL.md#podcast-live-broadcasts-and-replays) pour
|
||||
les limites et le deploiement ; [Podcast.http](../../src/Yavsc.Api/Podcast.http)
|
||||
pour les requetes.
|
||||
Le document OpenAPI dedie est `/openapi/podcasts.json`, accessible en
|
||||
developpement aux administrateurs comme les autres documents de l'API.
|
||||
|
||||
## Composition root (`App.axaml.cs`)
|
||||
|
||||
`App.OnFrameworkInitializationCompleted` est le seul endroit où
|
||||
|
|
|
|||
|
|
@ -73,6 +73,30 @@ HTTP, on stub `IAuthorizationService` directement (cf.
|
|||
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
|
||||
pour éviter de monter un host complet.
|
||||
|
||||
## Tests cibles des podcasts
|
||||
|
||||
Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
|
||||
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests'
|
||||
```
|
||||
|
||||
Les tests serveur couvrent HTTP/authentification, proprietaire, sequences,
|
||||
reprises idempotentes, limites exactes, concurrence et persistance des replays
|
||||
apres interruption/redemarrage, dans des dossiers temporaires isoles.
|
||||
|
||||
Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`.
|
||||
Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir
|
||||
cette variable sur un fichier video local d'environ huit secondes contenant
|
||||
une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la
|
||||
reprise apres acquittement perdu, le dernier segment apres arret et le
|
||||
decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni
|
||||
par l'application (le fichier d'entree peut provenir de n'importe quelle source).
|
||||
Les tests materiels camera/microphone Windows/Linux restent manuels.
|
||||
|
||||
## Quand ne PAS écrire de test
|
||||
|
||||
Un test qui ne détecte rien n'est pas un test. Si l'invariant
|
||||
|
|
|
|||
|
|
@ -17,16 +17,16 @@
|
|||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.2" />
|
||||
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
|
||||
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.2" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
|
||||
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
|
||||
<PackageVersion Include="PvRecorder" Version="1.2.13" />
|
||||
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||
|
|
@ -36,6 +36,6 @@
|
|||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal file
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal file
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ public static class ServiceCollectionHelpers
|
|||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
|
||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||
var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<MainView>();
|
||||
services.AddTransient<BlogsPage>();
|
||||
services.AddTransient<VideoPage>();
|
||||
services.AddTransient<PodcastsPage>();
|
||||
|
||||
|
||||
// Models
|
||||
|
|
@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(administrationClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(podcastClient);
|
||||
services.AddTransient<PodcastsPageViewModel>();
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<InternalsVisibleTo Include="PostIt.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="postit-settings.json">
|
||||
|
|
|
|||
|
|
@ -20,27 +20,14 @@ public class CameraStreamer
|
|||
"--aout=dummy",
|
||||
"--intf=dummy");
|
||||
|
||||
public static LibVLC CreatePlaybackLibVlc()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return new LibVLC();
|
||||
|
||||
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (preferHardware && IsVaapiUsable())
|
||||
return new LibVLC();
|
||||
|
||||
// On Linux, a non-hardware display path is still valid for in-app playback as long as
|
||||
// the platform is not explicitly forced into a broken VAAPI/VDPAU stack.
|
||||
return new LibVLC("--avcodec-hw=none");
|
||||
}
|
||||
|
||||
public static LibVLC CreatePreferredLibVlc()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return new LibVLC();
|
||||
|
||||
if (ShouldDisableHardwareAcceleration())
|
||||
return CreateHeadlessLibVlc();
|
||||
|
||||
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
|
@ -50,6 +37,10 @@ public class CameraStreamer
|
|||
return CreateHeadlessLibVlc();
|
||||
}
|
||||
|
||||
private static bool ShouldDisableHardwareAcceleration()
|
||||
=> string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsVaapiUsable()
|
||||
{
|
||||
var vaapiDrivers = new[]
|
||||
|
|
|
|||
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal file
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
231
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal file
231
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal file
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -540,8 +540,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
using (response)
|
||||
{
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate
|
|||
{
|
||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
|
||||
PodcastsPageViewModel => services.GetRequiredService<PodcastsPage>(),
|
||||
Settings => services.GetRequiredService<SettingsPage>(),
|
||||
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -6,7 +8,10 @@ public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelB
|
|||
{
|
||||
public Uri Source { get; } = source;
|
||||
public string MimeType { get; } = mimeType;
|
||||
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||
public PodcastApiClient? PodcastClient { get; init; }
|
||||
public PodcastResponse? Podcast { get; init; }
|
||||
public bool CanOpenExternal => Podcast is null;
|
||||
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||
public override bool CanNavigateNext { get => false; protected set { } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,10 +74,19 @@ public class HomePageViewModel : ViewModelBase
|
|||
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
|
||||
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
|
||||
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
||||
OpenPodcasts = new AsyncRelayCommand(async () =>
|
||||
{
|
||||
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
|
||||
var vm = app.ServiceProvider?.GetRequiredService<PodcastsPageViewModel>()
|
||||
?? throw new InvalidOperationException("Page podcasts indisponible.");
|
||||
await app.PushPageAsync(vm);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public IAsyncRelayCommand OpenChat { get; }
|
||||
public IAsyncRelayCommand OpenPodcasts { get; }
|
||||
public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenProfile { get; }
|
||||
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
|
||||
|
|
|
|||
276
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal file
276
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@
|
|||
x:DataType="vm:VideoPageViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
||||
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
||||
<Border Grid.Row="1" Background="Black">
|
||||
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
|
||||
<vlc:VideoView x:Name="Video" />
|
||||
</Border>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
||||
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
||||
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
|
||||
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" IsVisible="{Binding CanOpenExternal}" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
|
@ -15,9 +14,7 @@ public partial class VideoPage : ContentPage
|
|||
{
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _player;
|
||||
private Media? _media;
|
||||
private PodcastMediaInput? _podcastInput;
|
||||
private bool _externalFallbackTriggered;
|
||||
|
||||
public VideoPage() => InitializeComponent();
|
||||
|
||||
|
|
@ -25,38 +22,42 @@ public partial class VideoPage : ContentPage
|
|||
{
|
||||
base.OnNavigatedTo(e);
|
||||
ReleasePlayer();
|
||||
if (DataContext is not VideoPageViewModel vm)
|
||||
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
|
||||
return;
|
||||
try
|
||||
{
|
||||
Core.Initialize();
|
||||
_libVlc = CameraStreamer.CreatePlaybackLibVlc();
|
||||
_libVlc = new LibVLC();
|
||||
_player = new MediaPlayer(_libVlc);
|
||||
_player.EncounteredError += Player_EncounteredError;
|
||||
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
|
||||
{
|
||||
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
|
||||
_podcastInput.Failed += PodcastFailed;
|
||||
_media = new Media(_libVlc, _podcastInput);
|
||||
_media.AddOption(":demux=ts");
|
||||
}
|
||||
else
|
||||
_media = new Media(_libVlc, vm.Source);
|
||||
using var media = _podcastInput is null
|
||||
? new Media(_libVlc, vm.Source)
|
||||
: new Media(_libVlc, _podcastInput);
|
||||
if (_podcastInput is not null)
|
||||
media.AddOption(":demux=ts");
|
||||
Video.MediaPlayer = _player;
|
||||
var player = _player;
|
||||
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ReferenceEquals(_player, player))
|
||||
Video.TryUpdateNativeControlPosition();
|
||||
}, DispatcherPriority.Loaded);
|
||||
PlayButton.IsEnabled = true;
|
||||
PlaybackStatus.Text = vm.MimeType;
|
||||
if (!_player.Play(_media))
|
||||
if (!_player.Play(media))
|
||||
PlaybackStatus.Text = vm.Podcast is null
|
||||
? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."
|
||||
: "Impossible de demarrer la lecture du podcast.";
|
||||
}
|
||||
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
|
||||
{
|
||||
ReleasePlayer();
|
||||
_externalFallbackTriggered = true;
|
||||
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Ouverture du navigateur pour afficher le média.";
|
||||
_ = OpenExternalFallbackAsync(vm.Source);
|
||||
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,8 +85,6 @@ public partial class VideoPage : ContentPage
|
|||
_player.Dispose();
|
||||
_player = null;
|
||||
}
|
||||
_media?.Dispose();
|
||||
_media = null;
|
||||
if (_podcastInput is not null)
|
||||
{
|
||||
_podcastInput.Failed -= PodcastFailed;
|
||||
|
|
@ -100,55 +99,27 @@ public partial class VideoPage : ContentPage
|
|||
|
||||
private static bool CanLaunchExternally(Uri source)
|
||||
=> source is { IsAbsoluteUri: true }
|
||||
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(source.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task OpenExternalFallbackAsync(Uri source)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CanLaunchExternally(source))
|
||||
{
|
||||
PlaybackStatus.Text = "Lecture intégrée indisponible ; ce flux ne peut pas être ouvert dans le navigateur.";
|
||||
return;
|
||||
}
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null)
|
||||
{
|
||||
PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (await topLevel.Launcher.LaunchUriAsync(source))
|
||||
PlaybackStatus.Text = "Lecture intégrée indisponible ; le média a été ouvert dans le navigateur.";
|
||||
else
|
||||
PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PlaybackStatus.Text = $"Lecture intégrée indisponible : {ex.Message}. Ouvrez le média dans le navigateur.";
|
||||
}
|
||||
}
|
||||
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !source.AbsolutePath.Contains("/api/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void Player_EncounteredError(object? sender, EventArgs e)
|
||||
=> Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ReferenceEquals(sender, _player) && !_externalFallbackTriggered)
|
||||
{
|
||||
_externalFallbackTriggered = true;
|
||||
if (DataContext is VideoPageViewModel vm)
|
||||
_ = OpenExternalFallbackAsync(vm.Source);
|
||||
PlaybackStatus.Text = _podcastInput is null
|
||||
? "Impossible de lire cette vidéo. Ouverture du navigateur…"
|
||||
: "Impossible de lire le podcast. Ouverture du navigateur…";
|
||||
}
|
||||
if (!ReferenceEquals(sender, _player) || _player is null)
|
||||
return;
|
||||
|
||||
PlaybackStatus.Text = _podcastInput is null
|
||||
? "Impossible de lire cette vidéo."
|
||||
: "Impossible de lire le podcast.";
|
||||
});
|
||||
|
||||
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_player is null || _media is null)
|
||||
if (_player is null)
|
||||
return;
|
||||
if (_player.IsPlaying)
|
||||
_player.Pause();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@
|
|||
Command="{Binding OpenChat}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Podcasts : directs et replays"
|
||||
Command="{Binding OpenPodcasts}"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="{Binding SupportsPodcasts}"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Open Blog Interface"
|
||||
Command="{Binding OpenBlogs}"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
|
|||
55
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal file
55
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal 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>
|
||||
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal file
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/Yavsc.Abstract/Streaming/Podcast.cs
Normal file
28
src/Yavsc.Abstract/Streaming/Podcast.cs
Normal 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);
|
||||
51
src/Yavsc.Api.Client/PodcastApiClient.cs
Normal file
51
src/Yavsc.Api.Client/PodcastApiClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/Yavsc.Api/Middleware/PodcastExceptionFilter.cs
Normal file
21
src/Yavsc.Api/Middleware/PodcastExceptionFilter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/Yavsc.Api/Podcast.http
Normal file
49
src/Yavsc.Api/Podcast.http
Normal 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}}
|
||||
|
|
@ -71,6 +71,7 @@ internal class Program
|
|||
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
|
||||
// the document-generation flow and BillingController.
|
||||
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
|
||||
services.AddPodcasts(builder.Configuration);
|
||||
|
||||
// accepts any access token issued by identity server
|
||||
services.AddAuthentication("Bearer")
|
||||
|
|
|
|||
14
src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs
Normal file
14
src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,8 @@ namespace Yavsc.Blogs.Controllers
|
|||
}, $"{userName} is starting a stream!");
|
||||
|
||||
await liveProcessor.AcceptStream(HttpContext, user, destDir, shortFileName);
|
||||
return Ok();
|
||||
// The processor has already upgraded or rejected the HTTP response.
|
||||
return new EmptyResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.ViewModels.Streaming;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
|
|
@ -19,6 +20,14 @@ namespace Yavsc.Services
|
|||
/// <returns></returns>
|
||||
Task<bool> AcceptStream (HttpContext context, ApplicationUser user, string destDir, string fileName);
|
||||
|
||||
Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct);
|
||||
Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct);
|
||||
Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct);
|
||||
Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
|
||||
double durationSeconds, Stream input, CancellationToken ct);
|
||||
Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct);
|
||||
Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct);
|
||||
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,204 +1,360 @@
|
|||
#nullable enable annotations
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.ViewModels.Streaming;
|
||||
using Newtonsoft.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Yavsc.Services
|
||||
namespace Yavsc.Services;
|
||||
|
||||
public sealed class LiveProcessor : ILiveProcessor, IDisposable
|
||||
{
|
||||
private sealed record Document(PodcastManifestResponse Manifest, string[] Hashes);
|
||||
private readonly ILogger<LiveProcessor> _logger;
|
||||
private readonly PodcastOptions _options;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly SemaphoreSlim _gate = new(1);
|
||||
private readonly SemaphoreSlim _uploads = new(2);
|
||||
private readonly Dictionary<Guid, Document> _documents = new();
|
||||
private bool _loaded;
|
||||
|
||||
public class LiveProcessor : ILiveProcessor
|
||||
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new();
|
||||
|
||||
public LiveProcessor(ILoggerFactory loggerFactory, IOptions<PodcastOptions> options, TimeProvider? clock = null)
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
_logger = loggerFactory.CreateLogger<LiveProcessor>();
|
||||
_options = options.Value;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
if (!Path.IsPathFullyQualified(_options.RootPath) || _options.MaxSegmentBytes < 188
|
||||
|| _options.MaxSegmentBytes > 8 * 1024 * 1024
|
||||
|| _options.MaxOwnerBytes < _options.MaxSegmentBytes || _options.MaxSegments < 1
|
||||
|| _options.IdleTimeoutSeconds < 15 || _options.MaxOwnerPodcasts < 1)
|
||||
throw new InvalidOperationException("Invalid Podcast storage options.");
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new ConcurrentDictionary<string, LiveCastHandler>();
|
||||
|
||||
public LiveProcessor(ILoggerFactory loggerFactory)
|
||||
public async Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ownerId) || string.IsNullOrWhiteSpace(title) || title.Trim().Length > 160)
|
||||
throw new PodcastException(400, "Un titre de 1 a 160 caracteres et un utilisateur sont requis.");
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<LiveProcessor>();
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
if (_documents.Values.Any(d => d.Manifest.Podcast.OwnerId == ownerId && d.Manifest.Podcast.State == PodcastState.Live))
|
||||
throw new PodcastException(409, "Vous avez deja un direct en cours.");
|
||||
if (OwnerBytes(ownerId) >= _options.MaxOwnerBytes)
|
||||
throw new PodcastException(413, "Quota de stockage des podcasts atteint.");
|
||||
if (_documents.Values.Count(d => d.Manifest.Podcast.OwnerId == ownerId) >= _options.MaxOwnerPodcasts)
|
||||
throw new PodcastException(413, "Nombre maximal de podcasts atteint. Supprimez un ancien replay.");
|
||||
var now = _clock.GetUtcNow();
|
||||
var podcast = new PodcastResponse(Guid.NewGuid(), title.Trim(), ownerId, ownerName,
|
||||
now, now, PodcastState.Live, 0, 0, 0);
|
||||
var document = new Document(new(podcast, Array.Empty<PodcastSegmentResponse>()), Array.Empty<string>());
|
||||
Directory.CreateDirectory(DirectoryFor(podcast.Id));
|
||||
await SaveAsync(document, ct);
|
||||
_logger.LogInformation("Podcast {PodcastId} created by {OwnerId}", podcast.Id, ownerId);
|
||||
return podcast;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
|
||||
public async Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// TODO defer request handling
|
||||
string uname = user.UserName;
|
||||
LiveCastHandler liveHandler = null;
|
||||
if (Casters.ContainsKey(uname))
|
||||
{
|
||||
_logger.LogWarning($"Casters.ContainsKey({uname})");
|
||||
liveHandler = Casters[uname];
|
||||
if (liveHandler.Socket.State == WebSocketState.Open || liveHandler.Socket.State == WebSocketState.Connecting)
|
||||
{
|
||||
_logger.LogWarning($"Closing cx");
|
||||
// FIXME loosed connexion should be detected & disposed else where
|
||||
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "one by user", CancellationToken.None);
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
return _documents.Values.Select(d => d.Manifest.Podcast)
|
||||
.OrderByDescending(p => p.CreatedAt).ToArray();
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
}
|
||||
if (!liveHandler.TokenSource.IsCancellationRequested)
|
||||
{
|
||||
liveHandler.TokenSource.Cancel();
|
||||
}
|
||||
liveHandler.Socket.Dispose();
|
||||
liveHandler.Socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
liveHandler.TokenSource = new CancellationTokenSource();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation($"new caster");
|
||||
// Accept the socket
|
||||
liveHandler = new LiveCastHandler { Socket = await context.WebSockets.AcceptWebSocketAsync() };
|
||||
}
|
||||
_logger.LogInformation("Accepted web socket");
|
||||
// Dispatch the flow
|
||||
public async Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
var manifest = Find(id).Manifest;
|
||||
return manifest with { Segments = manifest.Segments.ToArray() };
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
|
||||
double durationSeconds, Stream input, CancellationToken ct)
|
||||
{
|
||||
if (sequence < 0 || !double.IsFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 15)
|
||||
throw new PodcastException(400, "Sequence ou duree du segment invalide (maximum 15 secondes).");
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
_ = Owned(id, ownerId);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
// Bound concurrent body buffering without holding the metadata lock during network reads.
|
||||
await _uploads.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var bytes = await ReadSegmentAsync(input, ct);
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (liveHandler.Socket != null && liveHandler.Socket.State == WebSocketState.Open)
|
||||
var document = Owned(id, ownerId);
|
||||
var podcast = document.Manifest.Podcast;
|
||||
var segments = document.Manifest.Segments;
|
||||
if (sequence > segments.Count)
|
||||
throw new PodcastException(409, $"Le prochain segment attendu est {segments.Count}.");
|
||||
if (sequence == segments.Count && podcast.State != PodcastState.Live)
|
||||
throw new PodcastException(409, "Ce direct est termine.");
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
if (sequence < segments.Count)
|
||||
{
|
||||
Casters[uname] = liveHandler;
|
||||
// TODO: Handle the socket here.
|
||||
// Find receivers: others in the chat room
|
||||
// send them the flow
|
||||
var buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
var sBuffer = new ArraySegment<byte>(buffer);
|
||||
_logger.LogInformation("Receiving bytes...");
|
||||
|
||||
WebSocketReceiveResult received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
|
||||
|
||||
_logger.LogInformation($"Received bytes : {received.Count}");
|
||||
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
|
||||
|
||||
|
||||
|
||||
var fsInputQueue = new Queue<ArraySegment<byte>>();
|
||||
|
||||
bool endOfInput = false;
|
||||
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
|
||||
fsInputQueue.Enqueue(sBuffer);
|
||||
var taskWritingToFs = liveHandler.ReceiveUserFile(user, _logger, destDir, fsInputQueue, fileName, () => endOfInput);
|
||||
|
||||
|
||||
Stack<string> ToClose = new Stack<string>();
|
||||
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
_logger.LogInformation($"Echoing {received.Count} bytes received in a {received.MessageType} message; Fin={received.EndOfMessage}");
|
||||
// Echo anything we receive
|
||||
// and send to all listner found
|
||||
_logger.LogInformation($"{liveHandler.Listeners.Count} listeners");
|
||||
foreach (var cliItem in liveHandler.Listeners)
|
||||
{
|
||||
var listenningSocket = cliItem.Value;
|
||||
if (listenningSocket.State == WebSocketState.Open)
|
||||
{
|
||||
_logger.LogInformation(cliItem.Key);
|
||||
await listenningSocket.SendAsync(
|
||||
sBuffer, received.MessageType, received.EndOfMessage, liveHandler.TokenSource.Token);
|
||||
}
|
||||
else if (listenningSocket.State == WebSocketState.CloseReceived || listenningSocket.State == WebSocketState.CloseSent)
|
||||
{
|
||||
ToClose.Push(cliItem.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!received.CloseStatus.HasValue)
|
||||
{
|
||||
_logger.LogInformation("try and receive new bytes");
|
||||
|
||||
buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
|
||||
|
||||
_logger.LogInformation($"Received bytes : {received.Count}");
|
||||
|
||||
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
|
||||
_logger.LogInformation($"segment : offset: {sBuffer.Offset} count: {sBuffer.Count}");
|
||||
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
|
||||
|
||||
if (received.CloseStatus.HasValue)
|
||||
{
|
||||
endOfInput=true;
|
||||
_logger.LogInformation($"received a close status: {received.CloseStatus.Value}: {received.CloseStatusDescription}");
|
||||
}
|
||||
else fsInputQueue.Enqueue(sBuffer);
|
||||
}
|
||||
else endOfInput=true;
|
||||
while (ToClose.Count > 0)
|
||||
{
|
||||
string no = ToClose.Pop();
|
||||
_logger.LogInformation("Closing follower connection");
|
||||
WebSocket listenningSocket;
|
||||
if (liveHandler.Listeners.TryRemove(no, out listenningSocket))
|
||||
{
|
||||
await listenningSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable,
|
||||
"State != WebSocketState.Open", CancellationToken.None);
|
||||
listenningSocket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
while (liveHandler.Socket.State == WebSocketState.Open);
|
||||
|
||||
_logger.LogInformation("Closing connection");
|
||||
taskWritingToFs.Wait();
|
||||
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, received.CloseStatusDescription, liveHandler.TokenSource.Token);
|
||||
|
||||
liveHandler.TokenSource.Cancel();
|
||||
liveHandler.Dispose();
|
||||
_logger.LogInformation("Resulting file : " + JsonConvert.SerializeObject(taskWritingToFs.Result));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Exception occured : {ex.Message}");
|
||||
_logger.LogError(ex.StackTrace);
|
||||
liveHandler.TokenSource.Cancel();
|
||||
throw;
|
||||
}
|
||||
taskWritingToFs.Dispose();
|
||||
if (document.Hashes[sequence] != hash || segments[sequence].DurationSeconds != durationSeconds)
|
||||
throw new PodcastException(409, "Un autre contenu existe deja pour cette sequence.");
|
||||
return podcast;
|
||||
}
|
||||
else
|
||||
if (segments.Count >= _options.MaxSegments || OwnerBytes(ownerId) + bytes.Length > _options.MaxOwnerBytes)
|
||||
throw new PodcastException(413, "Quota de stockage ou duree maximale du podcast atteint.");
|
||||
|
||||
var path = SegmentPath(id, sequence);
|
||||
await File.WriteAllBytesAsync(path + ".tmp", bytes, ct);
|
||||
File.Move(path + ".tmp", path, true);
|
||||
var updated = podcast with
|
||||
{
|
||||
// Socket was not accepted open ...
|
||||
// not (meta.Socket != null && meta.Socket.State == WebSocketState.Open)
|
||||
if (liveHandler.Socket != null)
|
||||
{
|
||||
_logger.LogError($"meta.Socket.State not Open: {liveHandler.Socket.State} ");
|
||||
liveHandler.Socket.Dispose();
|
||||
}
|
||||
else
|
||||
_logger.LogError("socket object is null");
|
||||
}
|
||||
|
||||
RemoveLiveInfo(uname);
|
||||
UpdatedAt = _clock.GetUtcNow(), SegmentCount = segments.Count + 1,
|
||||
Bytes = podcast.Bytes + bytes.Length, DurationSeconds = podcast.DurationSeconds + durationSeconds
|
||||
};
|
||||
await SaveAsync(new(new(updated,
|
||||
segments.Append(new PodcastSegmentResponse(sequence, durationSeconds, bytes.Length)).ToArray()),
|
||||
document.Hashes.Append(hash).ToArray()), ct);
|
||||
return updated;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
if (ex.Message == "Unexpected end of stream")
|
||||
{
|
||||
_logger.LogError($"Unexpected end of stream");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Really unexpected end of stream");
|
||||
await liveHandler.Socket?.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, ex.Message, CancellationToken.None);
|
||||
}
|
||||
liveHandler.Socket?.Dispose();
|
||||
RemoveLiveInfo(uname);
|
||||
}
|
||||
return true;
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
void RemoveLiveInfo(string userName)
|
||||
{
|
||||
LiveCastHandler caster;
|
||||
if (Casters.TryRemove(userName, out caster))
|
||||
_logger.LogInformation("removed live info");
|
||||
else
|
||||
_logger.LogError("could not remove live info");
|
||||
finally { _uploads.Release(); }
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadSegmentAsync(Stream input, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[64 * 1024];
|
||||
int count;
|
||||
while ((count = await input.ReadAsync(chunk, timeout.Token)) != 0)
|
||||
{
|
||||
if (buffer.Length + count > _options.MaxSegmentBytes)
|
||||
throw new PodcastException(413, "Segment trop volumineux.");
|
||||
await buffer.WriteAsync(chunk.AsMemory(0, count), timeout.Token);
|
||||
}
|
||||
var bytes = buffer.ToArray();
|
||||
ValidateTransportStream(bytes);
|
||||
return bytes;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new PodcastException(408, "Delai de reception du segment depasse.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Owned(id, ownerId);
|
||||
var podcast = document.Manifest.Podcast;
|
||||
if (podcast.State == PodcastState.Completed)
|
||||
return podcast;
|
||||
var updated = podcast with { State = PodcastState.Completed, UpdatedAt = _clock.GetUtcNow() };
|
||||
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = updated } }, ct);
|
||||
_logger.LogInformation("Podcast {PodcastId} completed with {Segments} segments", id, updated.SegmentCount);
|
||||
return updated;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Find(id);
|
||||
if (sequence < 0 || sequence >= document.Manifest.Segments.Count)
|
||||
throw new PodcastException(404, "Segment introuvable.");
|
||||
return new FileStream(SegmentPath(id, sequence), FileMode.Open, FileAccess.Read,
|
||||
FileShare.Read | FileShare.Delete, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, string ownerId, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Owned(id, ownerId);
|
||||
if (document.Manifest.Podcast.State == PodcastState.Live)
|
||||
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
|
||||
Directory.Delete(DirectoryFor(id), recursive: true);
|
||||
_documents.Remove(id);
|
||||
_logger.LogInformation("Podcast {PodcastId} deleted by {OwnerId}", id, ownerId);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task LoadAsync(CancellationToken ct)
|
||||
{
|
||||
if (_loaded) return;
|
||||
Directory.CreateDirectory(_options.RootPath);
|
||||
foreach (var directory in Directory.EnumerateDirectories(_options.RootPath))
|
||||
{
|
||||
if (!Guid.TryParseExact(Path.GetFileName(directory), "N", out var id)) continue;
|
||||
var path = Path.Combine(directory, "index.json");
|
||||
if (!File.Exists(path)) continue; // An uncommitted create may leave an empty directory.
|
||||
await using var file = File.OpenRead(path);
|
||||
var document = await JsonSerializer.DeserializeAsync<Document>(file, cancellationToken: ct)
|
||||
?? throw new InvalidDataException($"Invalid podcast index {id}.");
|
||||
if (document.Manifest.Podcast.Id != id || document.Hashes.Length != document.Manifest.Segments.Count)
|
||||
throw new InvalidDataException($"Inconsistent podcast index {id}.");
|
||||
_documents[id] = document;
|
||||
}
|
||||
// A restarted process cannot know whether its old encoder is still running.
|
||||
foreach (var document in _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Live).ToArray())
|
||||
await InterruptAsync(document, ct);
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task ExpireAsync(CancellationToken ct)
|
||||
{
|
||||
var deadline = _clock.GetUtcNow().AddSeconds(-_options.IdleTimeoutSeconds);
|
||||
foreach (var document in _documents.Values.Where(d =>
|
||||
d.Manifest.Podcast.State == PodcastState.Live && d.Manifest.Podcast.UpdatedAt < deadline).ToArray())
|
||||
await InterruptAsync(document, ct);
|
||||
}
|
||||
|
||||
private async Task InterruptAsync(Document document, CancellationToken ct)
|
||||
{
|
||||
var podcast = document.Manifest.Podcast with { State = PodcastState.Interrupted, UpdatedAt = _clock.GetUtcNow() };
|
||||
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = podcast } }, ct);
|
||||
_logger.LogWarning("Podcast {PodcastId} interrupted; committed segments remain playable", podcast.Id);
|
||||
}
|
||||
|
||||
private async Task SaveAsync(Document document, CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(DirectoryFor(document.Manifest.Podcast.Id), "index.json");
|
||||
await using (var file = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(file, document, cancellationToken: ct);
|
||||
await file.FlushAsync(ct);
|
||||
}
|
||||
File.Move(path + ".tmp", path, true);
|
||||
_documents[document.Manifest.Podcast.Id] = document;
|
||||
}
|
||||
|
||||
private Document Find(Guid id) => _documents.TryGetValue(id, out var document)
|
||||
? document : throw new PodcastException(404, "Podcast introuvable.");
|
||||
|
||||
private Document Owned(Guid id, string ownerId)
|
||||
{
|
||||
var document = Find(id);
|
||||
if (document.Manifest.Podcast.OwnerId != ownerId)
|
||||
throw new PodcastException(403, "Seul l'auteur peut modifier ce podcast.");
|
||||
return document;
|
||||
}
|
||||
|
||||
private long OwnerBytes(string ownerId) => _documents.Values
|
||||
.Where(d => d.Manifest.Podcast.OwnerId == ownerId).Sum(d => d.Manifest.Podcast.Bytes);
|
||||
private string DirectoryFor(Guid id) => Path.Combine(_options.RootPath, id.ToString("N"));
|
||||
private string SegmentPath(Guid id, int sequence) => Path.Combine(DirectoryFor(id), $"{sequence:D8}.ts");
|
||||
|
||||
private static void ValidateTransportStream(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0 || bytes.Length % 188 != 0)
|
||||
throw new PodcastException(400, "Un segment MPEG-TS complet est requis.");
|
||||
for (var i = 0; i < bytes.Length; i += 188)
|
||||
if (bytes[i] != 0x47)
|
||||
throw new PodcastException(400, "Synchronisation MPEG-TS invalide.");
|
||||
}
|
||||
|
||||
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
|
||||
{
|
||||
var userName = user.UserName ?? throw new InvalidOperationException("A stream owner must have a user name.");
|
||||
var handler = new LiveCastHandler();
|
||||
if (!Casters.TryAdd(userName, handler))
|
||||
{
|
||||
handler.Dispose();
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
handler.Socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
context.RequestAborted, handler.TokenSource.Token);
|
||||
var ct = cancellation.Token;
|
||||
var path = Path.Combine(destDir, Path.GetFileName(fileName));
|
||||
var oldLength = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
var usage = user.DiskUsage - oldLength;
|
||||
await using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
var buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
while (handler.Socket.State == WebSocketState.Open)
|
||||
{
|
||||
var received = await handler.Socket.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
|
||||
if (received.MessageType == WebSocketMessageType.Close) break;
|
||||
if (received.MessageType != WebSocketMessageType.Binary || usage + received.Count > user.DiskQuota)
|
||||
{
|
||||
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "Binary data and available quota required.", ct);
|
||||
return false;
|
||||
}
|
||||
await file.WriteAsync(buffer.AsMemory(0, received.Count), ct);
|
||||
usage += received.Count;
|
||||
user.DiskUsage = usage;
|
||||
foreach (var listener in handler.Listeners.Values)
|
||||
if (listener.State == WebSocketState.Open)
|
||||
await listener.SendAsync(new ArraySegment<byte>(buffer, 0, received.Count),
|
||||
WebSocketMessageType.Binary, received.EndOfMessage, ct);
|
||||
}
|
||||
if (handler.Socket.State == WebSocketState.CloseReceived)
|
||||
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Stream complete.", ct);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or WebSocketException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Legacy stream interrupted for {UserName}", user.UserName);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Casters.TryRemove(userName, out _);
|
||||
handler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var handler in Casters.Values) handler.Dispose();
|
||||
_gate.Dispose();
|
||||
_uploads.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/Yavsc.Server/Services/PodcastException.cs
Normal file
6
src/Yavsc.Server/Services/PodcastException.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Services;
|
||||
|
||||
public sealed class PodcastException(int statusCode, string message) : Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
}
|
||||
11
src/Yavsc.Server/Settings/PodcastOptions.cs
Normal file
11
src/Yavsc.Server/Settings/PodcastOptions.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -1,85 +1,33 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
|
||||
namespace Yavsc.ViewModels.Streaming
|
||||
namespace Yavsc.ViewModels.Streaming;
|
||||
|
||||
public class LiveCastClient
|
||||
{
|
||||
public class LiveCastClient
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public WebSocket Socket { get; set; }
|
||||
}
|
||||
|
||||
public class LiveEntryViewModel
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string FlowId { get; set; }
|
||||
}
|
||||
|
||||
public class LiveCastHandler : IDisposable
|
||||
{
|
||||
|
||||
public WebSocket Socket { get; set; }
|
||||
public ConcurrentDictionary<string, WebSocket> Listeners { get; set; } = new ConcurrentDictionary<string, WebSocket>();
|
||||
|
||||
public CancellationTokenSource TokenSource { get; set; } = new CancellationTokenSource();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<FileReceivedInfo> ReceiveUserFile(ApplicationUser user, ILogger logger, string root, Queue<ArraySegment<byte>> queue, string destFileName, Func<bool> isEndOfInput)
|
||||
{
|
||||
// TODO lock user's disk usage for this scope,
|
||||
// this process is not safe at concurrent access.
|
||||
long usage = user.DiskUsage;
|
||||
|
||||
var item = new FileReceivedInfo
|
||||
(root, AbstractFileSystemHelpers.FilterFileName(destFileName));
|
||||
|
||||
var fi = new FileInfo(Path.Combine(root, item.FileName));
|
||||
if (fi.Exists)
|
||||
{
|
||||
item.Overridden = true;
|
||||
usage -= fi.Length;
|
||||
}
|
||||
|
||||
logger.LogInformation("Opening the file");
|
||||
using (var dest = fi.Open(FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
{
|
||||
logger.LogInformation("Appening to file");
|
||||
while (!isEndOfInput() || queue.Count > 0)
|
||||
{
|
||||
if (queue.Count > 0)
|
||||
{
|
||||
var buffer = queue.Dequeue();
|
||||
|
||||
logger.LogInformation($"writing {buffer.Count} bytes...");
|
||||
|
||||
await dest.WriteAsync(buffer.Array, buffer.Offset, buffer.Count);
|
||||
logger.LogInformation($"done.");
|
||||
usage += buffer.Count;
|
||||
}
|
||||
if (usage >= user.DiskQuota) break;
|
||||
if (queue.Count == 0 && !isEndOfInput())
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
user.DiskUsage = usage;
|
||||
dest.Close();
|
||||
}
|
||||
if (usage >= user.DiskQuota)
|
||||
{
|
||||
item.QuotaOffense = true;
|
||||
}
|
||||
user.DiskUsage = usage;
|
||||
return item;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public string UserName { get; set; }
|
||||
public WebSocket Socket { get; set; }
|
||||
}
|
||||
|
||||
public class LiveEntryViewModel
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string FlowId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LiveCastHandler : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
public WebSocket Socket { get; set; }
|
||||
public ConcurrentDictionary<string, WebSocket> Listeners { get; } = new();
|
||||
public CancellationTokenSource TokenSource { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
TokenSource.Cancel();
|
||||
Socket?.Dispose();
|
||||
foreach (var listener in Listeners.Values) listener.Dispose();
|
||||
TokenSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
|
||||
.AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
|
||||
builder.Services.AddSiteAdministration();
|
||||
var podcastRoot = Path.Combine(Path.GetTempPath(), $"yavsc-api-podcasts-{Guid.NewGuid():N}");
|
||||
builder.Services.AddPodcasts(builder.Configuration);
|
||||
builder.Services.Configure<PodcastOptions>(options => options.RootPath = podcastRoot);
|
||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
|
|
@ -82,6 +85,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("ApiScope", policy => policy.RequireAuthenticatedUser().RequireClaim("scope", "api"));
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
|
|
@ -107,7 +111,12 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
};
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
var app = builder.Build();
|
||||
app.Lifetime.ApplicationStopped.Register(() =>
|
||||
{
|
||||
if (Directory.Exists(podcastRoot)) Directory.Delete(podcastRoot, recursive: true);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
||||
|
|
|
|||
267
src/test/Yavsc.Api.Tests/LiveProcessorTests.cs
Normal file
267
src/test/Yavsc.Api.Tests/LiveProcessorTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal file
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
|
||||
|
||||
private HttpClient Client(string? user = "alice", string scope = "api")
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
}) { BaseAddress = new Uri(fixture.BaseAddress) };
|
||||
if (user is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", TestTokenIssuer.Issue(user, scope));
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/podcasts")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/index.m3u8")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/segments/0.ts")]
|
||||
public async Task Metadata_playlists_and_segments_are_never_anonymous(string path)
|
||||
{
|
||||
using var anonymous = Client(null);
|
||||
using var response = await anonymous.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
using var wrongScope = Client("bob", "profile");
|
||||
using var forbidden = await wrongScope.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_ingestion_playback_completion_and_deletion_work_over_http()
|
||||
{
|
||||
using var owner = Client($"podcast-owner-{Guid.NewGuid():N}");
|
||||
using var listener = Client("podcast-listener");
|
||||
using var created = await owner.PostAsJsonAsync("/api/v1/podcasts", new CreatePodcastRequest { Title = "Concert" }, _ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var podcast = await created.Content.ReadFromJsonAsync<PodcastResponse>(_ct);
|
||||
Assert.NotNull(podcast);
|
||||
Assert.NotNull(created.Headers.Location);
|
||||
var path = $"/api/v1/podcasts/{podcast.Id}";
|
||||
using var content = new ByteArrayContent(LiveProcessorTests.Packets());
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("video/mp2t");
|
||||
using var appended = await owner.PutAsync($"{path}/segments/0?duration=2.5", content, _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, appended.StatusCode);
|
||||
var live = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
|
||||
Assert.StartsWith("#EXTM3U", live);
|
||||
Assert.Contains("#EXTINF:2.5,\nsegments/0.ts", live);
|
||||
Assert.DoesNotContain("#EXT-X-ENDLIST", live);
|
||||
Assert.Equal(LiveProcessorTests.Packets(), await listener.GetByteArrayAsync($"{path}/segments/0.ts", _ct));
|
||||
using var denied = await listener.PostAsync($"{path}/complete", null, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
using var completed = await owner.PostAsync($"{path}/complete", null, _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, completed.StatusCode);
|
||||
var replay = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
|
||||
Assert.Contains("#EXT-X-ENDLIST", replay);
|
||||
using var deleteDenied = await listener.DeleteAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, deleteDenied.StatusCode);
|
||||
using var deleted = await owner.DeleteAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
using var missing = await listener.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_title_is_a_validation_problem()
|
||||
{
|
||||
using var owner = Client();
|
||||
using var response = await owner.PostAsJsonAsync("/api/v1/podcasts", new { title = " " }, _ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("application/problem+json", response.Content.Headers.ContentType?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApi_exposes_podcast_routes_without_breaking_document_generation()
|
||||
{
|
||||
using var admin = Client("podcast-admin");
|
||||
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
|
||||
TestTokenIssuer.Issue("podcast-admin", "api",
|
||||
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminGroupName) }));
|
||||
using var response = await admin.GetAsync("/openapi/podcasts.json", _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(_ct));
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts", out _));
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/segments/{sequence}", out _));
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/index.m3u8", out _));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue