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