push-pqqsxyupswry #21
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,3 +39,7 @@ upmpdcli/
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
pmocontrol_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
121
BROWSEMETADATA_FIX.md
Normal file
121
BROWSEMETADATA_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# BrowseMetadata Fix for Radio Paradise - PMO Music
|
||||
|
||||
**Date:** 2025-11-27
|
||||
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
|
||||
|
||||
## Problem
|
||||
|
||||
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
|
||||
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
|
||||
- History containers (e.g., `radio-paradise:channel:mellow:history`)
|
||||
|
||||
Error:
|
||||
```
|
||||
Failed to get metadata for 'radio-paradise:channel:mellow:live'
|
||||
Failed to get metadata for 'radio-paradise:channel:mellow:history'
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The UPnP ContentDirectory service has two browse modes:
|
||||
- **BrowseMetadata**: Get metadata for a specific object (item or container)
|
||||
- **BrowseDirectChildren**: Get the children of a container
|
||||
|
||||
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
|
||||
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
|
||||
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
|
||||
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||
|
||||
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
|
||||
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
|
||||
|
||||
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
|
||||
|
||||
**For LiveStream items:**
|
||||
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
|
||||
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
|
||||
|
||||
**For HistoryTrack items:**
|
||||
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
|
||||
- Properly retrieves track metadata from the history playlist
|
||||
|
||||
**For History containers:**
|
||||
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
|
||||
- Provides both container metadata and its children in one result
|
||||
|
||||
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||
|
||||
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
|
||||
- Prevents containers from appearing as children of themselves
|
||||
- For History: BrowseDirectChildren returns only tracks, not the container
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
|
||||
- Lines 120-135: Try get_item() first in browse_metadata()
|
||||
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
|
||||
- Lines 212, 286: Updated callers to pass object_id
|
||||
|
||||
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
|
||||
- Lines 345-369: Modified History browse to return Mixed (container + items)
|
||||
- Lines 371-377: Modified LiveStream browse to return item metadata
|
||||
- Lines 379-383: Modified HistoryTrack browse to return track metadata
|
||||
|
||||
## Validation
|
||||
|
||||
### Live Stream Metadata ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
|
||||
```
|
||||
Returns:
|
||||
```xml
|
||||
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
|
||||
<dc:title>Unknown Title</dc:title>
|
||||
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
|
||||
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
|
||||
</item>
|
||||
```
|
||||
|
||||
### History Container Metadata ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
|
||||
```
|
||||
Returns:
|
||||
```xml
|
||||
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
|
||||
<dc:title>Mellow Mix - History</dc:title>
|
||||
<upnp:class>object.container.playlistContainer</upnp:class>
|
||||
</container>
|
||||
```
|
||||
|
||||
### History Children ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
|
||||
```
|
||||
Returns only track items (not the container itself)
|
||||
|
||||
## Design Notes
|
||||
|
||||
This solution works around a fundamental limitation in the `MusicSource` trait:
|
||||
- The `browse()` method doesn't receive the `browse_flag` parameter
|
||||
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
|
||||
- We use `get_item()` for items and `browse()` for containers
|
||||
- Container filtering ensures correct BrowseDirectChildren behavior
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] BrowseMetadata works for LiveStream items
|
||||
- [x] BrowseMetadata works for History containers
|
||||
- [x] BrowseDirectChildren works for History (returns only tracks)
|
||||
- [x] Container filtering prevents self-reference
|
||||
- [ ] Test with BubbleUPnP (user to verify)
|
||||
- [ ] Test with gupnp-av-cp (user to verify)
|
||||
|
||||
## References
|
||||
|
||||
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
|
||||
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||
849
Cargo.lock
generated
849
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,5 +19,5 @@ members = [
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmometadata", "pmocontrol",
|
||||
]
|
||||
|
||||
72
DEPENDENCIES.md
Normal file
72
DEPENDENCIES.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## Diagramme des dépendances PMOMusic (crates internes)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
|
||||
PMOMusic --> pmoapp
|
||||
PMOMusic --> pmoaudio_ext
|
||||
PMOMusic --> pmoaudiocache
|
||||
PMOMusic --> pmocovers
|
||||
PMOMusic --> pmoconfig
|
||||
PMOMusic --> pmoserver
|
||||
PMOMusic --> pmosource
|
||||
PMOMusic --> pmoupnp
|
||||
PMOMusic --> pmomediaserver
|
||||
PMOMusic --> pmomediarenderer
|
||||
PMOMusic --> pmoqobuz
|
||||
PMOMusic --> pmoparadise
|
||||
|
||||
pmoaudio_ext --> pmoaudio
|
||||
pmoaudio_ext --> pmocovers
|
||||
pmoaudio_ext --> pmocache
|
||||
pmoaudio_ext --> pmoaudiocache
|
||||
pmoaudio_ext --> pmometadata
|
||||
pmoaudio_ext --> pmoplaylist
|
||||
|
||||
pmoaudiocache --> pmocache
|
||||
pmoaudiocache --> pmometadata
|
||||
|
||||
pmocovers --> pmocache
|
||||
|
||||
pmoplaylist --> pmocache
|
||||
pmoplaylist --> pmoaudiocache
|
||||
pmoplaylist --> pmometadata
|
||||
pmoplaylist --> pmodidl
|
||||
|
||||
pmosource --> pmoaudiocache
|
||||
pmosource --> pmocovers
|
||||
pmosource --> pmocache
|
||||
pmosource --> pmoplaylist
|
||||
pmosource --> pmodidl
|
||||
pmosource --> pmoconfig
|
||||
pmosource --> pmoserver
|
||||
pmosource --> pmoupnp
|
||||
|
||||
pmoparadise --> pmosource
|
||||
pmoparadise --> pmoaudiocache
|
||||
pmoparadise --> pmoplaylist
|
||||
pmoparadise --> pmoserver
|
||||
pmoparadise --> pmoconfig
|
||||
|
||||
pmoqobuz --> pmosource
|
||||
pmoqobuz --> pmoaudiocache
|
||||
pmoqobuz --> pmocovers
|
||||
pmoqobuz --> pmoserver
|
||||
pmoqobuz --> pmoconfig
|
||||
|
||||
pmomediaserver --> pmoserver
|
||||
pmomediaserver --> pmosource
|
||||
pmomediaserver --> pmoconfig
|
||||
pmomediaserver --> pmocovers
|
||||
pmomediaserver --> pmoaudiocache
|
||||
pmomediaserver --> pmoplaylist
|
||||
|
||||
pmoupnp --> pmoserver
|
||||
pmoupnp --> pmocovers
|
||||
pmoupnp --> pmoaudiocache
|
||||
pmoupnp --> pmoplaylist
|
||||
pmoupnp --> pmocache
|
||||
pmoupnp --> pmoconfig
|
||||
```
|
||||
|
||||
> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace.
|
||||
285
PLAYER_PMOSOURCE_README.md
Normal file
285
PLAYER_PMOSOURCE_README.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Player Générique PMO Music
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
|
||||
|
||||
## Objectifs
|
||||
|
||||
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
|
||||
|
||||
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
|
||||
|
||||
```typescript
|
||||
// Endpoints utilisés
|
||||
GET /api/sources // Liste les sources
|
||||
GET /api/sources/{id} // Info sur une source
|
||||
GET /api/sources/{id}/root // Container racine
|
||||
GET /api/sources/{id}/browse // Parcourt un container
|
||||
GET /api/sources/{id}/resolve // Résout l'URI d'un item
|
||||
GET /api/sources/{id}/image // Image de la source
|
||||
GET /api/sources/{id}/capabilities // Capacités de la source
|
||||
```
|
||||
|
||||
**Fonctions implémentées :**
|
||||
- `listSources()` - Liste toutes les sources enregistrées
|
||||
- `getSource(id)` - Récupère une source spécifique
|
||||
- `getSourceRoot(id)` - Récupère le container racine
|
||||
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
|
||||
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
|
||||
- `getSourceImageUrl(id)` - URL de l'image de la source
|
||||
|
||||
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
|
||||
|
||||
Composant Vue.js qui implémente :
|
||||
|
||||
#### Fonctionnalités implémentées
|
||||
|
||||
1. **Sélection de sources**
|
||||
- Affichage de toutes les sources disponibles
|
||||
- Affichage du logo de chaque source
|
||||
- Affichage des capacités (FIFO, Search, Favorites)
|
||||
|
||||
2. **Navigation dans les containers**
|
||||
- Breadcrumb pour remonter dans la hiérarchie
|
||||
- Affichage des sous-containers (dossiers)
|
||||
- Navigation par clic dans les containers
|
||||
|
||||
3. **Liste des morceaux**
|
||||
- Affichage de tous les items audio d'un container
|
||||
- Métadonnées : titre, artiste, album, cover art
|
||||
- Numérotation des morceaux
|
||||
|
||||
4. **Lecteur audio**
|
||||
- Lecture d'un morceau via résolution d'URI
|
||||
- Contrôles audio natifs HTML5
|
||||
- Section "Now Playing" avec métadonnées
|
||||
- Gestion des erreurs de lecture
|
||||
|
||||
5. **Interface utilisateur**
|
||||
- Design moderne avec dégradés et animations
|
||||
- Responsive design
|
||||
- Indicateurs visuels (morceau actif, en cours de lecture)
|
||||
- Messages d'erreur clairs
|
||||
|
||||
### 3. Intégration
|
||||
|
||||
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||
// ... autres routes
|
||||
]
|
||||
```
|
||||
|
||||
## Ce qui fonctionne
|
||||
|
||||
✅ **Complètement fonctionnel avec l'API actuelle de pmosource :**
|
||||
|
||||
1. Découverte des sources disponibles
|
||||
2. Navigation complète dans la hiérarchie des containers
|
||||
3. Affichage des métadonnées des morceaux
|
||||
4. Résolution des URIs et lecture audio
|
||||
5. Affichage des images de sources
|
||||
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
|
||||
- Mise à jour automatique toutes les 3 secondes
|
||||
- Pas de polling, push serveur
|
||||
- Reconnexion automatique
|
||||
|
||||
## Limitations identifiées et améliorations possibles
|
||||
|
||||
### 1. Métadonnées de couverture d'album
|
||||
|
||||
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
|
||||
|
||||
**État actuel :**
|
||||
- Le champ `album_art` dans `Item` contient parfois une URI
|
||||
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
|
||||
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Résout l'URI de la couverture d'album pour un item
|
||||
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
|
||||
```
|
||||
|
||||
### 2. Recherche globale
|
||||
|
||||
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
|
||||
|
||||
**État actuel :**
|
||||
- Pas d'interface standardisée pour la recherche dans l'UI
|
||||
- Pas de retour clair sur les capacités de recherche
|
||||
|
||||
**Proposition :**
|
||||
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
|
||||
- Documenter clairement le format attendu des requêtes de recherche
|
||||
|
||||
### 3. Pagination
|
||||
|
||||
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
|
||||
|
||||
**État actuel :**
|
||||
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
|
||||
- Pas de méthode `get_total_count(object_id)` dans le trait
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Retourne le nombre total d'items dans un container
|
||||
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
|
||||
```
|
||||
|
||||
Ou ajouter `total_available` dans `BrowseResponse` :
|
||||
```rust
|
||||
pub struct SourceBrowseResponse {
|
||||
// ... champs existants
|
||||
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
|
||||
|
||||
**Solution implémentée :**
|
||||
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
|
||||
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
|
||||
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
|
||||
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
|
||||
|
||||
**Comment ça fonctionne :**
|
||||
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
|
||||
2. Le client se connecte avec `EventSource` (API browser native)
|
||||
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
|
||||
|
||||
**Pour RadioParadise :**
|
||||
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
|
||||
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
|
||||
|
||||
### 5. Playlists utilisateur
|
||||
|
||||
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
|
||||
|
||||
**État actuel :**
|
||||
- Pas encore testé dans le player
|
||||
- Nécessiterait une UI dédiée
|
||||
|
||||
**Proposition :**
|
||||
- Créer une section "Playlists" dans le player
|
||||
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
|
||||
|
||||
### 6. Favoris
|
||||
|
||||
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
|
||||
|
||||
**Proposition :**
|
||||
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
|
||||
- Afficher visuellement les morceaux favoris
|
||||
- Créer une section "Mes Favoris"
|
||||
|
||||
### 7. Auto-play / Queue
|
||||
|
||||
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Interface pour gérer une queue de lecture
|
||||
pub trait Playable: MusicSource {
|
||||
async fn get_next_track(&self) -> Result<Option<Item>>;
|
||||
async fn get_previous_track(&self) -> Result<Option<Item>>;
|
||||
async fn add_to_queue(&self, item: Item) -> Result<()>;
|
||||
async fn clear_queue(&self) -> Result<()>;
|
||||
async fn get_queue(&self) -> Result<Vec<Item>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Durée totale d'un container
|
||||
|
||||
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Statistiques d'un container spécifique
|
||||
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
|
||||
|
||||
pub struct ContainerStats {
|
||||
pub item_count: usize,
|
||||
pub total_duration_ms: Option<u64>,
|
||||
pub total_size_bytes: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Formats audio disponibles
|
||||
|
||||
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
|
||||
|
||||
**Proposition :**
|
||||
- Ajouter un sélecteur de qualité dans le player
|
||||
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
|
||||
|
||||
### 10. État du cache
|
||||
|
||||
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
|
||||
|
||||
**Proposition :**
|
||||
- Afficher un indicateur de cache sur chaque morceau
|
||||
- Bouton "📥 Télécharger" pour mettre en cache
|
||||
- Barre de progression pour le téléchargement
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
### Court terme
|
||||
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
|
||||
2. 🔄 Identifier les bugs et limitations pratiques
|
||||
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
|
||||
|
||||
### Moyen terme
|
||||
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
|
||||
2. Ajouter la gestion de queue et auto-play
|
||||
3. Ajouter la recherche si supportée
|
||||
4. Intégrer la gestion du cache
|
||||
|
||||
### Long terme
|
||||
1. Support des playlists utilisateur
|
||||
2. Support des favoris
|
||||
3. Égaliseur et effets audio
|
||||
4. Visualisations audio
|
||||
5. Mode hors-ligne avec cache
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
|
||||
|
||||
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
|
||||
2. **La pagination avancée** (pas de compte total)
|
||||
3. **Les métadonnées temps réel** (pour les streams live)
|
||||
4. **La gestion de queue** (pas d'API dédiée)
|
||||
|
||||
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
|
||||
|
||||
## Utilisation
|
||||
|
||||
Pour tester le player :
|
||||
|
||||
1. Lancer le serveur backend avec au moins une source enregistrée :
|
||||
```bash
|
||||
cargo run --example single_channel_server --features full
|
||||
```
|
||||
|
||||
2. Accéder à l'application web :
|
||||
```
|
||||
http://localhost:8080/app/
|
||||
```
|
||||
|
||||
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
|
||||
|
||||
## Remarques importantes
|
||||
|
||||
- ✅ Le player **n'utilise QUE l'API pmosource générique**
|
||||
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
|
||||
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
|
||||
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)
|
||||
Binary file not shown.
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
Binary file not shown.
21
PMOMusic/.pmomusic/config.yaml
Normal file
21
PMOMusic/.pmomusic/config.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
host:
|
||||
http_port: '8080'
|
||||
cover_cache:
|
||||
directory: cache_covers
|
||||
size: 2000
|
||||
audio_cache:
|
||||
directory: cache_audio
|
||||
size: 500
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: INFO
|
||||
playlists:
|
||||
directory: playlists
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
udn: f77de90b-3a4a-408c-8462-3308ad500744
|
||||
mediaserver:
|
||||
pmo_mediaserver:
|
||||
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmocontrol::ControlPointExt;
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
||||
use pmomediaserver::{
|
||||
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||
};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
@@ -36,13 +39,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("🎵 Registering music sources...");
|
||||
|
||||
// // Enregistrer Qobuz
|
||||
// if let Err(e) = server.register_qobuz().await {
|
||||
// if let Err(e) = server.write().await.register_qobuz().await {
|
||||
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e);
|
||||
// }
|
||||
|
||||
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
||||
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
|
||||
info!("📻 Initializing Radio Paradise streaming channels...");
|
||||
if let Err(e) = server.write().await.init_paradise_streaming().await {
|
||||
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
|
||||
} else {
|
||||
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
@@ -75,12 +84,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// Enregistrer l'instance ContentDirectory pour les notifications GENA
|
||||
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {
|
||||
pmomediaserver::contentdirectory::state::register_instance(&cd_service);
|
||||
}
|
||||
|
||||
// Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
server_instance.description_route()
|
||||
);
|
||||
|
||||
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||
info!("🎛️ Registering Control Point...");
|
||||
let _control_point = server
|
||||
.write()
|
||||
.await
|
||||
.register_control_point(5)
|
||||
.await
|
||||
.expect("Failed to register Control Point");
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server
|
||||
@@ -96,7 +122,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
|
||||
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||
server.write().await.wait().await;
|
||||
|
||||
Ok(())
|
||||
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||
info!("Waiting for background threads to finish...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
|
||||
info!("✅ PMOMusic stopped");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
447
Plan_d_implementation_webui_control_point.md
Normal file
447
Plan_d_implementation_webui_control_point.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# PMOControl WebUI - Design Recommendations & Implementation Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
|
||||
- A complete REST API with OpenAPI documentation (`/api/control/*`)
|
||||
- SSE endpoints for real-time updates (`/api/control/events/*`)
|
||||
- Vue 3 + TypeScript + Vite setup
|
||||
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Recommendations
|
||||
|
||||
### 1. State Management: **Use Pinia**
|
||||
|
||||
**Recommendation: Pinia (Vue 3's official state management)**
|
||||
|
||||
**Rationale:**
|
||||
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
|
||||
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
|
||||
- **TypeScript native**: Better type inference than Vuex
|
||||
- **DevTools integration**: Built-in debugging for SSE event flows
|
||||
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
|
||||
- **Performance**: Lightweight (~1KB), modular stores
|
||||
- **Official Vue 3 recommendation**: Future-proof choice
|
||||
|
||||
**Store Architecture:**
|
||||
```typescript
|
||||
// stores/renderers.ts - Renderer state (SSE updates)
|
||||
// stores/mediaServers.ts - Media server state (SSE updates)
|
||||
// stores/playback.ts - Current playback session
|
||||
// stores/ui.ts - UI state (selected renderer, view preferences)
|
||||
```
|
||||
|
||||
**Benefits for your use case:**
|
||||
- Handle 20+ concurrent clients with shared state
|
||||
- Real-time SSE event synchronization across all views
|
||||
- Easy to scale with multi-renderer, multi-server, multi-session architecture
|
||||
|
||||
---
|
||||
|
||||
### 2. UI Component Library: **Headless UI + Custom Components**
|
||||
|
||||
**Recommendation: Hybrid approach - Headless UI components + custom styling**
|
||||
|
||||
**Component Library: Shadcn-vue (Headless UI primitives)**
|
||||
|
||||
**Rationale:**
|
||||
- **Lightweight & performant**: Only import what you need
|
||||
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
|
||||
- **TypeScript-first**: Perfect type safety
|
||||
- **Accessibility built-in**: ARIA compliance out of the box
|
||||
- **No theme lock-in**: Complete CSS freedom
|
||||
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
|
||||
|
||||
**Why NOT a full framework (Vuetify, Element Plus)?**
|
||||
- Heavy bundle size (100-500KB vs ~10KB for headless)
|
||||
- Theme customization overhead
|
||||
- Your spec requires custom status-based coloring
|
||||
- Performance critical with 20 concurrent clients
|
||||
|
||||
**Alternative if you prefer pre-styled:** PrimeVue
|
||||
- Good performance
|
||||
- Customizable themes
|
||||
- Strong TypeScript support
|
||||
- But: 150KB+ bundle size
|
||||
|
||||
**Custom Components to Build:**
|
||||
- `RendererCard` - Status-colored cards for each renderer
|
||||
- `TransportControls` - Play/Pause/Stop/Next buttons
|
||||
- `VolumeControl` - Slider with mute toggle
|
||||
- `QueueViewer` - Playlist display with drag-drop
|
||||
- `MediaServerBrowser` - Container navigation
|
||||
|
||||
---
|
||||
|
||||
### 3. Existing Components: **Reorganize into Debug Section**
|
||||
|
||||
**Recommendation: Keep existing components, create new PMOControl home**
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
/app (root) → PMOControl Dashboard (NEW)
|
||||
/app/debug → Dropdown menu
|
||||
├─ /logs → LogView
|
||||
├─ /upnp → UpnpExplorer
|
||||
├─ /covers-cache → CoverCacheManager
|
||||
├─ /audio-cache → AudioCacheManager
|
||||
├─ /api-dashboard → APIDashboard
|
||||
└─ /radio-paradise → RadioParadiseExplorer
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Existing components are valuable for development/debugging
|
||||
- Don't break existing functionality
|
||||
- PMOControl becomes primary interface as specified
|
||||
- Debug tools remain accessible but not prominent
|
||||
- Matches current App.vue dropdown pattern
|
||||
|
||||
**Home Screen (/) - PMOControl Dashboard:**
|
||||
- Grid of renderer cards (status-colored)
|
||||
- Active playback session viewer
|
||||
- Quick controls (play/pause/volume)
|
||||
- Media server browser panel
|
||||
|
||||
---
|
||||
|
||||
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
|
||||
|
||||
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
|
||||
|
||||
**Breakpoints:**
|
||||
```css
|
||||
/* Mobile: < 768px (existing pattern) */
|
||||
- Single column layout
|
||||
- Stacked renderer cards
|
||||
- Bottom-fixed playback controls
|
||||
- Collapsible media browser
|
||||
|
||||
/* Tablet: 768px - 1024px */
|
||||
- Two column layout
|
||||
- Grid of renderer cards (2 columns)
|
||||
- Side panel for media browser
|
||||
- Floating playback controls
|
||||
|
||||
/* Desktop: > 1024px */
|
||||
- Three column layout
|
||||
- Renderer cards grid (3-4 columns)
|
||||
- Persistent media browser sidebar
|
||||
- Always-visible playback controls
|
||||
```
|
||||
|
||||
**Target Devices:**
|
||||
- **Primary**: Desktop browsers (control station)
|
||||
- **Secondary**: Tablets (remote control)
|
||||
- **Tertiary**: Mobile phones (quick controls)
|
||||
|
||||
**Performance considerations:**
|
||||
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
|
||||
- Lazy load album art
|
||||
- Throttle SSE position updates (max 1/sec per renderer)
|
||||
|
||||
---
|
||||
|
||||
### 5. Icons: **Lucide Icons (SVG library)**
|
||||
|
||||
**Recommendation: Lucide Icons (NOT emoji)**
|
||||
|
||||
**Rationale:**
|
||||
- **Professional appearance**: Emojis inconsistent across platforms
|
||||
- **Customizable**: Size, color, stroke width
|
||||
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
|
||||
- **Status coloring**: Icons can match card status colors
|
||||
- **Accessibility**: Proper ARIA labels
|
||||
- **Vue components**: `lucide-vue-next` package
|
||||
|
||||
**Icon mapping:**
|
||||
```typescript
|
||||
Play → PlayCircle
|
||||
Pause → PauseCircle
|
||||
Stop → StopCircle
|
||||
Next → SkipForward
|
||||
Volume → Volume2 / VolumeX (muted)
|
||||
Renderer → Speaker / MonitorSpeaker
|
||||
Server → Server / Database
|
||||
Queue → ListMusic
|
||||
```
|
||||
|
||||
**Alternative if you prefer minimal bundle:** Heroicons
|
||||
- Smaller set (fewer icons)
|
||||
- Tailwind CSS integration
|
||||
- But: less comprehensive for music player needs
|
||||
|
||||
**Why NOT emoji:**
|
||||
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
|
||||
- No color control
|
||||
- Accessibility issues
|
||||
- Unprofessional for production UI
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Real-time SSE Integration
|
||||
|
||||
**SSE Event Handling:**
|
||||
```typescript
|
||||
// services/controlPointSSE.ts
|
||||
class ControlPointSSE {
|
||||
private eventSource: EventSource
|
||||
private renderersStore: ReturnType<typeof useRenderersStore>
|
||||
|
||||
connect() {
|
||||
this.eventSource = new EventSource('/api/control/events')
|
||||
|
||||
this.eventSource.addEventListener('control', (e) => {
|
||||
const event = JSON.parse(e.data)
|
||||
|
||||
if (event.category === 'renderer') {
|
||||
this.handleRendererEvent(event)
|
||||
} else if (event.category === 'media_server') {
|
||||
this.handleServerEvent(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleRendererEvent(event: RendererEventPayload) {
|
||||
switch (event.type) {
|
||||
case 'state_changed':
|
||||
this.renderersStore.updateState(event.renderer_id, event.state)
|
||||
break
|
||||
case 'volume_changed':
|
||||
this.renderersStore.updateVolume(event.renderer_id, event.volume)
|
||||
break
|
||||
// ... handle all event types
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Store Integration:**
|
||||
```typescript
|
||||
// stores/renderers.ts
|
||||
export const useRenderersStore = defineStore('renderers', () => {
|
||||
const renderers = ref<Map<string, RendererState>>(new Map())
|
||||
|
||||
// SSE updates
|
||||
function updateState(id: string, state: string) {
|
||||
const renderer = renderers.value.get(id)
|
||||
if (renderer) {
|
||||
renderer.transport_state = state
|
||||
}
|
||||
}
|
||||
|
||||
// REST API calls
|
||||
async function play(id: string) {
|
||||
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
|
||||
// SSE will update state automatically
|
||||
}
|
||||
|
||||
return { renderers, updateState, play }
|
||||
})
|
||||
```
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
**For 20+ concurrent clients:**
|
||||
|
||||
1. **Throttle position updates**:
|
||||
```typescript
|
||||
const throttledPositionUpdate = throttle((id, pos) => {
|
||||
store.updatePosition(id, pos)
|
||||
}, 1000) // Max 1 update/second
|
||||
```
|
||||
|
||||
2. **Virtual scrolling** for renderer lists:
|
||||
```bash
|
||||
npm install vue-virtual-scroller
|
||||
```
|
||||
|
||||
3. **Lazy load album art**:
|
||||
```vue
|
||||
<img :src="albumArt" loading="lazy" />
|
||||
```
|
||||
|
||||
4. **Debounce volume sliders**:
|
||||
```typescript
|
||||
const debouncedVolumeChange = debounce((id, vol) => {
|
||||
api.setVolume(id, vol)
|
||||
}, 300)
|
||||
```
|
||||
|
||||
5. **Memoize computed properties**:
|
||||
```typescript
|
||||
const activeRenderers = computed(() =>
|
||||
renderers.value.filter(r => r.online)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Infrastructure (Week 1)
|
||||
1. Install Pinia + configure stores
|
||||
2. Install Lucide Icons
|
||||
3. Create SSE service layer
|
||||
4. Setup store structure (renderers, servers, playback, ui)
|
||||
5. Connect SSE events to stores
|
||||
|
||||
### Phase 2: UI Components (Week 2)
|
||||
5. Build RendererCard component (status-colored)
|
||||
6. Build TransportControls component
|
||||
7. Build VolumeControl component
|
||||
8. Build QueueViewer component
|
||||
9. Create responsive grid layouts
|
||||
|
||||
### Phase 3: Dashboard Assembly (Week 3)
|
||||
10. Create PMOControl home view
|
||||
11. Integrate all components
|
||||
12. Add media server browser panel
|
||||
13. Implement responsive breakpoints
|
||||
14. Add loading states & error handling
|
||||
|
||||
### Phase 4: Polish & Testing (Week 4)
|
||||
15. Test with 20+ concurrent clients
|
||||
16. Performance profiling & optimization
|
||||
17. Accessibility audit (ARIA, keyboard nav)
|
||||
18. Cross-browser testing
|
||||
19. Mobile/tablet testing
|
||||
20. Documentation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Install
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.8",
|
||||
"lucide-vue-next": "^0.470.0",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
// Already installed: vue, vue-router, typescript, vite
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
|
||||
|
||||
---
|
||||
|
||||
## Status-based Coloring Scheme
|
||||
|
||||
Based on "carte uniforme, responsive, colorée selon statut" spec:
|
||||
|
||||
```css
|
||||
/* Renderer Card Status Colors */
|
||||
.renderer-card.playing {
|
||||
border-color: #22c55e; /* green */
|
||||
background: linear-gradient(135deg, #22c55e10, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.paused {
|
||||
border-color: #f59e0b; /* amber */
|
||||
background: linear-gradient(135deg, #f59e0b10, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.stopped {
|
||||
border-color: #6b7280; /* gray */
|
||||
background: linear-gradient(135deg, #6b728010, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
border-color: #ef4444; /* red */
|
||||
background: linear-gradient(135deg, #ef444410, transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.renderer-card.transitioning {
|
||||
border-color: #3b82f6; /* blue */
|
||||
background: linear-gradient(135deg, #3b82f610, transparent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Answers to Your Specific Questions
|
||||
|
||||
### 1. State Management?
|
||||
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
|
||||
|
||||
### 2. UI Component Library?
|
||||
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
|
||||
|
||||
### 3. Keep existing components?
|
||||
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
|
||||
|
||||
### 4. Responsive breakpoints?
|
||||
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
|
||||
|
||||
### 5. Icons?
|
||||
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
**Potential challenges:**
|
||||
|
||||
1. **SSE connection management across tabs**
|
||||
- Solution: Use BroadcastChannel API for cross-tab sync
|
||||
- Fallback: LocalStorage events
|
||||
|
||||
2. **20+ renderers performance**
|
||||
- Solution: Virtual scrolling + throttled updates
|
||||
- Monitor: Chrome DevTools Performance profiler
|
||||
|
||||
3. **Network reliability (SSE reconnection)**
|
||||
- Solution: Exponential backoff reconnection
|
||||
- UI indicator for connection status
|
||||
|
||||
4. **Album art loading (CORS, 404s)**
|
||||
- Solution: Proxy through backend
|
||||
- Fallback: Default placeholder image
|
||||
|
||||
5. **Browser compatibility (SSE support)**
|
||||
- Chrome/Edge: Native support ✅
|
||||
- Firefox: Native support ✅
|
||||
- Safari: Native support ✅
|
||||
- IE11: Use EventSource polyfill
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Performance targets:**
|
||||
- Initial load: <2s (FCP)
|
||||
- SSE event latency: <100ms
|
||||
- UI interaction: <16ms (60fps)
|
||||
- Memory usage: <50MB with 20 renderers
|
||||
- Bundle size: <250KB gzipped
|
||||
|
||||
**Functionality checklist:**
|
||||
- [ ] Display all discovered renderers in real-time
|
||||
- [ ] Show accurate playback state (play/pause/stop)
|
||||
- [ ] Volume control works across all renderer types
|
||||
- [ ] Queue display syncs with server
|
||||
- [ ] Media server browsing functional
|
||||
- [ ] Playlist attachment working
|
||||
- [ ] Responsive on mobile/tablet/desktop
|
||||
- [ ] Accessible (WCAG AA compliance)
|
||||
- [ ] 20+ concurrent clients supported
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review & approve** this plan with stakeholders
|
||||
2. **Clarify any ambiguities** in requirements
|
||||
3. **Set up development environment** (install dependencies)
|
||||
4. **Begin Phase 1** (Core Infrastructure)
|
||||
|
||||
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?
|
||||
243
UPNP_ANALYSIS_REPORT.md
Normal file
243
UPNP_ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
|
||||
|
||||
**Date:** 2025-11-26
|
||||
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||
|
||||
## Résumé Exécutif
|
||||
|
||||
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
|
||||
|
||||
## Découverte Réseau
|
||||
|
||||
### Devices UPnP Détectés
|
||||
|
||||
| Device | IP | USN | Status |
|
||||
|--------|------|-----|---------|
|
||||
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
|
||||
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
|
||||
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
|
||||
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
|
||||
|
||||
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
|
||||
|
||||
## Comparaison des Descripteurs XML
|
||||
|
||||
### PMO Music MediaServer
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>PMOMusic Media Server</friendlyName>
|
||||
<manufacturer>PMOMusic</manufacturer>
|
||||
<modelName>PMOMusic Media Server</modelName>
|
||||
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
|
||||
<!-- ❌ Pas d'iconList -->
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
|
||||
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
|
||||
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
...
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Upmpdcli (Fonctionnel)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>1</minor> <!-- ✅ Version 1.1 -->
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
|
||||
<modelName>Upmpdcli Media Server</modelName>
|
||||
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
|
||||
<iconList> <!-- ✅ Présence d'icônes -->
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>64</width>
|
||||
<height>64</height>
|
||||
<depth>32</depth>
|
||||
<url>/uuid-.../icon.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
|
||||
<serviceList>
|
||||
<!-- Mêmes services -->
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Différences Clés dans le Descripteur
|
||||
|
||||
| Élément | PMO Music | Upmpdcli | Impact |
|
||||
|---------|-----------|----------|---------|
|
||||
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
|
||||
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
|
||||
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
|
||||
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
|
||||
|
||||
## Comparaison des Réponses SOAP
|
||||
|
||||
### Test 1: ConnectionManager::GetProtocolInfo
|
||||
|
||||
#### PMO Music
|
||||
```http
|
||||
Status: 200 OK
|
||||
Content-Type: (absent) ⚠️ PROBLÈME CRITIQUE
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||
<Source></Source> ⚠️ Vide
|
||||
<Sink></Sink> ⚠️ Vide
|
||||
</u:GetProtocolInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
#### Upmpdcli
|
||||
```http
|
||||
Status: 200 OK
|
||||
Content-Type: text/xml; charset="utf-8" ✅ Présent
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||
<Source></Source>
|
||||
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> ✅ Formats listés
|
||||
</u:GetProtocolInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
### Test 2: ContentDirectory::Browse
|
||||
|
||||
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
|
||||
|
||||
## Problèmes Identifiés par Ordre de Criticité
|
||||
|
||||
### 🔴 CRITIQUE
|
||||
|
||||
1. **Absence du header Content-Type dans les réponses SOAP**
|
||||
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
|
||||
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
|
||||
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
|
||||
- **Fichiers à vérifier:**
|
||||
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
|
||||
- `pmoupnp/src/soap/builder.rs`
|
||||
|
||||
2. **ProtocolInfo vide pour Source et Sink**
|
||||
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
|
||||
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
|
||||
- **Action:** Implémenter la liste des formats dans ConnectionManager
|
||||
|
||||
### 🟡 MOYEN
|
||||
|
||||
3. **specVersion 1.0 au lieu de 1.1**
|
||||
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
|
||||
- **Solution:** Passer à specVersion 1.1
|
||||
|
||||
4. **Absence d'iconList**
|
||||
- **Impact:** Pas d'icône visible dans les clients UPnP
|
||||
- **Solution:** Ajouter au moins une icône PNG 64x64
|
||||
|
||||
### 🟢 FAIBLE
|
||||
|
||||
5. **Ordre des éléments XML différent**
|
||||
- **Impact:** Minimal - XML valide dans tous les cas
|
||||
- **Action:** Optionnel - standardiser l'ordre
|
||||
|
||||
## Recommandations d'Implémentation
|
||||
|
||||
### Priorité 1: Corriger le Content-Type
|
||||
|
||||
Localiser le code qui génère les réponses SOAP et ajouter le header:
|
||||
|
||||
```rust
|
||||
// Dans pmoupnp/src/services/service_instance.rs ou similaire
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
|
||||
xml
|
||||
)
|
||||
```
|
||||
|
||||
### Priorité 2: Implémenter GetProtocolInfo correctement
|
||||
|
||||
Dans ConnectionManager, retourner la liste des formats supportés:
|
||||
|
||||
```rust
|
||||
// Exemple de formats à supporter
|
||||
let sink_protocols = vec![
|
||||
"http-get:*:audio/flac:*",
|
||||
"http-get:*:audio/mpeg:*",
|
||||
"http-get:*:audio/mp4:*",
|
||||
"http-get:*:audio/ogg:*",
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
### Priorité 3: Passer à UPnP 1.1
|
||||
|
||||
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
|
||||
|
||||
### Priorité 4: Ajouter une icône
|
||||
|
||||
Créer une icône PNG 64x64 et l'ajouter au descripteur:
|
||||
|
||||
```xml
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>64</width>
|
||||
<height>64</height>
|
||||
<depth>32</depth>
|
||||
<url>/icon.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
```
|
||||
|
||||
## Fichiers à Modifier
|
||||
|
||||
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
|
||||
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
|
||||
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
|
||||
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
|
||||
|
||||
## Tests de Validation
|
||||
|
||||
Après les corrections, vérifier:
|
||||
|
||||
1. ✅ `curl` sur le descripteur montre specVersion 1.1 et iconList
|
||||
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
|
||||
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
|
||||
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
|
||||
|
||||
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.
|
||||
128
UPNP_FIX_SUMMARY.md
Normal file
128
UPNP_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Résolution du Problème UPnP - PMO Music MediaServer
|
||||
|
||||
**Date:** 2025-11-26
|
||||
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||
|
||||
## Diagnostic
|
||||
|
||||
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
|
||||
|
||||
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
|
||||
|
||||
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
|
||||
|
||||
### Réponse AVANT la correction :
|
||||
|
||||
```xml
|
||||
<u:GetProtocolInfoResponse>
|
||||
<Source></Source> <!-- ❌ VIDE -->
|
||||
<Sink></Sink>
|
||||
</u:GetProtocolInfoResponse>
|
||||
```
|
||||
|
||||
## Solution Implémentée
|
||||
|
||||
### 1. Nouveau Module : `device_ext.rs`
|
||||
|
||||
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
|
||||
|
||||
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
|
||||
|
||||
```rust
|
||||
pub trait MediaServerDeviceExt {
|
||||
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
|
||||
///
|
||||
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
|
||||
fn init_protocol_info(&self);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Formats Supportés
|
||||
|
||||
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
|
||||
|
||||
- `http-get:*:audio/flac:*` - FLAC standard
|
||||
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
|
||||
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
|
||||
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
|
||||
- `http-get:*:application/ogg:*` - OGG-FLAC
|
||||
- `http-get:*:audio/ogg:*` - OGG-FLAC
|
||||
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
|
||||
|
||||
### 3. Intégration dans `main.rs`
|
||||
|
||||
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
|
||||
|
||||
```rust
|
||||
use pmomediaserver::MediaServerDeviceExt;
|
||||
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// ✅ Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
```
|
||||
|
||||
### 4. Export dans `lib.rs`
|
||||
|
||||
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
|
||||
|
||||
```rust
|
||||
pub mod device_ext;
|
||||
pub use device_ext::MediaServerDeviceExt;
|
||||
```
|
||||
|
||||
## Réponse APRÈS la correction
|
||||
|
||||
```xml
|
||||
<u:GetProtocolInfoResponse>
|
||||
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
|
||||
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
|
||||
</u:GetProtocolInfoResponse>
|
||||
```
|
||||
|
||||
## Fichiers Modifiés
|
||||
|
||||
1. ✅ **Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
|
||||
2. ✅ **Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
|
||||
3. ✅ **Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
|
||||
|
||||
## Test de Validation
|
||||
|
||||
Après redémarrage du serveur PMO Music, vérifier avec :
|
||||
|
||||
```bash
|
||||
python3 tools/test_soap.py
|
||||
```
|
||||
|
||||
Ou directement :
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: text/xml" \
|
||||
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
|
||||
-d '<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>' \
|
||||
http://localhost:8080/device/.../service/ConnectionManager/control
|
||||
```
|
||||
|
||||
## Prochaines Étapes
|
||||
|
||||
1. ✅ Redémarrer le serveur PMO Music
|
||||
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
|
||||
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
|
||||
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
|
||||
|
||||
## Références
|
||||
|
||||
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
|
||||
- UPnP AV Architecture Specification :
|
||||
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||
BIN
bubble_upmpdcli.pcap
Normal file
BIN
bubble_upmpdcli.pcap
Normal file
Binary file not shown.
431
media_
Normal file
431
media_
Normal file
@@ -0,0 +1,431 @@
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoserver/src/config_ext.rs:35:5
|
||||
|
|
||||
35 | async fn init_config_api(&mut self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
35 - async fn init_config_api(&mut self) -> Result<()>;
|
||||
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoserver` (lib) generated 1 warning
|
||||
warning: variable does not need to be mutable
|
||||
--> pmocache/src/cache.rs:604:9
|
||||
|
|
||||
604 | mut reader: R,
|
||||
| ----^^^^^^
|
||||
| |
|
||||
| help: remove this `mut`
|
||||
|
|
||||
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:97:5
|
||||
|
|
||||
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:111:5
|
||||
|
|
||||
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:118:5
|
||||
|
|
||||
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:125:5
|
||||
|
|
||||
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:128:5
|
||||
|
|
||||
128 | async fn purge(&self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
128 - async fn purge(&self) -> Result<()>;
|
||||
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:131:5
|
||||
|
|
||||
131 | async fn consolidate(&self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
131 - async fn consolidate(&self) -> Result<()>;
|
||||
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:147:5
|
||||
|
|
||||
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
|
||||
148 | if self.get_database().get(pk, false).is_err() {
|
||||
...
|
||||
218 | false
|
||||
219 ~ } }
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/pmoserver_ext.rs:391:5
|
||||
|
|
||||
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
|
||||
392 | &mut self,
|
||||
...
|
||||
395 | content_type: &'static str,
|
||||
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
|
||||
warning: unused import: `serde_json::Value`
|
||||
--> pmoaudiocache/src/cache.rs:11:5
|
||||
|
|
||||
11 | use serde_json::Value;
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unused import: `pmometadata::TrackMetadata`
|
||||
--> pmoaudiocache/src/api.rs:11:5
|
||||
|
|
||||
11 | use pmometadata::TrackMetadata;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/lib.rs:188:5
|
||||
|
|
||||
188 | async fn init_audio_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
188 ~ fn init_audio_cache(
|
||||
189 | &mut self,
|
||||
190 | cache_dir: &str,
|
||||
191 | limit: usize,
|
||||
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/lib.rs:197:5
|
||||
|
|
||||
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
||||
|
|
||||
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
||||
|
|
||||
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
||||
|
|
||||
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
||||
|
|
||||
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
|
||||
warning: unused import: `tokio_stream::StreamExt`
|
||||
--> pmoplaylist/src/sse.rs:16:5
|
||||
|
|
||||
16 | use tokio_stream::StreamExt;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
|
||||
--> pmoplaylist/src/persistence/mod.rs:219:18
|
||||
|
|
||||
17 | impl PersistenceManager {
|
||||
| ----------------------- methods in this implementation
|
||||
...
|
||||
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: function `PlaylistManager` should have a snake case name
|
||||
--> pmoplaylist/src/manager.rs:620:8
|
||||
|
|
||||
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
|
||||
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
||||
|
|
||||
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
|
||||
|
||||
warning: `pmoplaylist` (lib) generated 3 warnings
|
||||
warning: unused variable: `base_url`
|
||||
--> pmoupnp/src/upnp_server.rs:306:13
|
||||
|
|
||||
306 | let base_url = self.info().base_url.clone();
|
||||
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
||||
|
|
||||
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_api.rs:229:5
|
||||
|
|
||||
229 | async fn register_upnp_api(&mut self);
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
229 - async fn register_upnp_api(&mut self);
|
||||
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:96:5
|
||||
|
|
||||
96 | async fn register_device(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
96 ~ fn register_device(
|
||||
97 | &mut self,
|
||||
98 | device: Arc<Device>,
|
||||
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:125:5
|
||||
|
|
||||
125 | async fn init_cover_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
125 ~ fn init_cover_cache(
|
||||
126 | &mut self,
|
||||
127 | cache_dir: &str,
|
||||
128 | limit: usize,
|
||||
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:144:5
|
||||
|
|
||||
144 | async fn init_audio_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
144 ~ fn init_audio_cache(
|
||||
145 | &mut self,
|
||||
146 | cache_dir: &str,
|
||||
147 | limit: usize,
|
||||
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:158:5
|
||||
|
|
||||
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:225:5
|
||||
|
|
||||
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoupnp` (lib) generated 7 warnings
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
|
||||
Running `target/debug/examples/media_server_events_demo`
|
||||
ControlPoint started; waiting 5s for discovery...
|
||||
Discovered media servers:
|
||||
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
|
||||
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
|
||||
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
|
||||
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
|
||||
Listening for ContentDirectory events for 90 seconds...
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
Monitoring finished.
|
||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -36,18 +36,44 @@ impl WebAppExt for Server {
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
let mount_path = normalize_mount_path(path);
|
||||
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||
}
|
||||
|
||||
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
let mount_path = normalize_mount_path(path);
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
self.add_redirect("/", &path).await;
|
||||
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||
self.add_redirect("/", &mount_path).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// S'assure que les chemins SPA sont cohérents : `"/app"` devient `"/app"`,
|
||||
/// tandis que `"/"` reste tel quel. Les espaces ou slashs multiples sont
|
||||
/// nettoyés pour éviter des routes dupliquées.
|
||||
fn normalize_mount_path(path: &str) -> String {
|
||||
let trimmed = path.trim();
|
||||
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", trimmed.trim_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Monte la SPA et ajoute automatiquement une redirection `"/app/" -> "/app"`
|
||||
/// afin que les URLs avec slash final servent également l'application.
|
||||
async fn mount_spa_with_trailing_slash_redirect<W>(server: &mut Server, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
server.add_spa::<W>(path).await;
|
||||
|
||||
if path != "/" {
|
||||
let trailing = format!("{}/", path.trim_end_matches('/'));
|
||||
server.add_redirect(&trailing, path).await;
|
||||
}
|
||||
}
|
||||
|
||||
191
pmoapp/webapp/package-lock.json
generated
191
pmoapp/webapp/package-lock.json
generated
@@ -9,9 +9,12 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -957,6 +960,36 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit/node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
|
||||
@@ -1057,6 +1090,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -1165,6 +1222,33 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz",
|
||||
"integrity": "sha512-7hczPsiMD/y+VNLpal5Q5Wv09kQxlHS0l/cM1xagrd+MA3i5umMm+PUXqllvsbgwAl3PHv27fo59h4PN02GM5A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
@@ -1186,6 +1270,12 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
||||
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -1218,6 +1308,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1230,6 +1326,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1237,6 +1334,36 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1265,6 +1392,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
@@ -1316,6 +1449,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -1339,6 +1493,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1353,6 +1508,7 @@
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1434,6 +1590,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
@@ -1450,6 +1607,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-observe-visibility": {
|
||||
"version": "2.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz",
|
||||
"integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-resize": {
|
||||
"version": "2.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
|
||||
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
@@ -1481,6 +1656,20 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-virtual-scroller": {
|
||||
"version": "2.0.0-beta.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz",
|
||||
"integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mitt": "^2.1.0",
|
||||
"vue-observe-visibility": "^2.0.0-alpha.1",
|
||||
"vue-resize": "^2.0.0-alpha.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<nav class="main-nav">
|
||||
<router-link to="/">🏠 Accueil</router-link>
|
||||
<router-link to="/" class="nav-logo">PMOControl</router-link>
|
||||
|
||||
<!-- Menu déroulant Debug -->
|
||||
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
||||
@@ -10,32 +10,37 @@
|
||||
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
||||
</button>
|
||||
<div v-show="showDebugMenu" class="dropdown-menu">
|
||||
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||
<router-link to="/debug/generic-player" @click="showDebugMenu = false">🎵 Generic Player</router-link>
|
||||
<router-link to="/debug/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/debug/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/debug/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||
<router-link to="/debug/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||
<router-link to="/debug/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||
|
||||
<div class="submenu-divider">Sources</div>
|
||||
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
||||
<router-link to="/debug/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NotificationToast from '@/components/NotificationToast.vue'
|
||||
|
||||
const showDebugMenu = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const isDebugRoute = computed(() => {
|
||||
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
|
||||
return route.path.startsWith('/debug/')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,6 +68,25 @@ const isDebugRoute = computed(() => {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #fff !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.nav-logo:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
color: #eee;
|
||||
padding: 0.5rem 1rem;
|
||||
@@ -77,7 +101,7 @@ const isDebugRoute = computed(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main-nav a.router-link-active {
|
||||
.main-nav a.router-link-active:not(.nav-logo) {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
|
||||
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
@@ -0,0 +1,436 @@
|
||||
/* Styles PMOControl spécifiques */
|
||||
@import './variables.css';
|
||||
|
||||
/* ========================================
|
||||
Reset & Base Styles
|
||||
======================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets on mobile */
|
||||
@media (max-width: 768px) {
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Status Badge Styles
|
||||
======================================== */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge.playing {
|
||||
background-color: var(--status-playing-bg);
|
||||
color: var(--status-playing);
|
||||
border: 1px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.status-badge.paused {
|
||||
background-color: var(--status-paused-bg);
|
||||
color: var(--status-paused);
|
||||
border: 1px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background-color: var(--status-stopped-bg);
|
||||
color: var(--status-stopped);
|
||||
border: 1px solid var(--status-stopped);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background-color: var(--status-offline-bg);
|
||||
color: var(--status-offline);
|
||||
border: 1px solid var(--status-offline);
|
||||
}
|
||||
|
||||
.status-badge.transitioning {
|
||||
background-color: var(--status-transitioning-bg);
|
||||
color: var(--status-transitioning);
|
||||
border: 1px solid var(--status-transitioning);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Card Styles
|
||||
======================================== */
|
||||
.pmo-card {
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.pmo-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pmo-card.active {
|
||||
border-color: var(--status-playing);
|
||||
box-shadow: 0 0 0 2px var(--status-playing-bg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Renderer Card Status Borders
|
||||
======================================== */
|
||||
.renderer-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.renderer-card.playing {
|
||||
border-left: 4px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.renderer-card.paused {
|
||||
border-left: 4px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.renderer-card.stopped {
|
||||
border-left: 4px solid var(--status-stopped);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
border-left: 4px solid var(--status-offline);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Queue Item Styles
|
||||
======================================== */
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.queue-item.current {
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.queue-item.current::before {
|
||||
content: '▶';
|
||||
color: var(--status-playing);
|
||||
font-size: var(--text-lg);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Button Styles
|
||||
======================================== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--status-playing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--spacing-sm);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Grid Layouts (Responsive)
|
||||
======================================== */
|
||||
.grid-cards {
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Tablet: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.grid-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: 3 columns */
|
||||
@media (min-width: 1024px) {
|
||||
.grid-cards {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Performance Optimizations
|
||||
======================================== */
|
||||
/* Use will-change for frequently animated elements */
|
||||
.progress-bar-fill,
|
||||
input[type="range"]::-webkit-slider-thumb,
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.pmo-card:hover {
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
/* GPU acceleration for smoother animations */
|
||||
.fade-in,
|
||||
.spin,
|
||||
.status-badge.transitioning {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Volume Slider
|
||||
======================================== */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--status-playing);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--status-playing);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Progress Bar (Seekbar)
|
||||
======================================== */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--status-playing);
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Notification Toast
|
||||
======================================== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.toast-item.success {
|
||||
background-color: var(--status-playing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
background-color: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.warning {
|
||||
background-color: var(--status-paused);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.info {
|
||||
background-color: var(--status-transitioning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Utilities
|
||||
======================================== */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Variables CSS globales pour PMOControl */
|
||||
|
||||
:root {
|
||||
/* ========================================
|
||||
Status Colors
|
||||
======================================== */
|
||||
--status-playing: #22c55e; /* Vert pour en lecture */
|
||||
--status-playing-bg: #22c55e15; /* Background playing */
|
||||
--status-paused: #f59e0b; /* Orange pour en pause */
|
||||
--status-paused-bg: #f59e0b15; /* Background paused */
|
||||
--status-stopped: #6b7280; /* Gris pour arrêté */
|
||||
--status-stopped-bg: #6b728015; /* Background stopped */
|
||||
--status-offline: #ef4444; /* Rouge pour offline */
|
||||
--status-offline-bg: #ef444415; /* Background offline */
|
||||
--status-transitioning: #3b82f6; /* Bleu pour transition */
|
||||
--status-transitioning-bg: #3b82f615; /* Background transitioning */
|
||||
|
||||
/* ========================================
|
||||
Breakpoints (pour media queries)
|
||||
======================================== */
|
||||
--breakpoint-mobile: 768px;
|
||||
--breakpoint-tablet: 1024px;
|
||||
|
||||
/* ========================================
|
||||
Spacing Scale
|
||||
======================================== */
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
|
||||
/* ========================================
|
||||
Border Radius
|
||||
======================================== */
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 1rem; /* 16px */
|
||||
--radius-full: 9999px; /* Pill shape */
|
||||
|
||||
/* ========================================
|
||||
Shadows
|
||||
======================================== */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ========================================
|
||||
Typography
|
||||
======================================== */
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
/* ========================================
|
||||
Colors (neutral palette)
|
||||
======================================== */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f3f4f6;
|
||||
--color-bg-tertiary: #e5e7eb;
|
||||
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #d1d5db;
|
||||
--color-border-light: #e5e7eb;
|
||||
|
||||
/* Primary color (brand) */
|
||||
--color-primary: #667eea;
|
||||
--color-primary-hover: #5568d3;
|
||||
|
||||
/* ========================================
|
||||
Transitions
|
||||
======================================== */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-base: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
--z-tooltip: 400;
|
||||
}
|
||||
|
||||
/* Dark mode support (optionnel pour l'avenir) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #4b5563;
|
||||
--color-border-light: #374151;
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,7 @@
|
||||
</div>
|
||||
|
||||
<div class="api-body">
|
||||
<p v-if="api.description" class="api-description">
|
||||
{{ api.description }}
|
||||
</p>
|
||||
<div v-if="api.description" class="api-description" v-html="renderMarkdown(api.description)"></div>
|
||||
<p v-else class="api-description empty">Aucune description disponible</p>
|
||||
|
||||
<div class="api-stats">
|
||||
@@ -86,6 +84,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Configurer marked pour un rendu simple
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
interface ApiRegistryEntry {
|
||||
name: string;
|
||||
@@ -142,6 +148,14 @@ function getApiIcon(name: string): string {
|
||||
return icons[name.toLowerCase()] || '🔧';
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const rawHtml = marked.parse(markdown, { async: false }) as string;
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRegistry();
|
||||
});
|
||||
@@ -332,6 +346,66 @@ onMounted(() => {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Styles pour le contenu markdown rendu */
|
||||
.api-description :deep(p) {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.api-description :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.api-description :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.api-description :deep(code) {
|
||||
background: #f7fafc;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
color: #d63384;
|
||||
}
|
||||
|
||||
.api-description :deep(pre) {
|
||||
background: #f7fafc;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.api-description :deep(ul),
|
||||
.api-description :deep(ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.api-description :deep(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(a) {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.api-description :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.api-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -76,7 +76,14 @@
|
||||
@click="selectedTrack = track"
|
||||
>
|
||||
<div class="track-icon">
|
||||
<div class="music-icon">🎵</div>
|
||||
<img
|
||||
v-if="getTrackCoverUrl(track, 400)"
|
||||
:src="getTrackCoverUrl(track, 400)"
|
||||
:alt="`Cover for ${track.metadata?.title || 'Unknown'}`"
|
||||
class="cover-image"
|
||||
@error="handleCoverError(track.pk)"
|
||||
/>
|
||||
<div v-else class="music-icon">🎵</div>
|
||||
<div class="track-overlay">
|
||||
<span class="hits">{{ track.hits }} plays</span>
|
||||
</div>
|
||||
@@ -138,7 +145,14 @@
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedTrack = null">✕</button>
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon">🎵</div>
|
||||
<img
|
||||
v-if="getTrackCoverUrl(selectedTrack, 200)"
|
||||
:src="getTrackCoverUrl(selectedTrack, 200)"
|
||||
:alt="`Cover for ${selectedTrack.metadata?.title || 'Unknown'}`"
|
||||
class="modal-cover-image"
|
||||
@error="handleCoverError(selectedTrack.pk)"
|
||||
/>
|
||||
<div v-else class="modal-icon">🎵</div>
|
||||
<h3>Track Details</h3>
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
@@ -221,6 +235,7 @@ import {
|
||||
formatDuration,
|
||||
formatBitrate,
|
||||
formatSampleRate,
|
||||
getCoverUrl,
|
||||
} from "../services/audioCache";
|
||||
|
||||
// --- États ---
|
||||
@@ -246,6 +261,9 @@ const deletingTracks = ref(new Set<string>());
|
||||
const isPlaying = ref(false);
|
||||
const audioError = ref("");
|
||||
|
||||
// Gestion des erreurs de chargement des covers
|
||||
const failedCovers = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
|
||||
|
||||
@@ -449,6 +467,15 @@ function conversionLabel(track: AudioCacheEntry | null): string | undefined {
|
||||
return formatConversion(track?.metadata?.conversion ?? undefined);
|
||||
}
|
||||
|
||||
function getTrackCoverUrl(track: AudioCacheEntry | null, size?: number): string | undefined {
|
||||
if (!track || failedCovers.value.has(track.pk)) return undefined;
|
||||
return getCoverUrl(track.metadata, size);
|
||||
}
|
||||
|
||||
function handleCoverError(pk: string) {
|
||||
failedCovers.value.add(pk);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshTracks();
|
||||
});
|
||||
@@ -692,6 +719,15 @@ button:disabled {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.music-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -838,6 +874,14 @@ button:disabled {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-cover-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@
|
||||
>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(image.pk, 256)"
|
||||
:src="getImageUrlWithFallback(image.pk, 256)"
|
||||
:alt="resolveOrigin(image) || image.pk"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
@error="(e) => handleImageError(image.pk, e)"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<span class="hits">👁️ {{ image.hits }}</span>
|
||||
@@ -107,9 +107,10 @@
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||
<img
|
||||
:src="getImageUrl(selectedImage.pk)"
|
||||
:src="getImageUrlWithFallback(selectedImage.pk)"
|
||||
:alt="resolveOrigin(selectedImage) || selectedImage.pk"
|
||||
class="modal-image"
|
||||
@error="(e) => selectedImage && handleImageError(selectedImage.pk, e)"
|
||||
/>
|
||||
<div class="modal-info">
|
||||
<h3>Image Details</h3>
|
||||
@@ -121,6 +122,10 @@
|
||||
<p v-else><strong>Source URL:</strong> Unknown</p>
|
||||
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||
<div class="links">
|
||||
<p><strong>WebP:</strong> <a :href="getImageUrl(selectedImage.pk)" target="_blank">{{ getImageUrl(selectedImage.pk) }}</a></p>
|
||||
<p><strong>JPEG:</strong> <a :href="getJpegUrl(selectedImage.pk)" target="_blank">{{ getJpegUrl(selectedImage.pk) }}</a></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||
📋 Copy URL
|
||||
@@ -145,8 +150,10 @@ import {
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getImageUrl,
|
||||
getJpegUrl,
|
||||
getOriginUrl,
|
||||
waitForDownload,
|
||||
getDefaultImageUrl,
|
||||
} from "../services/coverCache";
|
||||
|
||||
// --- États ---
|
||||
@@ -166,6 +173,9 @@ const isConsolidating = ref(false);
|
||||
const isPurging = ref(false);
|
||||
const deletingImages = ref(new Set<string>());
|
||||
|
||||
// Gestion des erreurs de chargement d'images
|
||||
const failedImages = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||
|
||||
@@ -230,8 +240,10 @@ async function handleConsolidate(){
|
||||
}
|
||||
|
||||
function copyImageUrl(pk:string){
|
||||
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
|
||||
alert("✅ URL copied!");
|
||||
const webpUrl = window.location.origin + getImageUrl(pk);
|
||||
const jpegUrl = window.location.origin + getJpegUrl(pk);
|
||||
navigator.clipboard.writeText(`${webpUrl}\n${jpegUrl}`);
|
||||
alert("✅ URLs copied (WebP + JPEG)!");
|
||||
}
|
||||
|
||||
function resolveOrigin(entry: CacheEntry | null): string | undefined {
|
||||
@@ -246,7 +258,18 @@ function formatDate(dateString:string){
|
||||
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||
}
|
||||
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
|
||||
|
||||
function getImageUrlWithFallback(pk: string, size?: number): string {
|
||||
if (failedImages.value.has(pk)) {
|
||||
return getDefaultImageUrl();
|
||||
}
|
||||
return getImageUrl(pk, size);
|
||||
}
|
||||
|
||||
function handleImageError(pk: string, event: Event) {
|
||||
failedImages.value.add(pk);
|
||||
(event.target as HTMLImageElement).src = getDefaultImageUrl();
|
||||
}
|
||||
|
||||
onMounted(()=>refreshImages());
|
||||
</script>
|
||||
|
||||
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { AlertCircle, CheckCircle, Info, X, AlertTriangle } from 'lucide-vue-next'
|
||||
import type { Notification } from '@/stores/ui'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
|
||||
function removeNotification(id: string) {
|
||||
uiStore.removeNotification(id)
|
||||
}
|
||||
|
||||
function getIcon(type: Notification['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return CheckCircle
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeClass(type: Notification['type']) {
|
||||
return `notification-${type}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-container">
|
||||
<TransitionGroup name="notification-list">
|
||||
<div
|
||||
v-for="notification in uiStore.notifications"
|
||||
:key="notification.id"
|
||||
:class="['notification', getTypeClass(notification.type)]"
|
||||
>
|
||||
<component :is="getIcon(notification.type)" :size="20" class="notification-icon" />
|
||||
<p class="notification-message">{{ notification.message }}</p>
|
||||
<button
|
||||
class="notification-close"
|
||||
@click="removeNotification(notification.id)"
|
||||
title="Fermer"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Type-specific styles */
|
||||
.notification-success {
|
||||
border-left: 4px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.notification-success .notification-icon {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
border-left: 4px solid var(--status-offline);
|
||||
}
|
||||
|
||||
.notification-error .notification-icon {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
border-left: 4px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.notification-warning .notification-icon {
|
||||
color: var(--status-paused);
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
border-left: 4px solid var(--status-transitioning);
|
||||
}
|
||||
|
||||
.notification-info .notification-icon {
|
||||
color: var(--status-transitioning);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.notification-list-enter-active,
|
||||
.notification-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.notification-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 767px) {
|
||||
.notification-container {
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
left: var(--spacing-md);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
type: 'container' | 'item'
|
||||
entryId: string
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
playNow: [rendererId: string]
|
||||
addToQueue: [rendererId: string]
|
||||
}>()
|
||||
|
||||
const { onlineRenderers } = useRenderers()
|
||||
const showMenu = ref(false)
|
||||
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
||||
|
||||
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
||||
showMenu.value = false
|
||||
showRendererSubmenu.value = null
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
emit('playNow', rendererId)
|
||||
break
|
||||
case 'queue':
|
||||
emit('addToQueue', rendererId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
showMenu.value = !showMenu.value
|
||||
if (!showMenu.value) {
|
||||
showRendererSubmenu.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-menu-container" @click.stop>
|
||||
<button class="action-menu-trigger" @click="toggleMenu" :title="'Actions'">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<div v-if="showMenu" class="action-menu" @click.stop>
|
||||
<!-- Play Now -->
|
||||
<div
|
||||
class="menu-item"
|
||||
@mouseenter="showRendererSubmenu = 'play'"
|
||||
@mouseleave="showRendererSubmenu = null"
|
||||
>
|
||||
<Play :size="16" />
|
||||
<span>Lire maintenant</span>
|
||||
<ChevronRight :size="16" class="submenu-arrow" />
|
||||
|
||||
<!-- Submenu renderers -->
|
||||
<div v-if="showRendererSubmenu === 'play'" class="renderer-submenu">
|
||||
<div
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="submenu-item"
|
||||
@click="handleAction('play', renderer.id)"
|
||||
>
|
||||
{{ renderer.friendly_name }}
|
||||
</div>
|
||||
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||
Aucun renderer disponible
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Queue -->
|
||||
<div
|
||||
class="menu-item"
|
||||
@mouseenter="showRendererSubmenu = 'queue'"
|
||||
@mouseleave="showRendererSubmenu = null"
|
||||
>
|
||||
<ListPlus :size="16" />
|
||||
<span>Ajouter à la queue</span>
|
||||
<ChevronRight :size="16" class="submenu-arrow" />
|
||||
|
||||
<div v-if="showRendererSubmenu === 'queue'" class="renderer-submenu">
|
||||
<div
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="submenu-item"
|
||||
@click="handleAction('queue', renderer.id)"
|
||||
>
|
||||
{{ renderer.friendly_name }}
|
||||
</div>
|
||||
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||
Aucun renderer disponible
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop to close menu -->
|
||||
<div v-if="showMenu" class="menu-backdrop" @click="showMenu = false"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-menu-trigger:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 200px;
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.menu-item:first-child {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
.menu-item:only-child {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.menu-item svg:first-child {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-item span {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.renderer-submenu {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
margin-left: var(--spacing-xs);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: calc(var(--z-dropdown) + 1);
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.submenu-empty {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: calc(var(--z-dropdown) - 1);
|
||||
}
|
||||
|
||||
/* Scrollbar for long renderer lists */
|
||||
.renderer-submenu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Home } from 'lucide-vue-next'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: BreadcrumbItem[]
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [containerId: string]
|
||||
}>()
|
||||
|
||||
function handleNavigate(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
emit('navigate', '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||
<ol class="breadcrumb-list">
|
||||
<!-- Home -->
|
||||
<li class="breadcrumb-item">
|
||||
<button class="breadcrumb-link" @click="goHome" title="Accueil">
|
||||
<Home :size="16" />
|
||||
<span>Accueil</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Path items -->
|
||||
<template v-for="(item, index) in items" :key="item.id">
|
||||
<li class="breadcrumb-separator" aria-hidden="true">
|
||||
<ChevronRight :size="16" />
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<button
|
||||
v-if="index < items.length - 1"
|
||||
class="breadcrumb-link"
|
||||
@click="handleNavigate(item.id)"
|
||||
:title="item.title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
<span v-else class="breadcrumb-current" :title="item.title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-link:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb-link,
|
||||
.breadcrumb-current {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Folder, Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: [containerId: string]
|
||||
playNow: [containerId: string, rendererId: string]
|
||||
addToQueue: [containerId: string, rendererId: string]
|
||||
}>()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return Music
|
||||
if (cls.includes('album')) return Music
|
||||
return Folder
|
||||
})
|
||||
|
||||
const containerType = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return 'Playlist'
|
||||
if (cls.includes('album')) return 'Album'
|
||||
if (cls.includes('artist')) return 'Artiste'
|
||||
if (cls.includes('genre')) return 'Genre'
|
||||
return 'Dossier'
|
||||
})
|
||||
|
||||
function handleBrowse() {
|
||||
emit('browse', props.entry.id)
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container-item">
|
||||
<!-- Main content (clickable) -->
|
||||
<button class="container-content" @click="handleBrowse">
|
||||
<div class="container-icon">
|
||||
<component :is="iconComponent" :size="24" />
|
||||
</div>
|
||||
<div class="container-metadata">
|
||||
<div class="container-title">{{ entry.title }}</div>
|
||||
<div class="container-details">
|
||||
<span class="container-type">{{ containerType }}</span>
|
||||
<span v-if="entry.child_count !== null" class="container-count">
|
||||
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="container-actions">
|
||||
<ActionMenu
|
||||
type="container"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.container-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.container-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.container-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.container-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.container-type {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container-count::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.container-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
168
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
168
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const metadata = computed(() => state.value?.current_track)
|
||||
|
||||
// Calcul du pourcentage de progression
|
||||
const progressPercent = computed(() => {
|
||||
const position = state.value?.position_ms
|
||||
const duration = state.value?.duration_ms
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Formater la durée en MM:SS
|
||||
function formatTime(ms: number | null | undefined): string {
|
||||
if (!ms) return '--:--'
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-track">
|
||||
<!-- Cover Art -->
|
||||
<div class="cover-container">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Music :size="64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="metadata">
|
||||
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ currentTime }}</span>
|
||||
<span>{{ totalTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.current-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.album {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.cover-container {
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cover-container {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
import ContainerItem from './ContainerItem.vue'
|
||||
import MediaItem from './MediaItem.vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
containerId: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
error
|
||||
} = useMediaServers()
|
||||
|
||||
const {
|
||||
playContent,
|
||||
addToQueue,
|
||||
attachAndPlayPlaylist,
|
||||
attachPlaylist,
|
||||
} = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||
const isRefreshing = ref(false)
|
||||
const refreshTimeoutId = ref<number | null>(null)
|
||||
const lastRefreshTime = ref<number>(0)
|
||||
const REFRESH_COOLDOWN_MS = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId)
|
||||
)
|
||||
|
||||
const containers = computed(() =>
|
||||
browseData.value?.entries.filter((e) => e.is_container) || []
|
||||
)
|
||||
|
||||
const items = computed(() =>
|
||||
browseData.value?.entries.filter((e) => !e.is_container) || []
|
||||
)
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
async (newContainerId) => {
|
||||
if (newContainerId) {
|
||||
await browseContainer(props.serverId, newContainerId)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||
watch(
|
||||
() => browseData.value,
|
||||
(data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, planifier un rechargement
|
||||
if (!data && props.containerId && !loading.value) {
|
||||
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value
|
||||
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||
console.log(
|
||||
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Annuler tout timeout en cours
|
||||
if (refreshTimeoutId.value !== null) {
|
||||
clearTimeout(refreshTimeoutId.value)
|
||||
}
|
||||
|
||||
// Planifier le rechargement après 3 secondes
|
||||
// Cela permet de regrouper plusieurs événements SSE successifs
|
||||
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||
if (!isRefreshing.value) {
|
||||
console.log(
|
||||
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
||||
)
|
||||
isRefreshing.value = true
|
||||
await browseContainer(props.serverId, props.containerId, false)
|
||||
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
|
||||
isRefreshing.value = false
|
||||
refreshTimeoutId.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [containerId: string]
|
||||
}>()
|
||||
|
||||
function handleNavigate(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
function handleBrowseContainer(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
// Actions handlers pour les containers (playlists/albums)
|
||||
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture de la playlist: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout de la playlist: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions handlers pour les items (tracks)
|
||||
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await playContent(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Lecture démarrée !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await addToQueue(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Ajouté à la queue !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-browser">
|
||||
<!-- Breadcrumb -->
|
||||
<Breadcrumb
|
||||
:items="breadcrumbPath"
|
||||
:serverId="serverId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="browser-error">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn btn-secondary" @click="browseContainer(serverId, containerId, false)">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="browser-content">
|
||||
<!-- Containers section -->
|
||||
<div v-if="containers.length" class="browser-section">
|
||||
<h3 class="section-title">Dossiers et playlists</h3>
|
||||
<div class="entries-list">
|
||||
<ContainerItem
|
||||
v-for="container in containers"
|
||||
:key="container.id"
|
||||
:entry="container"
|
||||
:server-id="serverId"
|
||||
@browse="handleBrowseContainer"
|
||||
@play-now="handlePlayContainer"
|
||||
@add-to-queue="handleQueueContainer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items section -->
|
||||
<div v-if="items.length" class="browser-section">
|
||||
<h3 class="section-title">Pistes</h3>
|
||||
<div class="entries-list">
|
||||
<MediaItem
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:entry="item"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayItem"
|
||||
@add-to-queue="handleQueueItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!containers.length && !items.length" class="browser-empty">
|
||||
<p>Ce dossier est vide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.browser-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.browser-error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: var(--text-base);
|
||||
color: var(--status-offline);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.browser-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.browser-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.entries-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.browser-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-base);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
playNow: [itemId: string, rendererId: string]
|
||||
addToQueue: [itemId: string, rendererId: string]
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
const placeholder = img.nextElementSibling
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = 'flex'
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-item">
|
||||
<!-- Cover miniature -->
|
||||
<div class="media-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
:alt="entry.album || entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
|
||||
<Music :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="media-metadata">
|
||||
<div class="media-title">{{ entry.title }}</div>
|
||||
<div class="media-details">
|
||||
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
|
||||
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="media-actions">
|
||||
<ActionMenu
|
||||
type="item"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.media-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.media-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.media-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.media-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-artist {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album {
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.media-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { MediaServerSummary } from '@/services/pmocontrol/types'
|
||||
import { Server, Circle } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
server: MediaServerSummary
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const statusClass = computed(() => props.server.online ? 'online' : 'offline')
|
||||
const statusLabel = computed(() => props.server.online ? 'En ligne' : 'Hors ligne')
|
||||
|
||||
function goToServer() {
|
||||
if (props.server.online) {
|
||||
router.push(`/server/${props.server.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['media-server-card', { offline: !server.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="server-icon">
|
||||
<Server :size="40" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h3 class="server-name">{{ server.friendly_name }}</h3>
|
||||
<p class="server-model">{{ server.model_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card-status">
|
||||
<Circle :size="12" :class="['status-indicator', statusClass]" fill="currentColor" />
|
||||
<span :class="['status-label', statusClass]">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Browse Button -->
|
||||
<button
|
||||
class="btn btn-primary card-browse-btn"
|
||||
@click="goToServer"
|
||||
:disabled="!server.online"
|
||||
>
|
||||
Parcourir
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-server-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.media-server-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.media-server-card.offline {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-label.online {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.status-label.offline {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Browse Button */
|
||||
.card-browse-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-browse-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Link, Unlink } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { detachPlaylist } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
|
||||
async function handleDetach() {
|
||||
try {
|
||||
await detachPlaylist(props.rendererId)
|
||||
uiStore.notifySuccess('Playlist détachée avec succès')
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playlist-binding-panel">
|
||||
<h4 class="panel-title">Synchronisation Playlist</h4>
|
||||
|
||||
<!-- État attaché -->
|
||||
<div v-if="isAttached" class="binding-status attached">
|
||||
<div class="status-info">
|
||||
<Link :size="20" />
|
||||
<div class="status-text">
|
||||
<p class="status-label">Attachée à une playlist</p>
|
||||
<p class="status-details">
|
||||
<span class="detail-label">Serveur:</span>
|
||||
{{ binding?.server_id }}
|
||||
</p>
|
||||
<p class="status-details">
|
||||
<span class="detail-label">Container:</span>
|
||||
{{ binding?.container_id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" @click="handleDetach">
|
||||
<Unlink :size="16" />
|
||||
Détacher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- État détaché -->
|
||||
<div v-else class="binding-status detached">
|
||||
<div class="status-info">
|
||||
<Unlink :size="20" />
|
||||
<div class="status-text">
|
||||
<p class="status-label">Non attachée</p>
|
||||
<p class="status-description">
|
||||
La file d'attente n'est pas synchronisée avec une playlist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<p class="help-text">
|
||||
<strong>📌 Playlist Dynamique</strong><br>
|
||||
La queue se synchronisera automatiquement avec la playlist/album du serveur.
|
||||
Les pistes ajoutées ou retirées sur le serveur apparaîtront ici en temps réel.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
<strong>💡 Comment activer ?</strong><br>
|
||||
Naviguez vers un serveur de médias, trouvez une playlist/album,
|
||||
et cliquez sur ⋮ puis "Lire maintenant". Le binding se fera automatiquement !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playlist-binding-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-info > svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.attached .status-info > svg {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.detached .status-info > svg {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--spacing-xs) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.help-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--status-transitioning);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-text strong {
|
||||
color: var(--color-text);
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.help-text-warning {
|
||||
border-left-color: var(--status-paused);
|
||||
background-color: var(--status-paused-bg);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
129
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
129
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { Music, Play } from 'lucide-vue-next'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
|
||||
defineProps<{
|
||||
item: QueueItem
|
||||
isCurrent: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['queue-item', { current: isCurrent }]">
|
||||
<!-- Indicateur piste en cours -->
|
||||
<div class="current-indicator" v-if="isCurrent">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</div>
|
||||
|
||||
<!-- Index (1-based pour l'affichage) -->
|
||||
<span class="item-index">{{ item.index + 1 }}</span>
|
||||
|
||||
<!-- Cover miniature -->
|
||||
<div class="item-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<Music v-else :size="20" />
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="item-metadata">
|
||||
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
|
||||
<div class="item-artist">
|
||||
{{ item.artist || 'Artiste inconnu' }}
|
||||
<span v-if="item.album"> • {{ item.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.queue-item.current {
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current-indicator {
|
||||
position: absolute;
|
||||
left: var(--spacing-xs);
|
||||
color: var(--status-playing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-index {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.queue-item.current .item-index {
|
||||
margin-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.item-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-item.current .item-title {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.item-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
149
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
149
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import QueueItem from './QueueItem.vue'
|
||||
import { Link } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick()
|
||||
const currentItem = queueContainer.value.querySelector('.queue-item.current')
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="queue-viewer">
|
||||
<!-- Header avec indication de binding -->
|
||||
<div class="queue-header">
|
||||
<h3 class="queue-title">
|
||||
File d'attente
|
||||
<span class="queue-count" v-if="queue?.items.length">
|
||||
({{ queue.items.length }})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text">
|
||||
Attachée à une playlist
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des items -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else class="queue-empty">
|
||||
<p>Aucun élément dans la file d'attente</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.binding-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--status-playing-bg);
|
||||
color: var(--status-playing);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--status-playing);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.binding-text {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.queue-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-base);
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type {
|
||||
RendererCapabilitiesSummary,
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from '@/services/pmocontrol/types'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
renderer: RendererSummary
|
||||
state: RendererState | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||
const metadata = computed(() => props.state?.current_track)
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
const protocolClass = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'protocol-upnp'
|
||||
case 'openhome':
|
||||
return 'protocol-openhome'
|
||||
case 'hybrid':
|
||||
return 'protocol-hybrid'
|
||||
default:
|
||||
return 'protocol-unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const capabilityBadges = computed(() => {
|
||||
const caps = props.renderer.capabilities
|
||||
if (!caps) return []
|
||||
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||
{ key: 'has_oh_volume', label: 'Vol' },
|
||||
{ key: 'has_oh_time', label: 'Time' },
|
||||
{ key: 'has_oh_info', label: 'Info' },
|
||||
]
|
||||
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||
})
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
|
||||
function goToRenderer() {
|
||||
router.push(`/renderer/${props.renderer.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span :class="['protocol-badge', protocolClass]">
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover Art -->
|
||||
<div v-if="capabilityBadges.length" class="capabilities">
|
||||
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Music :size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata (current track) -->
|
||||
<div v-if="metadata" class="card-metadata">
|
||||
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
||||
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
||||
</div>
|
||||
<div v-else class="card-metadata empty">
|
||||
<p class="track-title">Aucun média</p>
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div v-if="state && state.volume !== null" class="card-volume">
|
||||
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||
<Volume2 v-else :size="16" class="volume-icon" />
|
||||
<div class="volume-bar">
|
||||
<div
|
||||
class="volume-bar-fill"
|
||||
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="volume-value">{{ state.volume }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Control Button -->
|
||||
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||
Contrôler
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.renderer-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.capability-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.card-metadata {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-metadata.empty .track-title {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Volume */
|
||||
.card-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.volume-icon.muted {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Control Button */
|
||||
.card-control-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Responsive grid handled by parent (DashboardView) */
|
||||
</style>
|
||||
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' | 'OFFLINE'
|
||||
}>()
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'PLAYING':
|
||||
return 'playing'
|
||||
case 'PAUSED':
|
||||
return 'paused'
|
||||
case 'STOPPED':
|
||||
case 'NO_MEDIA':
|
||||
return 'stopped'
|
||||
case 'TRANSITIONING':
|
||||
return 'transitioning'
|
||||
case 'OFFLINE':
|
||||
return 'offline'
|
||||
default:
|
||||
return 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'PLAYING':
|
||||
return 'En lecture'
|
||||
case 'PAUSED':
|
||||
return 'En pause'
|
||||
case 'STOPPED':
|
||||
return 'Arrêté'
|
||||
case 'NO_MEDIA':
|
||||
return 'Aucun média'
|
||||
case 'TRANSITIONING':
|
||||
return 'Transition...'
|
||||
case 'OFFLINE':
|
||||
return 'Hors ligne'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['status-badge', getStatusClass(status)]">
|
||||
{{ getStatusLabel(status) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Les styles sont définis globalement dans pmocontrol.css */
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
104
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
104
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { setVolume, toggleMute } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const localVolume = ref(state.value?.volume ?? 50)
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
watch(() => state.value?.volume, (newVolume) => {
|
||||
if (newVolume !== undefined && newVolume !== null) {
|
||||
localVolume.value = newVolume
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Debounce pour le slider
|
||||
let debounceTimer: number | null = null
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
localVolume.value = parseInt(target.value, 10)
|
||||
|
||||
// Debounce: attendre 300ms avant d'envoyer à l'API
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await setVolume(props.rendererId, localVolume.value)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
debounceTimer = null
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await toggleMute(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="volume-control">
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
@click="handleToggleMute"
|
||||
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
>
|
||||
<VolumeX v-if="state?.mute" :size="20" />
|
||||
<Volume2 v-else :size="20" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="localVolume"
|
||||
@input="handleVolumeChange"
|
||||
class="volume-slider"
|
||||
:disabled="state?.mute ?? false"
|
||||
/>
|
||||
|
||||
<span class="volume-value">{{ localVolume }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.volume-slider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
183
pmoapp/webapp/src/composables/useMediaServers.ts
Normal file
183
pmoapp/webapp/src/composables/useMediaServers.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Composable pour gérer les media servers
|
||||
* Architecture simple : l'API est la source de vérité, SSE invalide le cache
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
const lastFetch = {
|
||||
servers: 0
|
||||
}
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
|
||||
// Connecter SSE une seule fois
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
|
||||
sse.onMediaServerEvent((event) => {
|
||||
const serverId = event.server_id
|
||||
|
||||
switch (event.type) {
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
keysToDelete.forEach(key => browseCache.value.delete(key))
|
||||
break
|
||||
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
console.log(`[useMediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal pour gérer les media servers
|
||||
*/
|
||||
export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
const allServers = computed(() => Array.from(serversCache.value.values()))
|
||||
const onlineServers = computed(() => allServers.value.filter(s => s.online))
|
||||
|
||||
// Fetch servers list
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return // Cache encore valide
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getServers()
|
||||
|
||||
serversCache.value.clear()
|
||||
data.forEach(s => serversCache.value.set(s.id, s))
|
||||
lastFetch.servers = now
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||
console.error('[useMediaServers] Erreur fetch:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
|
||||
return data
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
}
|
||||
|
||||
function getBrowseCached(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
}
|
||||
|
||||
function clearPath() {
|
||||
currentPath.value = []
|
||||
}
|
||||
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
keysToDelete.forEach(key => browseCache.value.delete(key))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
allServers,
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
}
|
||||
}
|
||||
299
pmoapp/webapp/src/composables/useRenderers.ts
Normal file
299
pmoapp/webapp/src/composables/useRenderers.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Composable pour gérer les renderers.
|
||||
* Le ControlPoint est la seule source de vérité :
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, type Ref } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
QueueSnapshot,
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
interface RendererSnapshotState {
|
||||
snapshots: Map<string, FullRendererSnapshot>
|
||||
lastSnapshotAt: Map<string, number>
|
||||
lastEventAt: Map<string, number>
|
||||
loadingIds: Set<string>
|
||||
selectedRendererId: string | null
|
||||
}
|
||||
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
|
||||
const RENDERERS_CACHE_MS = 2000
|
||||
const lastRenderersFetch = ref(0)
|
||||
|
||||
const snapshotState = reactive<RendererSnapshotState>({
|
||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||
lastEventAt: reactive(new Map<string, number>()),
|
||||
loadingIds: reactive(new Set<string>()),
|
||||
selectedRendererId: null,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
|
||||
sse.onRendererEvent((event) => {
|
||||
const rendererId = event.renderer_id
|
||||
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp)
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
|
||||
void fetchRendererSnapshot(rendererId, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
|
||||
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
|
||||
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
.filter((snapshot) => snapshot.state.transport_state === 'PLAYING')
|
||||
.map((snapshot) => snapshot.state),
|
||||
)
|
||||
|
||||
function getRendererById(id: string) {
|
||||
return renderersCache.value.get(id)
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshotState.snapshots.get(id) ?? null
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshotState.snapshots.get(id)?.state ?? null
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id)
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected()
|
||||
|
||||
const now = Date.now()
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getRenderers()
|
||||
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
|
||||
lastRenderersFetch.value = now
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur fetch renderers'
|
||||
console.error('[useRenderers] Erreur fetch:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) {
|
||||
ensureSSEConnected()
|
||||
const force = opts?.force ?? false
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId)
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotState.loadingIds.has(rendererId)) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshotState.loadingIds.add(rendererId)
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId)
|
||||
snapshotState.snapshots.set(rendererId, snapshot)
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
|
||||
} finally {
|
||||
snapshotState.loadingIds.delete(rendererId)
|
||||
}
|
||||
}
|
||||
|
||||
// Transport controls
|
||||
async function play(id: string) {
|
||||
await api.play(id)
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshotState.snapshots.get(id)
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`)
|
||||
}
|
||||
|
||||
const state = snapshot.state
|
||||
if (state.transport_state === 'PAUSED') {
|
||||
return play(id)
|
||||
}
|
||||
|
||||
if (
|
||||
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
return api.resume(id)
|
||||
}
|
||||
|
||||
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
|
||||
}
|
||||
|
||||
async function pause(id: string) {
|
||||
await api.pause(id)
|
||||
}
|
||||
|
||||
async function stop(id: string) {
|
||||
await api.stop(id)
|
||||
}
|
||||
|
||||
async function next(id: string) {
|
||||
await api.next(id)
|
||||
}
|
||||
|
||||
// Volume controls
|
||||
async function setVolume(id: string, volume: number) {
|
||||
await api.setVolume(id, volume)
|
||||
}
|
||||
|
||||
async function volumeUp(id: string) {
|
||||
await api.volumeUp(id)
|
||||
}
|
||||
|
||||
async function volumeDown(id: string) {
|
||||
await api.volumeDown(id)
|
||||
}
|
||||
|
||||
async function toggleMute(id: string) {
|
||||
await api.toggleMute(id)
|
||||
}
|
||||
|
||||
// Playlist binding
|
||||
async function attachPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
options?: { autoPlay?: boolean },
|
||||
) {
|
||||
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
|
||||
}
|
||||
|
||||
async function detachPlaylist(rendererId: string) {
|
||||
await api.detachPlaylist(rendererId)
|
||||
}
|
||||
|
||||
async function attachAndPlayPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
) {
|
||||
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
|
||||
}
|
||||
|
||||
// Queue content
|
||||
async function playContent(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.playContent(rendererId, serverId, objectId)
|
||||
}
|
||||
|
||||
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.addToQueue(rendererId, serverId, objectId)
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
// Collections
|
||||
allRenderers,
|
||||
onlineRenderers,
|
||||
playingRenderers,
|
||||
// Accessors
|
||||
getRendererById,
|
||||
getSnapshotById,
|
||||
getStateById,
|
||||
getQueueById,
|
||||
getBindingById,
|
||||
isSnapshotLoading,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
// Transport controls
|
||||
play,
|
||||
resumeOrPlayFromQueue,
|
||||
pause,
|
||||
stop,
|
||||
next,
|
||||
// Volume controls
|
||||
setVolume,
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
// Playlist binding
|
||||
attachPlaylist,
|
||||
detachPlaylist,
|
||||
attachAndPlayPlaylist,
|
||||
// Queue content
|
||||
playContent,
|
||||
addToQueue,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected()
|
||||
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value))
|
||||
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
|
||||
const state = computed(() => snapshot.value?.state ?? null)
|
||||
const queue = computed(() => snapshot.value?.queue ?? null)
|
||||
const binding = computed(() => snapshot.value?.binding ?? null)
|
||||
|
||||
async function refresh(force = true) {
|
||||
await Promise.all([
|
||||
fetchRenderers(force),
|
||||
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
renderer,
|
||||
snapshot,
|
||||
state,
|
||||
queue,
|
||||
binding,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,44 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./style.css";
|
||||
// Service SSE (les composables se connectent automatiquement)
|
||||
import { sse } from "./services/pmocontrol/sse";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
// Styles
|
||||
import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
import "./assets/styles/pmocontrol.css";
|
||||
|
||||
// Créer l'application Vue
|
||||
const app = createApp(App);
|
||||
|
||||
// Créer et installer Pinia
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Monter l'application
|
||||
app.mount("#app");
|
||||
|
||||
// Après montage, initialiser SSE
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Les composables se connectent automatiquement à SSE
|
||||
// Ils gèrent eux-mêmes le re-fetch lors des événements
|
||||
|
||||
sse.onConnectionChange((connected) => {
|
||||
uiStore.setSSEConnected(connected);
|
||||
if (connected) {
|
||||
console.log("[App] SSE connecté");
|
||||
}
|
||||
});
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
sse.connect();
|
||||
|
||||
console.log("[App] PMOControl initialisé");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
|
||||
// PMOControl Views (nouvelle home)
|
||||
import DashboardView from "../views/DashboardView.vue";
|
||||
import RendererView from "../views/RendererView.vue";
|
||||
import MediaServerView from "../views/MediaServerView.vue";
|
||||
|
||||
// Debug Components (anciennes routes)
|
||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
@@ -8,13 +15,59 @@ import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
||||
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
|
||||
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
|
||||
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
|
||||
// PMOControl (nouvelle home)
|
||||
{
|
||||
path: "/",
|
||||
name: "Dashboard",
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: "/renderer/:id",
|
||||
name: "Renderer",
|
||||
component: RendererView,
|
||||
},
|
||||
{
|
||||
path: "/server/:serverId",
|
||||
name: "MediaServer",
|
||||
component: MediaServerView,
|
||||
},
|
||||
|
||||
// Debug menu (anciennes routes déplacées sous /debug)
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "GenericPlayer",
|
||||
component: GenericMusicPlayer,
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
component: LogView,
|
||||
},
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "CoversCache",
|
||||
component: CoverCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "AudioCache",
|
||||
component: AudioCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "UpnpExplorer",
|
||||
component: UpnpExplorer,
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "APIDashboard",
|
||||
component: APIDashboard,
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "RadioParadise",
|
||||
component: RadioParadiseExplorer,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface AudioCacheMetadata {
|
||||
bitrate?: number;
|
||||
channels?: number;
|
||||
conversion?: ConversionInfo;
|
||||
cover_pk?: string;
|
||||
cover_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -228,3 +230,26 @@ export function formatSampleRate(sampleRate?: number): string {
|
||||
if (!sampleRate) return "Unknown";
|
||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL de la cover d'une piste
|
||||
* Priorité : cover_pk (cache) > cover_url (externe) > undefined
|
||||
*/
|
||||
export function getCoverUrl(metadata?: AudioCacheMetadata | null, size?: number): string | undefined {
|
||||
if (!metadata) return undefined;
|
||||
|
||||
// Priorité 1 : cover en cache via cover_pk
|
||||
if (metadata.cover_pk) {
|
||||
if (size) {
|
||||
return `/covers/image/${metadata.cover_pk}/${size}`;
|
||||
}
|
||||
return `/covers/image/${metadata.cover_pk}`;
|
||||
}
|
||||
|
||||
// Priorité 2 : cover externe via cover_url
|
||||
if (metadata.cover_url) {
|
||||
return metadata.cover_url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -192,3 +192,42 @@ export function getImageUrl(pk: string, size?: number): string {
|
||||
}
|
||||
return `/covers/image/${pk}`;
|
||||
}
|
||||
|
||||
export function getJpegUrl(pk: string, size?: number): string {
|
||||
if (size) {
|
||||
return `/covers/jpeg/${pk}/${size}`;
|
||||
}
|
||||
return `/covers/jpeg/${pk}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG par défaut pour les images qui ne se chargent pas
|
||||
*/
|
||||
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* Retourne l'URL de l'image par défaut comme data URL
|
||||
*/
|
||||
export function getDefaultImageUrl(): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
|
||||
}
|
||||
|
||||
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
OpenHomePlaylistAddRequest,
|
||||
OpenHomePlaylistSnapshot,
|
||||
} from '@/services/pmocontrol/types'
|
||||
|
||||
const API_BASE = '/api/control'
|
||||
|
||||
export async function getOpenHomePlaylist(rendererId: string): Promise<OpenHomePlaylistSnapshot> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`,
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function clearOpenHomePlaylist(rendererId: string): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addOpenHomeTrack(
|
||||
rendererId: string,
|
||||
payload: OpenHomePlaylistAddRequest,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(
|
||||
rendererId,
|
||||
)}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
291
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
291
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
// Client API REST pour PMOControl
|
||||
// Communique avec /api/control/*
|
||||
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
FullRendererSnapshot,
|
||||
QueueSnapshot,
|
||||
AttachedPlaylistInfo,
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = '/api/control'
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json().catch(() => ({
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.error)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Liste tous les renderers découverts
|
||||
* GET /api/control/renderers
|
||||
*/
|
||||
async getRenderers(): Promise<RendererSummary[]> {
|
||||
return this.request<RendererSummary[]>('/renderers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'état détaillé d'un renderer
|
||||
* GET /api/control/renderers/{id}
|
||||
*/
|
||||
async getRendererState(id: string): Promise<RendererState> {
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le snapshot complet d'un renderer
|
||||
* GET /api/control/renderers/{id}/full
|
||||
*/
|
||||
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
|
||||
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la queue d'un renderer (avec current_index)
|
||||
* GET /api/control/renderers/{id}/queue
|
||||
*/
|
||||
async getQueue(id: string): Promise<QueueSnapshot> {
|
||||
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le binding playlist d'un renderer
|
||||
* GET /api/control/renderers/{id}/binding
|
||||
*/
|
||||
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
|
||||
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRÔLE TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Démarre la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/play
|
||||
*/
|
||||
async play(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en pause la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/pause
|
||||
*/
|
||||
async pause(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/stop
|
||||
*/
|
||||
async stop(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend la lecture depuis le morceau actuel de la queue
|
||||
* POST /api/control/renderers/{id}/resume
|
||||
*/
|
||||
async resume(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe au morceau suivant dans la queue
|
||||
* POST /api/control/renderers/{id}/next
|
||||
*/
|
||||
async next(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRÔLE VOLUME
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Définit le volume d'un renderer (0-100)
|
||||
* POST /api/control/renderers/{id}/volume/set
|
||||
*/
|
||||
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
|
||||
const payload: VolumeSetRequest = { volume }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Augmente le volume de 5%
|
||||
* POST /api/control/renderers/{id}/volume/up
|
||||
*/
|
||||
async volumeUp(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Diminue le volume de 5%
|
||||
* POST /api/control/renderers/{id}/volume/down
|
||||
*/
|
||||
async volumeDown(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule le mute d'un renderer
|
||||
* POST /api/control/renderers/{id}/mute/toggle
|
||||
*/
|
||||
async toggleMute(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYLIST BINDING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Attache la queue d'un renderer à une playlist d'un serveur
|
||||
* POST /api/control/renderers/{id}/binding/attach
|
||||
*/
|
||||
async attachPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
autoPlay = false
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: AttachPlaylistRequest = {
|
||||
server_id: serverId,
|
||||
container_id: containerId,
|
||||
auto_play: autoPlay,
|
||||
}
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Détache la queue d'un renderer de sa playlist
|
||||
* POST /api/control/renderers/{id}/binding/detach
|
||||
*/
|
||||
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE CONTENT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lire du contenu immédiatement (clear queue + enqueue + play)
|
||||
* POST /api/control/renderers/{id}/queue/play
|
||||
*/
|
||||
async playContent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter du contenu à la queue (sans démarrer la lecture)
|
||||
* POST /api/control/renderers/{id}/queue/add
|
||||
*/
|
||||
async addToQueue(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Liste tous les serveurs de médias découverts
|
||||
* GET /api/control/servers
|
||||
*/
|
||||
async getServers(): Promise<MediaServerSummary[]> {
|
||||
return this.request<MediaServerSummary[]>('/servers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse le contenu d'un container sur un serveur
|
||||
* GET /api/control/servers/{serverId}/containers/{containerId}
|
||||
*/
|
||||
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const api = new PMOControlAPI()
|
||||
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Service SSE (Server-Sent Events) pour PMOControl
|
||||
// Gère la connexion temps réel à /api/control/events
|
||||
|
||||
import type { RendererEventPayload, MediaServerEventPayload, UnifiedEventPayload } from './types'
|
||||
|
||||
type RendererEventCallback = (event: RendererEventPayload) => void
|
||||
type MediaServerEventCallback = (event: MediaServerEventPayload) => void
|
||||
type ConnectionCallback = (connected: boolean) => void
|
||||
|
||||
/**
|
||||
* Service SSE pour recevoir les événements du Control Point en temps réel
|
||||
*/
|
||||
export class PMOControlSSE {
|
||||
private eventSource: EventSource | null = null
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectDelay = 30000 // 30 secondes max
|
||||
private reconnectTimer: number | null = null
|
||||
|
||||
private rendererCallbacks: Set<RendererEventCallback> = new Set()
|
||||
private serverCallbacks: Set<MediaServerEventCallback> = new Set()
|
||||
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||
|
||||
private isConnected = false
|
||||
|
||||
/**
|
||||
* Connecte au flux SSE
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.eventSource) {
|
||||
console.warn('[SSE] Connexion déjà active')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[SSE] Connexion à /api/control/events...')
|
||||
|
||||
try {
|
||||
this.eventSource = new EventSource('/api/control/events')
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('[SSE] Connexion établie')
|
||||
this.reconnectAttempts = 0
|
||||
this.isConnected = true
|
||||
this.notifyConnectionCallbacks(true)
|
||||
}
|
||||
|
||||
this.eventSource.addEventListener('control', (e: MessageEvent) => {
|
||||
try {
|
||||
const event: UnifiedEventPayload = JSON.parse(e.data)
|
||||
this.handleEvent(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur parsing événement:', error)
|
||||
}
|
||||
})
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
console.error('[SSE] Erreur de connexion')
|
||||
this.isConnected = false
|
||||
this.notifyConnectionCallbacks(false)
|
||||
this.disconnect()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur création EventSource:', error)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnecte du flux SSE
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.eventSource) {
|
||||
console.log('[SSE] Déconnexion')
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
this.isConnected = false
|
||||
this.notifyConnectionCallbacks(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme une reconnexion avec backoff exponentiel
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
|
||||
// Backoff exponentiel: 1s, 2s, 4s, 8s, 16s, 30s (max)
|
||||
const delay = Math.min(
|
||||
1000 * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
)
|
||||
|
||||
console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch un événement aux callbacks appropriés
|
||||
*/
|
||||
private handleEvent(event: UnifiedEventPayload): void {
|
||||
if (event.category === 'renderer') {
|
||||
// Extraire le payload renderer (sans le champ category)
|
||||
const { category, ...rendererEvent } = event
|
||||
this.notifyRendererCallbacks(rendererEvent as RendererEventPayload)
|
||||
} else if (event.category === 'media_server') {
|
||||
// Extraire le payload server (sans le champ category)
|
||||
const { category, ...serverEvent } = event
|
||||
this.notifyServerCallbacks(serverEvent as MediaServerEventPayload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les événements renderer
|
||||
*/
|
||||
onRendererEvent(callback: RendererEventCallback): () => void {
|
||||
this.rendererCallbacks.add(callback)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.rendererCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les événements media server
|
||||
*/
|
||||
onMediaServerEvent(callback: MediaServerEventCallback): () => void {
|
||||
this.serverCallbacks.add(callback)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.serverCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les changements de connexion
|
||||
*/
|
||||
onConnectionChange(callback: ConnectionCallback): () => void {
|
||||
this.connectionCallbacks.add(callback)
|
||||
// Appeler immédiatement avec l'état actuel
|
||||
callback(this.isConnected)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.connectionCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks renderer
|
||||
*/
|
||||
private notifyRendererCallbacks(event: RendererEventPayload): void {
|
||||
this.rendererCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback renderer:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks server
|
||||
*/
|
||||
private notifyServerCallbacks(event: MediaServerEventPayload): void {
|
||||
this.serverCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback server:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks de connexion
|
||||
*/
|
||||
private notifyConnectionCallbacks(connected: boolean): void {
|
||||
this.connectionCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(connected)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback connexion:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'état de connexion actuel
|
||||
*/
|
||||
isConnectedState(): boolean {
|
||||
return this.isConnected
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const sse = new PMOControlSSE()
|
||||
203
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
203
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Types TypeScript pour l'API PMOControl
|
||||
// Synchronisés avec pmocontrol/src/openapi.rs
|
||||
|
||||
// ============================================================================
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
||||
|
||||
export interface RendererCapabilitiesSummary {
|
||||
has_avtransport: boolean
|
||||
has_avtransport_set_next: boolean
|
||||
has_rendering_control: boolean
|
||||
has_connection_manager: boolean
|
||||
has_linkplay_http: boolean
|
||||
has_arylic_tcp: boolean
|
||||
has_oh_playlist: boolean
|
||||
has_oh_volume: boolean
|
||||
has_oh_info: boolean
|
||||
has_oh_time: boolean
|
||||
has_oh_radio: boolean
|
||||
}
|
||||
|
||||
export interface RendererSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
protocol: RendererProtocolSummary
|
||||
capabilities: RendererCapabilitiesSummary
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
id: string
|
||||
friendly_name: string
|
||||
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
||||
position_ms: number | null
|
||||
duration_ms: number | null
|
||||
volume: number | null // 0-100
|
||||
mute: boolean | null
|
||||
queue_len: number
|
||||
attached_playlist: AttachedPlaylistInfo | null
|
||||
current_track: CurrentTrackMetadata | null
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface AttachedPlaylistInfo {
|
||||
server_id: string
|
||||
container_id: string
|
||||
has_seen_update: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE (avec current_index)
|
||||
// ============================================================================
|
||||
|
||||
export interface QueueItem {
|
||||
index: number // 0-based
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
server_id: string | null
|
||||
object_id: string | null
|
||||
}
|
||||
|
||||
export interface QueueSnapshot {
|
||||
renderer_id: string
|
||||
items: QueueItem[]
|
||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||
}
|
||||
|
||||
export interface FullRendererSnapshot {
|
||||
state: RendererState
|
||||
queue: QueueSnapshot
|
||||
binding: AttachedPlaylistInfo | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenHomePlaylistTrack {
|
||||
id: number
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistSnapshot {
|
||||
renderer_id: string
|
||||
current_id: number | null
|
||||
tracks: OpenHomePlaylistTrack[]
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistAddRequest {
|
||||
uri: string
|
||||
metadata: string
|
||||
after_id?: number | null
|
||||
play?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
export interface MediaServerSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
id: string
|
||||
title: string
|
||||
class: string // UPnP class
|
||||
is_container: boolean
|
||||
child_count: number | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMMANDES
|
||||
// ============================================================================
|
||||
|
||||
export interface VolumeSetRequest {
|
||||
volume: number // 0-100
|
||||
}
|
||||
|
||||
export interface AttachPlaylistRequest {
|
||||
server_id: string
|
||||
container_id: string
|
||||
auto_play?: boolean
|
||||
}
|
||||
|
||||
export interface PlayContentRequest {
|
||||
server_id: string
|
||||
object_id: string
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ÉVÉNEMENTS SSE
|
||||
// ============================================================================
|
||||
|
||||
export type RendererEventPayload =
|
||||
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
|
||||
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
|
||||
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
|
||||
|
||||
export type MediaServerEventPayload =
|
||||
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
|
||||
|
||||
export type UnifiedEventPayload =
|
||||
| { category: 'renderer' } & RendererEventPayload
|
||||
| { category: 'media_server' } & MediaServerEventPayload
|
||||
|
||||
// ============================================================================
|
||||
// MÉTADONNÉES PISTE
|
||||
// ============================================================================
|
||||
|
||||
export interface TrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface PositionInfo {
|
||||
track: number | null
|
||||
rel_time: string | null // Format HH:MM:SS
|
||||
track_duration: string | null // Format HH:MM:SS
|
||||
}
|
||||
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Service pour interagir avec l'API pmosource générique
|
||||
*
|
||||
* Ce service utilise uniquement l'API REST définie dans pmosource::api
|
||||
* et ne dépend d'aucune implémentation spécifique (comme pmoparadise)
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/sources'
|
||||
|
||||
// Types correspondant aux structures de l'API pmosource
|
||||
|
||||
export interface SourceInfo {
|
||||
id: string
|
||||
name: string
|
||||
supports_fifo: boolean
|
||||
capabilities: SourceCapabilities
|
||||
}
|
||||
|
||||
export interface SourceCapabilities {
|
||||
supports_search: boolean
|
||||
supports_favorites: boolean
|
||||
supports_playlists: boolean
|
||||
supports_user_content: boolean
|
||||
supports_high_res_audio: boolean
|
||||
max_sample_rate: number | null
|
||||
supports_multiple_formats: boolean
|
||||
supports_advanced_search: boolean
|
||||
supports_pagination: boolean
|
||||
}
|
||||
|
||||
export interface SourcesList {
|
||||
count: number
|
||||
sources: SourceInfo[]
|
||||
}
|
||||
|
||||
export interface BrowseContainer {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
child_count: string | null
|
||||
restricted: string | null
|
||||
}
|
||||
|
||||
export interface BrowseItemResource {
|
||||
url: string
|
||||
protocol_info: string
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
export interface BrowseItem {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
artist: string | null
|
||||
album: string | null
|
||||
creator: string | null
|
||||
album_art: string | null
|
||||
resources: BrowseItemResource[]
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
object_id: string
|
||||
containers: BrowseContainer[]
|
||||
items: BrowseItem[]
|
||||
returned_containers: number
|
||||
returned_items: number
|
||||
total: number
|
||||
update_id: number
|
||||
}
|
||||
|
||||
export interface ResolveUriResponse {
|
||||
object_id: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export interface SourceRootContainer {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
child_count: string | null
|
||||
searchable: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sources musicales enregistrées
|
||||
*/
|
||||
export async function listSources(): Promise<SourcesList> {
|
||||
const response = await fetch(`${API_BASE}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list sources: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'une source spécifique
|
||||
*/
|
||||
export async function getSource(sourceId: string): Promise<SourceInfo> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le container racine d'une source
|
||||
*/
|
||||
export async function getSourceRoot(sourceId: string): Promise<SourceRootContainer> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/root`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source root: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt un container d'une source
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'objet à parcourir (optionnel, par défaut utilise la racine)
|
||||
* @param startingIndex - Index de départ pour la pagination
|
||||
* @param requestedCount - Nombre d'éléments demandés
|
||||
*/
|
||||
export async function browseSource(
|
||||
sourceId: string,
|
||||
objectId?: string,
|
||||
startingIndex?: number,
|
||||
requestedCount?: number
|
||||
): Promise<BrowseResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (objectId) params.set('object_id', objectId)
|
||||
if (startingIndex !== undefined) params.set('starting_index', startingIndex.toString())
|
||||
if (requestedCount !== undefined) params.set('requested_count', requestedCount.toString())
|
||||
|
||||
const url = `${API_BASE}/${sourceId}/browse?${params.toString()}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to browse source: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'URI réelle d'un objet (pour le streaming)
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'objet à résoudre
|
||||
*/
|
||||
export async function resolveUri(sourceId: string, objectId: string): Promise<ResolveUriResponse> {
|
||||
const params = new URLSearchParams({ object_id: objectId })
|
||||
const url = `${API_BASE}/${sourceId}/resolve?${params.toString()}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve URI: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de l'image par défaut d'une source
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @returns L'URL de l'image
|
||||
*/
|
||||
export function getSourceImageUrl(sourceId: string): string {
|
||||
return `${API_BASE}/${sourceId}/image`
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les capacités d'une source
|
||||
*/
|
||||
export async function getSourceCapabilities(sourceId: string): Promise<SourceCapabilities> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/capabilities`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source capabilities: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métadonnées détaillées d'un item spécifique
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'item à récupérer
|
||||
*/
|
||||
export async function getItem(sourceId: string, objectId: string): Promise<BrowseItem> {
|
||||
const params = new URLSearchParams({ object_id: objectId })
|
||||
const url = `${API_BASE}/${sourceId}/item?${params.toString()}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get item: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
233
pmoapp/webapp/src/services/radioParadise.ts
Normal file
233
pmoapp/webapp/src/services/radioParadise.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Service API pour interagir avec Radio Paradise
|
||||
*/
|
||||
|
||||
export interface ChannelInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SongInfo {
|
||||
index: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
year?: number;
|
||||
elapsed_ms: number;
|
||||
duration_ms: number;
|
||||
cover_url?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface BlockResponse {
|
||||
event: number;
|
||||
end_event: number;
|
||||
url: string;
|
||||
length_ms: number;
|
||||
songs: SongInfo[];
|
||||
}
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
event: number;
|
||||
end_event: number;
|
||||
stream_url: string;
|
||||
block_length_ms: number;
|
||||
current_song_index?: number;
|
||||
current_song?: SongInfo;
|
||||
songs: SongInfo[];
|
||||
}
|
||||
|
||||
export interface StreamUrlResponse {
|
||||
event: number;
|
||||
stream_url: string;
|
||||
length_ms: number;
|
||||
}
|
||||
|
||||
export interface CoverUrlResponse {
|
||||
event: number;
|
||||
song_index: number;
|
||||
cover_url?: string;
|
||||
cover_type: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les canaux disponibles
|
||||
*/
|
||||
export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
const response = await fetch("/api/radioparadise/channels");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch channels");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le morceau en cours de lecture
|
||||
*/
|
||||
export async function getNowPlaying(channel?: number): Promise<NowPlayingResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/now-playing?channel=${channel}`
|
||||
: "/api/radioparadise/now-playing";
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch now playing");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le block actuel
|
||||
*/
|
||||
export async function getCurrentBlock(channel?: number): Promise<BlockResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/current?channel=${channel}`
|
||||
: "/api/radioparadise/block/current";
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch current block");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un block spécifique par son event ID
|
||||
*/
|
||||
export async function getBlockById(eventId: number, channel?: number): Promise<BlockResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/${eventId}?channel=${channel}`
|
||||
: `/api/radioparadise/block/${eventId}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch block");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un morceau spécifique d'un block
|
||||
*/
|
||||
export async function getSongByIndex(
|
||||
eventId: number,
|
||||
index: number,
|
||||
channel?: number
|
||||
): Promise<SongInfo> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/${eventId}/song/${index}?channel=${channel}`
|
||||
: `/api/radioparadise/block/${eventId}/song/${index}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch song");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de la pochette d'un morceau
|
||||
*/
|
||||
export async function getCoverUrl(
|
||||
eventId: number,
|
||||
songIndex: number,
|
||||
channel?: number
|
||||
): Promise<CoverUrlResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/cover-url/${eventId}/${songIndex}?channel=${channel}`
|
||||
: `/api/radioparadise/cover-url/${eventId}/${songIndex}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch cover URL");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de streaming d'un block
|
||||
*/
|
||||
export async function getStreamUrl(
|
||||
eventId: number,
|
||||
channel?: number
|
||||
): Promise<StreamUrlResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/stream-url/${eventId}?channel=${channel}`
|
||||
: `/api/radioparadise/stream-url/${eventId}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch stream URL");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une durée en millisecondes en format MM:SS
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une durée en millisecondes en format H:MM:SS si >= 1h, sinon MM:SS
|
||||
*/
|
||||
export function formatDurationLong(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de la pochette d'un morceau, avec fallback vers l'image par défaut
|
||||
*/
|
||||
export function getSongCoverUrl(song: SongInfo): string | undefined {
|
||||
return song.cover_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom complet d'un canal
|
||||
*/
|
||||
export function getChannelName(channelId: number): string {
|
||||
const channelNames: Record<number, string> = {
|
||||
0: "Main Mix",
|
||||
1: "Mellow Mix",
|
||||
2: "Rock Mix",
|
||||
3: "Eclectic Mix",
|
||||
};
|
||||
return channelNames[channelId] || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la description d'un canal
|
||||
*/
|
||||
export function getChannelDescription(channelId: number): string {
|
||||
const descriptions: Record<number, string> = {
|
||||
0: "Eclectic mix of rock, world, electronica, and more",
|
||||
1: "Mellower, less aggressive music",
|
||||
2: "Heavier, more guitar-driven music",
|
||||
3: "Curated worldwide selection",
|
||||
};
|
||||
return descriptions[channelId] || "";
|
||||
}
|
||||
111
pmoapp/webapp/src/stores/ui.ts
Normal file
111
pmoapp/webapp/src/stores/ui.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Store Pinia pour l'état UI global
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
message: string
|
||||
duration?: number // ms, undefined = permanent
|
||||
}
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
// État
|
||||
const selectedRendererId = ref<string | null>(null)
|
||||
const selectedServerId = ref<string | null>(null)
|
||||
const showEventLog = ref(false)
|
||||
const sseConnected = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// Actions
|
||||
function selectRenderer(id: string | null) {
|
||||
selectedRendererId.value = id
|
||||
}
|
||||
|
||||
function selectServer(id: string | null) {
|
||||
selectedServerId.value = id
|
||||
}
|
||||
|
||||
function toggleEventLog() {
|
||||
showEventLog.value = !showEventLog.value
|
||||
}
|
||||
|
||||
function setSSEConnected(connected: boolean) {
|
||||
sseConnected.value = connected
|
||||
}
|
||||
|
||||
function addNotification(
|
||||
type: Notification['type'],
|
||||
message: string,
|
||||
duration?: number
|
||||
) {
|
||||
const id = `notif-${Date.now()}-${Math.random()}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
duration,
|
||||
}
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
// Auto-remove après duration (défaut: 5s)
|
||||
const timeout = duration !== undefined ? duration : 5000
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function removeNotification(id: string) {
|
||||
const index = notifications.value.findIndex(n => n.id === id)
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
// Raccourcis pour les types de notifications
|
||||
function notifySuccess(message: string, duration?: number) {
|
||||
return addNotification('success', message, duration)
|
||||
}
|
||||
|
||||
function notifyError(message: string, duration?: number) {
|
||||
return addNotification('error', message, duration || 7000) // 7s pour erreurs
|
||||
}
|
||||
|
||||
function notifyWarning(message: string, duration?: number) {
|
||||
return addNotification('warning', message, duration)
|
||||
}
|
||||
|
||||
function notifyInfo(message: string, duration?: number) {
|
||||
return addNotification('info', message, duration)
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
selectedRendererId,
|
||||
selectedServerId,
|
||||
showEventLog,
|
||||
sseConnected,
|
||||
notifications,
|
||||
// Actions
|
||||
selectRenderer,
|
||||
selectServer,
|
||||
toggleEventLog,
|
||||
setSSEConnected,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
}
|
||||
})
|
||||
239
pmoapp/webapp/src/views/DashboardView.vue
Normal file
239
pmoapp/webapp/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
|
||||
const {
|
||||
allRenderers: renderers,
|
||||
onlineRenderers,
|
||||
getStateById,
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot
|
||||
} = useRenderers()
|
||||
|
||||
const {
|
||||
allServers: mediaServers,
|
||||
onlineServers,
|
||||
fetchServers
|
||||
} = useMediaServers()
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await fetchRenderers()
|
||||
await fetchServers()
|
||||
|
||||
for (const renderer of renderers.value) {
|
||||
fetchRendererSnapshot(renderer.id, { force: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-view">
|
||||
<!-- Header -->
|
||||
<header class="dashboard-header">
|
||||
<h1 class="dashboard-title">PMOControl Dashboard</h1>
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat">
|
||||
<Radio :size="20" />
|
||||
<span>{{ onlineRenderers.length }} / {{ renderers.length }} renderers</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<Server :size="20" />
|
||||
<span>{{ onlineServers.length }} / {{ mediaServers.length }} serveurs</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Renderers Section -->
|
||||
<section class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<Radio :size="24" />
|
||||
<span>Renderers Audio</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="renderers.length" class="renderers-grid">
|
||||
<RendererCard
|
||||
v-for="renderer in renderers"
|
||||
:key="renderer.id"
|
||||
:renderer="renderer"
|
||||
:state="getStateById(renderer.id) ?? null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>Aucun renderer découvert</p>
|
||||
<button class="btn btn-secondary" @click="fetchRenderers(true)">
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Servers Section -->
|
||||
<section class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<Server :size="24" />
|
||||
<span>Serveurs de Médias</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="mediaServers.length" class="servers-grid">
|
||||
<MediaServerCard
|
||||
v-for="server in mediaServers"
|
||||
:key="server.id"
|
||||
:server="server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>Aucun serveur de médias découvert</p>
|
||||
<button class="btn btn-secondary" @click="fetchServers(true)">
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.dashboard-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-view {
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
||||
import { ArrowLeft, Server } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getServerById } = useMediaServers()
|
||||
|
||||
const serverId = computed(() => route.params.serverId as string)
|
||||
const containerId = ref(route.query.container as string || '0')
|
||||
|
||||
const server = computed(() => getServerById(serverId.value))
|
||||
|
||||
// Watcher sur query.container pour mettre à jour containerId
|
||||
watch(
|
||||
() => route.query.container,
|
||||
(newContainer) => {
|
||||
containerId.value = (newContainer as string) || '0'
|
||||
}
|
||||
)
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function handleNavigate(newContainerId: string) {
|
||||
router.push({
|
||||
name: 'MediaServer',
|
||||
params: { serverId: serverId.value },
|
||||
query: { container: newContainerId }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-server-view">
|
||||
<!-- Header -->
|
||||
<header class="server-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="server-info">
|
||||
<Server :size="24" class="server-icon" />
|
||||
<div class="server-details">
|
||||
<h1 class="server-name">{{ server?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="server-model">{{ server?.model_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="server" class="server-status" :class="{ online: server.online, offline: !server.online }">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">{{ server.online ? 'En ligne' : 'Hors ligne' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!server" class="loading-state">
|
||||
<p>Chargement du serveur...</p>
|
||||
</div>
|
||||
|
||||
<!-- Offline state -->
|
||||
<div v-else-if="!server.online" class="offline-state">
|
||||
<p>Ce serveur est actuellement hors ligne</p>
|
||||
<button class="btn btn-secondary" @click="goBack">
|
||||
Retour au dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="server-content">
|
||||
<MediaBrowser
|
||||
:serverId="serverId"
|
||||
:containerId="containerId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-server-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.server-status.online .status-dot {
|
||||
background-color: var(--status-playing);
|
||||
}
|
||||
|
||||
.server-status.online .status-label {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.server-status.offline .status-dot {
|
||||
background-color: var(--status-offline);
|
||||
}
|
||||
|
||||
.server-status.offline .status-label {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Loading & Offline states */
|
||||
.loading-state,
|
||||
.offline-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.offline-state p {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.server-content {
|
||||
flex: 1;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.media-server-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
516
pmoapp/webapp/src/views/RendererView.vue
Normal file
516
pmoapp/webapp/src/views/RendererView.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
addOpenHomeTrack,
|
||||
clearOpenHomePlaylist,
|
||||
getOpenHomePlaylist,
|
||||
playOpenHomeTrack,
|
||||
} from '@/services/openhomePlaylist'
|
||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const rendererId = computed(() => route.params.id as string)
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
||||
const openHomeSupported = computed(() => {
|
||||
const current = renderer.value
|
||||
if (!current) return false
|
||||
const caps = current.capabilities
|
||||
return (
|
||||
current.protocol === 'openhome' ||
|
||||
current.protocol === 'hybrid' ||
|
||||
caps?.has_oh_playlist === true
|
||||
)
|
||||
})
|
||||
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||
const ohLoading = ref(false)
|
||||
const ohError = ref<string | null>(null)
|
||||
const newOhUri = ref('')
|
||||
const newOhMeta = ref('')
|
||||
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||
|
||||
// Charger les données au montage si nécessaire
|
||||
onMounted(async () => {
|
||||
uiStore.selectRenderer(rendererId.value)
|
||||
selectRendererSnapshot(rendererId.value)
|
||||
|
||||
// Charger toutes les données du renderer
|
||||
if (!renderer.value) {
|
||||
await fetchRenderers()
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
|
||||
watch(
|
||||
openHomeSupported,
|
||||
async supported => {
|
||||
if (supported) {
|
||||
await refreshOhPlaylist()
|
||||
} else {
|
||||
ohPlaylist.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(rendererId, () => {
|
||||
ohPlaylist.value = null
|
||||
ohError.value = null
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
})
|
||||
|
||||
// Nettoyer la sélection au démontage
|
||||
onUnmounted(() => {
|
||||
uiStore.selectRenderer(null)
|
||||
selectRendererSnapshot(null)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
if (!renderer.value) return ''
|
||||
switch (renderer.value.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOhPlaylist() {
|
||||
if (!renderer.value || !openHomeSupported.value) return
|
||||
ohLoading.value = true
|
||||
ohError.value = null
|
||||
try {
|
||||
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
|
||||
} finally {
|
||||
ohLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhClear() {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await clearOpenHomePlaylist(renderer.value.id)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhPlay(trackId: number) {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await playOpenHomeTrack(renderer.value.id, trackId)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhAdd() {
|
||||
if (!renderer.value || !canAddOhTrack.value) return
|
||||
try {
|
||||
await addOpenHomeTrack(renderer.value.id, {
|
||||
uri: newOhUri.value.trim(),
|
||||
metadata: newOhMeta.value,
|
||||
play: false,
|
||||
})
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-view">
|
||||
<!-- Header -->
|
||||
<header class="renderer-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="renderer-info">
|
||||
<Radio :size="24" class="renderer-icon" />
|
||||
<div class="renderer-details">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!renderer || !state" class="loading-state">
|
||||
<p>Chargement du renderer...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="renderer-content">
|
||||
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||
<div class="left-column">
|
||||
<!-- Current Track -->
|
||||
<section class="content-section">
|
||||
<CurrentTrack :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Transport Controls -->
|
||||
<section class="content-section">
|
||||
<TransportControls :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<section class="content-section">
|
||||
<h3 class="section-subtitle">Volume</h3>
|
||||
<VolumeControl :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Playlist Binding -->
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<section v-if="openHomeSupported" class="content-section openhome-playlist">
|
||||
<h2>OpenHome Playlist</h2>
|
||||
|
||||
<div v-if="ohLoading">Chargement de la playlist…</div>
|
||||
<div v-else-if="ohError" class="error">{{ ohError }}</div>
|
||||
|
||||
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
|
||||
Playlist vide.
|
||||
</div>
|
||||
|
||||
<div v-else-if="ohPlaylist">
|
||||
<table class="oh-playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Artiste</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="track in ohPlaylist.tracks"
|
||||
:key="track.id"
|
||||
:class="{ current: ohPlaylist.current_id === track.id }"
|
||||
>
|
||||
<td>{{ track.id }}</td>
|
||||
<td>{{ track.title || '—' }}</td>
|
||||
<td>{{ track.artist || '—' }}</td>
|
||||
<td>{{ track.album || '—' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
|
||||
▶
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="oh-controls">
|
||||
<button class="btn btn-secondary" @click="refreshOhPlaylist">
|
||||
🔁 Rafraîchir
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="handleOhClear">
|
||||
🗑 Effacer la playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oh-add-form">
|
||||
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
||||
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
||||
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
||||
➕ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
<div class="right-column">
|
||||
<section class="content-section queue-section">
|
||||
<QueueViewer :rendererId="rendererId" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.renderer-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.renderer-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-column,
|
||||
.right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.openhome-playlist h2 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.oh-playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-playlist-table th,
|
||||
.oh-playlist-table td {
|
||||
padding: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.oh-playlist-table tbody tr:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.oh-playlist-table tr.current {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oh-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.oh-add-form input,
|
||||
.oh-add-form textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oh-add-form button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 400px 1fr;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Tablet */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.left-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.renderer-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -29,10 +29,11 @@ rand = "0.8"
|
||||
# HTTP streaming dependencies
|
||||
bytes = { version = "1.0", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
|
||||
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"]
|
||||
all = ["cache-sink", "playlist", "http-stream"]
|
||||
|
||||
@@ -22,15 +22,21 @@
|
||||
//! Aucune des crates ci-dessus ne dépend de `pmoaudio-ext`, évitant ainsi
|
||||
//! tout cycle de dépendances.
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub mod sinks;
|
||||
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub mod nodes;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub mod sources;
|
||||
|
||||
// Re-exports pour faciliter l'utilisation
|
||||
#[cfg(feature = "cache-sink")]
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub use sinks::*;
|
||||
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub use nodes::*;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub use sources::*;
|
||||
|
||||
5
pmoaudio-ext/src/nodes/mod.rs
Normal file
5
pmoaudio-ext/src/nodes/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub mod track_boundary_cover_node;
|
||||
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub use track_boundary_cover_node::TrackBoundaryCoverNode;
|
||||
168
pmoaudio-ext/src/nodes/track_boundary_cover_node.rs
Normal file
168
pmoaudio-ext/src/nodes/track_boundary_cover_node.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Node de conversion qui s'assure que chaque `TrackBoundary` possède un `cover_pk`.
|
||||
//!
|
||||
//! Il laisse passer tous les segments audio de manière transparente. Lorsqu'un
|
||||
//! `TrackBoundary` est détecté, il vérifie si ses métadonnées contiennent déjà
|
||||
//! un `cover_pk`. Si ce n'est pas le cas mais qu'une `cover_url` est disponible,
|
||||
//! l'image est sauvegardée dans le cache de couvertures puis la clé primaire est
|
||||
//! écrite dans les métadonnées avant de poursuivre la propagation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic, PipelineHandle},
|
||||
AudioSegment, TypeRequirement, TypedAudioNode,
|
||||
};
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmometadata::TrackMetadata;
|
||||
use tokio::select;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Node enveloppe qui applique [`TrackBoundaryCoverLogic`].
|
||||
pub struct TrackBoundaryCoverNode {
|
||||
inner: Node<TrackBoundaryCoverLogic>,
|
||||
}
|
||||
|
||||
impl TrackBoundaryCoverNode {
|
||||
/// Crée un nouveau node.
|
||||
pub fn new(cover_cache: Arc<CoverCache>) -> Self {
|
||||
let logic = TrackBoundaryCoverLogic::new(cover_cache);
|
||||
Self {
|
||||
inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for TrackBoundaryCoverNode {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child);
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
|
||||
fn start(self: Box<Self>) -> PipelineHandle {
|
||||
Box::new(self.inner).start()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for TrackBoundaryCoverNode {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackBoundaryCoverLogic {
|
||||
cover_cache: Arc<CoverCache>,
|
||||
}
|
||||
|
||||
impl TrackBoundaryCoverLogic {
|
||||
fn new(cover_cache: Arc<CoverCache>) -> Self {
|
||||
Self { cover_cache }
|
||||
}
|
||||
|
||||
async fn ensure_cover_pk(&self, metadata: Arc<RwLock<dyn TrackMetadata>>) {
|
||||
let cover_url = {
|
||||
let guard = metadata.read().await;
|
||||
|
||||
match guard.get_cover_pk().await {
|
||||
Ok(Some(pk)) => {
|
||||
debug!("TrackBoundaryCoverNode: cover_pk already set ({})", pk);
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => warn!("TrackBoundaryCoverNode: cannot read cover_pk: {}", err),
|
||||
}
|
||||
|
||||
match guard.get_cover_url().await {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
warn!("TrackBoundaryCoverNode: cannot read cover_url: {}", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cover_url = match cover_url {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
debug!("TrackBoundaryCoverNode: no cover_url present, skipping cache");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match self.cover_cache.add_from_url(&cover_url, None).await {
|
||||
Ok(pk) => {
|
||||
debug!(
|
||||
"TrackBoundaryCoverNode: cached cover for url={}, pk={}",
|
||||
cover_url, pk
|
||||
);
|
||||
let mut guard = metadata.write().await;
|
||||
if let Err(err) = guard.set_cover_pk(Some(pk.clone())).await {
|
||||
warn!(
|
||||
"TrackBoundaryCoverNode: failed to set cover_pk {}: {}",
|
||||
pk, err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"TrackBoundaryCoverNode: failed to cache cover from {}: {}",
|
||||
cover_url, err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for TrackBoundaryCoverLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut input = input.ok_or_else(|| {
|
||||
AudioError::ProcessingError(
|
||||
"TrackBoundaryCoverNode requires an upstream input channel".into(),
|
||||
)
|
||||
})?;
|
||||
let node_name = std::any::type_name::<Self>();
|
||||
|
||||
loop {
|
||||
let segment = select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
debug!("TrackBoundaryCoverNode: stop requested");
|
||||
break;
|
||||
}
|
||||
segment = input.recv() => segment,
|
||||
};
|
||||
|
||||
let Some(segment) = segment else {
|
||||
debug!("TrackBoundaryCoverNode: upstream closed");
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(metadata) = segment.as_track_metadata() {
|
||||
self.ensure_cover_pk(Arc::clone(metadata)).await;
|
||||
}
|
||||
|
||||
send_to_children(node_name, &output, segment).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
60
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal file
60
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Shared broadcast pacing logic for streaming sinks.
|
||||
//!
|
||||
//! Provides intelligent backpressure based on audio timing:
|
||||
//! - Detects TopZeroSync (when audio timestamp resets to 0)
|
||||
//! - Drops frames that are late (audio_ts < elapsed)
|
||||
//! - Paces broadcast to match audio playback rate
|
||||
|
||||
use std::time::Instant;
|
||||
use tracing::trace;
|
||||
|
||||
/// Error returned when a frame should be skipped (too late)
|
||||
#[derive(Debug)]
|
||||
pub struct SkipFrame;
|
||||
|
||||
/// Manages broadcast pacing with TopZeroSync detection
|
||||
#[allow(dead_code)]
|
||||
pub struct BroadcastPacer {
|
||||
/// Start time (reset on TopZeroSync)
|
||||
start_time: Instant,
|
||||
/// Maximum allowed lead time before sleeping (0 = no pacing)
|
||||
max_lead_time: f64,
|
||||
/// Label for logging (e.g., "FLAC" or "OGG")
|
||||
label: String,
|
||||
/// Pending reset flag - will reset timer on next chunk
|
||||
pending_reset: bool,
|
||||
}
|
||||
|
||||
impl BroadcastPacer {
|
||||
/// Create a new broadcast pacer
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max_lead_time` - Maximum lead time in seconds (0 = no pacing)
|
||||
/// * `label` - Label for logging
|
||||
pub fn new(max_lead_time: f64, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
max_lead_time: max_lead_time.max(0.0),
|
||||
label: label.into(),
|
||||
pending_reset: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check timing and apply pacing - NO-OP VERSION
|
||||
///
|
||||
/// Pacing is now handled entirely by the expiration-based system in
|
||||
/// TimedBroadcast. This method is kept for backward compatibility
|
||||
/// but always returns Ok(()).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - Always returns `Ok(())`
|
||||
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
|
||||
trace!(
|
||||
"{} broadcaster: check_and_pace called with audio_ts={:.3}s (no-op - pacing handled by TimedBroadcast)",
|
||||
self.label, audio_timestamp
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
98
pmoaudio-ext/src/sinks/byte_stream_reader.rs
Normal file
98
pmoaudio-ext/src/sinks/byte_stream_reader.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::io;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
io::{AsyncRead, ReadBuf},
|
||||
sync::{mpsc, RwLock},
|
||||
};
|
||||
|
||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||
#[derive(Debug)]
|
||||
pub struct PcmChunk {
|
||||
/// Raw PCM audio bytes
|
||||
pub bytes: Vec<u8>,
|
||||
/// Timestamp in seconds (from AudioSegment)
|
||||
pub timestamp_sec: f64,
|
||||
/// Duration in seconds of this PCM chunk (samples / sample_rate)
|
||||
pub duration_sec: f64,
|
||||
}
|
||||
|
||||
/// AsyncRead adapter for mpsc::Receiver<PcmChunk>.
|
||||
/// Extracts bytes from PcmChunk and provides them to the FLAC encoder.
|
||||
pub struct ByteStreamReader {
|
||||
rx: mpsc::Receiver<PcmChunk>,
|
||||
buffer: VecDeque<u8>,
|
||||
finished: bool,
|
||||
/// Shared timestamp for broadcaster pacing
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
/// Shared duration for broadcaster pacing
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
}
|
||||
|
||||
impl ByteStreamReader {
|
||||
pub fn new(
|
||||
rx: mpsc::Receiver<PcmChunk>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buffer: VecDeque::new(),
|
||||
finished: false,
|
||||
current_timestamp,
|
||||
current_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ByteStreamReader {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
loop {
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
if to_copy == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let slice = self.buffer.make_contiguous();
|
||||
buf.put_slice(&slice[..to_copy]);
|
||||
self.buffer.drain(..to_copy);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
match Pin::new(&mut self.rx).poll_recv(cx) {
|
||||
Poll::Ready(Some(chunk)) => {
|
||||
if chunk.bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Update shared timestamp and duration for broadcaster pacing
|
||||
if let Ok(mut ts) = self.current_timestamp.try_write() {
|
||||
*ts = chunk.timestamp_sec;
|
||||
}
|
||||
if let Ok(mut dur) = self.current_duration.try_write() {
|
||||
*dur = chunk.duration_sec;
|
||||
}
|
||||
self.buffer.extend(chunk.bytes);
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
self.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
pmoaudio-ext/src/sinks/chunk_to_pcm.rs
Normal file
97
pmoaudio-ext/src/sinks/chunk_to_pcm.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use pmoaudio::{AudioChunk, AudioError};
|
||||
|
||||
/// Convert an AudioChunk to PCM bytes with specified bit depth.
|
||||
pub(crate) fn chunk_to_pcm_bytes(
|
||||
chunk: &AudioChunk,
|
||||
bits_per_sample: u8,
|
||||
) -> Result<Vec<u8>, AudioError> {
|
||||
match chunk {
|
||||
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"StreamingFlacSink only supports integer audio chunks".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let len = chunk.len();
|
||||
let bytes_per_frame = (bits_per_sample / 8) as usize * 2;
|
||||
let mut bytes = Vec::with_capacity(len * bytes_per_frame);
|
||||
|
||||
match (chunk, bits_per_sample) {
|
||||
(AudioChunk::I16(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 8;
|
||||
let right = (frame[1] as i32) << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 16;
|
||||
let right = (frame[1] as i32) << 16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0].as_i32() >> 8) as i16;
|
||||
let right = (frame[1].as_i32() >> 8) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0].as_i32() << 8;
|
||||
let right = frame[1].as_i32() << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] >> 16) as i16;
|
||||
let right = (frame[1] >> 16) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0] >> 8;
|
||||
let right = frame[1] >> 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bits_per_sample: {}",
|
||||
bits_per_sample
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -34,13 +34,6 @@ use tokio_util::sync::CancellationToken;
|
||||
// FlacCacheSinkLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté.
|
||||
enum StopReason {
|
||||
TrackBoundary(Arc<RwLock<dyn pmometadata::TrackMetadata>>),
|
||||
EndOfStream,
|
||||
ChannelClosed,
|
||||
}
|
||||
|
||||
/// Logique pure d'encodage FLAC vers le cache
|
||||
pub struct FlacCacheSinkLogic {
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
@@ -93,10 +86,16 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
|
||||
loop {
|
||||
// Attendre le premier chunk audio pour cette track
|
||||
tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Waiting for first audio chunk (track_number={})",
|
||||
track_number
|
||||
);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take()
|
||||
{
|
||||
// On a déjà reçu le TrackBoundary en Phase 3 de la track précédente
|
||||
tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"
|
||||
);
|
||||
// Attendre juste le premier chunk
|
||||
match wait_for_first_audio_chunk(&mut rx, &stop_token).await {
|
||||
Ok(chunk) => {
|
||||
@@ -194,6 +193,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
|
||||
// Phase 1: Dispatcher jusqu'à ce que le prebuffer soit terminé
|
||||
let mut end_of_stream_received = false;
|
||||
let mut early_track_boundary_received = false;
|
||||
let mut track_tx_opt = Some(track_tx);
|
||||
let pk = loop {
|
||||
tokio::select! {
|
||||
@@ -215,9 +215,9 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(segment) => {
|
||||
// Si EndOfStream a été reçu, ignorer tous les segments suivants
|
||||
// Si EndOfStream ou TrackBoundary a été reçu, ignorer tous les segments suivants
|
||||
// et continuer à attendre cache_future
|
||||
if end_of_stream_received {
|
||||
if end_of_stream_received || early_track_boundary_received {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -233,10 +233,18 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
// TrackBoundary avant fin du prebuffer - track trop courte
|
||||
tracing::error!("FlacCacheSink: TrackBoundary received before prebuffer complete - track too short");
|
||||
return Err(AudioError::ProcessingError("Track too short for prebuffer".to_string()));
|
||||
SyncMarker::TrackBoundary { metadata } => {
|
||||
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
|
||||
);
|
||||
// Stocker les métadonnées pour la prochaine track
|
||||
next_track_metadata = Some(metadata.clone());
|
||||
// Fermer le track_tx pour que le pump se termine proprement
|
||||
track_tx_opt = None;
|
||||
// Marquer qu'on a reçu un TrackBoundary précoce
|
||||
early_track_boundary_received = true;
|
||||
// Continuer à attendre cache_future
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete");
|
||||
@@ -256,7 +264,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
None => {
|
||||
// EOF sur rx pendant le prebuffer - attendre que cache_future se termine
|
||||
if !end_of_stream_received {
|
||||
if !end_of_stream_received && !early_track_boundary_received {
|
||||
tracing::debug!("FlacCacheSink: EOF on rx during prebuffer, waiting for ingestion to complete");
|
||||
track_tx_opt = None;
|
||||
end_of_stream_received = true;
|
||||
@@ -274,6 +282,95 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(transform) = self.cache.transform_metadata(&pk).await {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Got transform metadata for pk {}: sr={:?}, bps={:?}, ch={:?}, ts={:?}",
|
||||
pk,
|
||||
transform.sample_rate,
|
||||
transform.bits_per_sample,
|
||||
transform.channels,
|
||||
transform.total_samples
|
||||
);
|
||||
|
||||
// Persister les métadonnées techniques via l'interface TrackMetadata
|
||||
let track_meta = self.cache.track_metadata(&pk);
|
||||
let mut meta = track_meta.write().await;
|
||||
|
||||
if let Some(sr) = transform.sample_rate {
|
||||
if let Err(e) = meta.set_sample_rate(Some(sr)).await {
|
||||
tracing::error!(
|
||||
"FlacCacheSink: Failed to set sample_rate for pk {}: {:?}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk);
|
||||
}
|
||||
}
|
||||
if let Some(bps) = transform.bits_per_sample {
|
||||
if let Err(e) = meta.set_bits_per_sample(Some(bps)).await {
|
||||
tracing::error!(
|
||||
"FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = transform.channels {
|
||||
if let Err(e) = meta.set_channels(Some(ch)).await {
|
||||
tracing::error!(
|
||||
"FlacCacheSink: Failed to set channels for pk {}: {:?}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk);
|
||||
}
|
||||
}
|
||||
if let Some(ts) = transform.total_samples {
|
||||
if let Err(e) = meta.set_total_samples(Some(ts)).await {
|
||||
tracing::error!(
|
||||
"FlacCacheSink: Failed to set total_samples for pk {}: {:?}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk);
|
||||
}
|
||||
|
||||
// Calculer la durée à partir de total_samples et sample_rate
|
||||
if let Some(sr) = transform.sample_rate {
|
||||
if sr > 0 {
|
||||
use std::time::Duration;
|
||||
let secs = (ts as f64 / sr as f64).round() as u64;
|
||||
if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await
|
||||
{
|
||||
tracing::error!(
|
||||
"FlacCacheSink: Failed to set duration for pk {}: {:?}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Set duration={} secs for pk {}",
|
||||
secs,
|
||||
pk
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(meta); // Libérer le lock explicitement
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: No transform metadata available for pk {}",
|
||||
pk
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist
|
||||
// Copier les métadonnées du TrackBoundary dans le cache
|
||||
// IMPORTANT: Faire ceci AVANT d'ajouter à la playlist pour que les métadonnées soient disponibles
|
||||
@@ -290,24 +387,72 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = match dest_metadata.read().await.get_cover_url().await {
|
||||
Ok(url) => {
|
||||
tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url);
|
||||
url
|
||||
let cover_pk_present = match dest_metadata.read().await.get_cover_pk().await {
|
||||
Ok(Some(existing_pk)) => {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: cover_pk already set for audio asset {} ({})",
|
||||
pk,
|
||||
existing_pk
|
||||
);
|
||||
true
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e);
|
||||
None
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Transient error getting cover_pk for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e);
|
||||
None
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover_pk for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let url = if cover_pk_present {
|
||||
None
|
||||
} else {
|
||||
match dest_metadata.read().await.get_cover_url().await {
|
||||
Ok(url) => {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Got cover URL for pk {}: {:?}",
|
||||
pk,
|
||||
url
|
||||
);
|
||||
url
|
||||
}
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cover_url) = url {
|
||||
tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url);
|
||||
match self.covers
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Attempting to cache cover from URL: {}",
|
||||
cover_url
|
||||
);
|
||||
match self
|
||||
.covers
|
||||
.add_from_url(&cover_url, self.collection.as_deref())
|
||||
.await
|
||||
{
|
||||
@@ -323,7 +468,11 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e);
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Failed to cache cover for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -339,20 +488,37 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||
})?;
|
||||
tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed());
|
||||
tracing::info!(
|
||||
"FlacCacheSink: Successfully pushed to playlist in {:?}",
|
||||
push_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
// Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité
|
||||
// Il faut juste attendre que le pump se termine et retourner
|
||||
if end_of_stream_received {
|
||||
tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: EndOfStream was received during prebuffer, track complete"
|
||||
);
|
||||
drop(pump_handle);
|
||||
track_number += 1;
|
||||
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
|
||||
}
|
||||
|
||||
// Si TrackBoundary précoce a été reçu pendant le prebuffer, passer à la track suivante
|
||||
if early_track_boundary_received {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: TrackBoundary was received during prebuffer, track complete, moving to next track"
|
||||
);
|
||||
drop(pump_handle);
|
||||
track_number += 1;
|
||||
continue; // Passer à la track suivante (métadonnées déjà stockées dans next_track_metadata)
|
||||
}
|
||||
|
||||
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
|
||||
tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
|
||||
);
|
||||
let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false
|
||||
let mut pump_handle = Some(pump_handle);
|
||||
let mut pump_closed = false;
|
||||
@@ -384,7 +550,9 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
if tx.send(segment).await.is_err() {
|
||||
// Le pump a fermé son channel - cela peut arriver si le fichier
|
||||
// était déjà en cache (add_from_reader retourne immédiatement)
|
||||
tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: pump closed track_tx, checking pump status"
|
||||
);
|
||||
drop(track_tx.take());
|
||||
|
||||
// Attendre que le pump se termine et vérifier le résultat
|
||||
@@ -397,13 +565,21 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Le pump a rencontré une erreur
|
||||
tracing::error!("FlacCacheSink: pump died with error: {}", e);
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump died with error: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
// Le pump task a paniqué
|
||||
tracing::error!("FlacCacheSink: pump task panicked: {}", e);
|
||||
return Err(AudioError::ProcessingError("Pump task panicked".to_string()));
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump task panicked: {}",
|
||||
e
|
||||
);
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Pump task panicked".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,7 +710,9 @@ async fn wait_for_first_audio_chunk(
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
// On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà
|
||||
tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk");
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
@@ -600,144 +778,6 @@ async fn wait_for_first_audio_chunk_with_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
/// Draine tous les segments jusqu'au prochain TrackBoundary ou EndOfStream
|
||||
///
|
||||
/// Cette fonction est utilisée quand le fichier était déjà en cache et que
|
||||
/// nous devons ignorer les segments restants pour rester synchronisé avec la source.
|
||||
async fn drain_until_track_boundary(
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<StopReason, AudioError> {
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
return Ok(StopReason::ChannelClosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
return Ok(StopReason::ChannelClosed);
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Chunk(_) => {
|
||||
// Ignorer les chunks audio
|
||||
continue;
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
return Ok(StopReason::TrackBoundary(metadata.clone()));
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
return Ok(StopReason::EndOfStream);
|
||||
}
|
||||
_ => {
|
||||
// Ignorer les autres syncmarkers
|
||||
continue;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||
async fn pump_track_segments(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||
bits_per_sample: u8,
|
||||
expected_rate: u32,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(u64, u64, f64, StopReason), AudioError> {
|
||||
let mut chunks = 0u64;
|
||||
let mut samples = 0u64;
|
||||
let mut duration_sec = 0.0f64;
|
||||
|
||||
// Traiter le premier segment
|
||||
if let Some(chunk) = first_segment.as_chunk() {
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if !pcm_bytes.is_empty() {
|
||||
// Si le send échoue, c'est que le receiver est fermé
|
||||
// (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement)
|
||||
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Boucle sur les segments suivants
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
// Vérifier la cohérence du sample rate
|
||||
if chunk.sample_rate() != expected_rate {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"FlacCacheSink: inconsistent sample rate ({} vs {})",
|
||||
chunk.sample_rate(),
|
||||
expected_rate
|
||||
)));
|
||||
}
|
||||
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?;
|
||||
if pcm_bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Si le send échoue, c'est que le receiver est fermé
|
||||
// (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement)
|
||||
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((
|
||||
chunks,
|
||||
samples,
|
||||
duration_sec,
|
||||
StopReason::TrackBoundary(metadata.clone()),
|
||||
));
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream));
|
||||
}
|
||||
_ => {} // Ignorer les autres syncmarkers
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pompe les segments pour une seule track depuis un channel dédié.
|
||||
///
|
||||
/// Cette version permet d'avoir plusieurs pumps en parallèle (pour cache progressif),
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
//! Frame header validation includes CRC-8 verification as per FLAC specification
|
||||
//! to eliminate false positives that would cause decoder errors.
|
||||
|
||||
use pmoaudio::AudioError;
|
||||
use pmoflac::FlacEncodedStream;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// State for FLAC stream subscription.
|
||||
pub(crate) enum FlacStreamState {
|
||||
SendingHeader,
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// Validate and parse FLAC block size from frame header
|
||||
///
|
||||
/// Returns the number of samples in the frame if the header is valid, or None if:
|
||||
@@ -365,6 +375,103 @@ pub(crate) fn find_complete_frames_with_samples(data: &[u8]) -> (usize, u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract sample rate from STREAMINFO block in FLAC header
|
||||
pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioError> {
|
||||
// Verify we have at least "fLaC" magic + STREAMINFO block header
|
||||
if flac_header.len() < 8 {
|
||||
return Err(AudioError::ProcessingError("FLAC header too short".into()));
|
||||
}
|
||||
|
||||
if &flac_header[0..4] != b"fLaC" {
|
||||
return Err(AudioError::ProcessingError("Invalid FLAC magic".into()));
|
||||
}
|
||||
|
||||
// First metadata block should be STREAMINFO (type 0)
|
||||
let block_type = flac_header[4] & 0x7F;
|
||||
if block_type != 0 {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"First block is not STREAMINFO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// STREAMINFO data starts at offset 8 (after magic + block header)
|
||||
// Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header)
|
||||
if flac_header.len() < 21 {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"STREAMINFO block truncated".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Sample rate: 20 bits starting at byte 10 of STREAMINFO
|
||||
// Format: [byte10: SSSSSSSS] [byte11: SSSSSSSS] [byte12: SSSSCCCC]
|
||||
// S = sample rate bits, C = channels bits
|
||||
let byte10 = flac_header[18] as u32;
|
||||
let byte11 = flac_header[19] as u32;
|
||||
let byte12 = flac_header[20] as u32;
|
||||
|
||||
// Extract 20 bits for sample rate (top 20 bits of 3 bytes)
|
||||
let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid sample rate (0)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sample_rate)
|
||||
}
|
||||
|
||||
/// Read FLAC header (fLaC + all metadata blocks until first frame)
|
||||
pub(crate) async fn read_flac_header(
|
||||
stream: &mut FlacEncodedStream,
|
||||
) -> Result<Vec<u8>, AudioError> {
|
||||
let mut header = Vec::new();
|
||||
let mut buffer = [0u8; 4];
|
||||
|
||||
// Read "fLaC" magic
|
||||
stream
|
||||
.read_exact(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?;
|
||||
|
||||
if &buffer != b"fLaC" {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid FLAC stream: missing fLaC magic".into(),
|
||||
));
|
||||
}
|
||||
|
||||
header.extend_from_slice(&buffer);
|
||||
|
||||
// Read metadata blocks
|
||||
loop {
|
||||
// Read metadata block header (1 byte type + 3 bytes length)
|
||||
let mut block_header = [0u8; 4];
|
||||
stream.read_exact(&mut block_header).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to read metadata block header: {}", e))
|
||||
})?;
|
||||
|
||||
let is_last = (block_header[0] & 0x80) != 0;
|
||||
let block_length =
|
||||
u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
|
||||
|
||||
header.extend_from_slice(&block_header);
|
||||
|
||||
// Read metadata block data
|
||||
let mut block_data = vec![0u8; block_length];
|
||||
stream.read_exact(&mut block_data).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to read metadata block data: {}", e))
|
||||
})?;
|
||||
|
||||
header.extend_from_slice(&block_data);
|
||||
|
||||
if is_last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -374,8 +481,8 @@ mod tests {
|
||||
// Real-world example: first frame at 0, false positive at 7
|
||||
let data = vec![
|
||||
0xFF, 0xF8, 0xC9, 0xA8, // Valid frame header at position 0
|
||||
0x00, 0x8D, 0x4C,
|
||||
0xFF, 0xFE, 0x00, 0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
0x00, 0x8D, 0x4C, 0xFF, 0xFE, 0x00,
|
||||
0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
];
|
||||
|
||||
// Position 0 should be valid
|
||||
|
||||
@@ -4,23 +4,42 @@
|
||||
//! et ne peuvent pas être placés directement dans pmoaudio sans créer
|
||||
//! de dépendances cycliques.
|
||||
|
||||
pub mod byte_stream_reader;
|
||||
pub mod chunk_to_pcm;
|
||||
pub mod streaming_icyflac_sink;
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
mod flac_cache_sink;
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod broadcast_pacing;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod flac_frame_utils;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod timed_broadcast;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod streaming_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream};
|
||||
mod streaming_sink_common;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_flac_sink::{FlacClientStream, StreamHandle, StreamingFlacSink};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_icyflac_sink::IcyClientStream;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod streaming_ogg_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream};
|
||||
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_sink_common::{MetadataSnapshot, StreamingSinkOptions};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
255
pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs
Normal file
255
pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
io::{AsyncRead, ReadBuf},
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
sinks::{
|
||||
flac_frame_utils::FlacStreamState,
|
||||
streaming_sink_common::SharedStreamHandleInner,
|
||||
timed_broadcast::{self, TryRecvError},
|
||||
},
|
||||
MetadataSnapshot,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use std::io;
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// ICY-wrapped FLAC client stream (implements AsyncRead).
|
||||
///
|
||||
/// This stream injects ICY metadata blocks at regular intervals,
|
||||
/// allowing clients to display "Now Playing" information.
|
||||
/// As with [`FlacClientStream`], hitting [`TryRecvError::Lagged`]
|
||||
/// simply indicates that the timed broadcast discarded a stale chunk;
|
||||
/// the client resumes with fresh data to avoid wedging the HTTP response.
|
||||
pub struct IcyClientStream {
|
||||
rx: timed_broadcast::Receiver<Bytes>,
|
||||
metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||
metaint: usize,
|
||||
byte_count: usize,
|
||||
buffer: VecDeque<u8>,
|
||||
current_metadata_version: u64,
|
||||
cached_icy_metadata: Bytes,
|
||||
finished: bool,
|
||||
handle: Arc<SharedStreamHandleInner>,
|
||||
state: FlacStreamState,
|
||||
current_epoch: u64,
|
||||
}
|
||||
|
||||
impl IcyClientStream {
|
||||
pub(crate) fn new(
|
||||
rx: timed_broadcast::Receiver<Bytes>,
|
||||
handle: Arc<SharedStreamHandleInner>,
|
||||
metaint: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
metadata: handle.metadata.clone(),
|
||||
metaint,
|
||||
byte_count: 0,
|
||||
buffer: VecDeque::new(),
|
||||
current_metadata_version: 0,
|
||||
cached_icy_metadata: Bytes::new(),
|
||||
finished: false,
|
||||
handle,
|
||||
state: FlacStreamState::SendingHeader,
|
||||
current_epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_epoch(&self) -> u64 {
|
||||
self.current_epoch
|
||||
}
|
||||
}
|
||||
|
||||
impl IcyClientStream {
|
||||
/// Format metadata as ICY metadata block.
|
||||
///
|
||||
/// ICY format: StreamTitle='Artist - Title';StreamUrl='url';
|
||||
/// Padded to multiple of 16 bytes, prefixed with length byte.
|
||||
///
|
||||
/// If cover_pk is available, constructs a URL for the cover image:
|
||||
/// - If pmoserver is initialized: http://server/covers/image/{pk}/256
|
||||
/// - Otherwise: relative URL /covers/image/{pk}/256
|
||||
fn format_icy_metadata(meta: &MetadataSnapshot) -> Bytes {
|
||||
let title = meta.title.as_deref().unwrap_or("Unknown");
|
||||
let artist = meta.artist.as_deref().unwrap_or("Unknown Artist");
|
||||
|
||||
// Build ICY metadata string with cover URL if available
|
||||
let mut metadata_str = format!("StreamTitle='{} - {}';", artist, title);
|
||||
|
||||
// Add cover URL if we have a cover_pk
|
||||
if let Some(pk) = &meta.cover_pk {
|
||||
// Use relative URL /covers/image/{pk}/256
|
||||
// This works when streaming from the same server that serves covers
|
||||
// VLC and other players will resolve relative URLs correctly
|
||||
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
|
||||
} else if let Some(url) = &meta.cover_url {
|
||||
// Fallback to external cover URL if no local pk
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", url));
|
||||
}
|
||||
|
||||
// ICY metadata is padded to multiple of 16 bytes
|
||||
let metadata_bytes = metadata_str.as_bytes();
|
||||
let length = metadata_bytes.len();
|
||||
let padded_length = ((length + 15) / 16) * 16;
|
||||
let length_byte = (padded_length / 16) as u8;
|
||||
|
||||
let mut result = Vec::with_capacity(1 + padded_length);
|
||||
result.push(length_byte);
|
||||
result.extend_from_slice(metadata_bytes);
|
||||
result.resize(1 + padded_length, 0); // Pad with zeros
|
||||
|
||||
Bytes::from(result)
|
||||
}
|
||||
|
||||
/// Get metadata block if it needs to be inserted.
|
||||
#[allow(dead_code)]
|
||||
async fn get_metadata_if_changed(&mut self) -> Option<Bytes> {
|
||||
let meta = self.metadata.read().await;
|
||||
if meta.version > self.current_metadata_version {
|
||||
self.current_metadata_version = meta.version;
|
||||
let icy_meta = Self::format_icy_metadata(&meta);
|
||||
self.cached_icy_metadata = icy_meta.clone();
|
||||
Some(icy_meta)
|
||||
} else if self.byte_count == 0 {
|
||||
// Always send metadata at the start
|
||||
Some(self.cached_icy_metadata.clone())
|
||||
} else {
|
||||
// No change, send empty metadata block
|
||||
Some(Bytes::from(vec![0u8]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for IcyClientStream {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
loop {
|
||||
// If in header state, send the header first
|
||||
if matches!(self.state, FlacStreamState::SendingHeader) {
|
||||
let header_opt = if let Ok(guard) = self.handle.header.try_read() {
|
||||
guard.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
debug!(
|
||||
"Sending cached FLAC header to new ICY client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
// Header not yet captured - client will receive it via broadcast
|
||||
// Skip directly to streaming to avoid blocking
|
||||
debug!(
|
||||
"FLAC header not yet available, ICY client will receive it via broadcast"
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have buffered data, copy it
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
if to_copy == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let slice = self.buffer.make_contiguous();
|
||||
buf.put_slice(&slice[..to_copy]);
|
||||
self.buffer.drain(..to_copy);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Check if we need to insert metadata
|
||||
if self.byte_count % self.metaint == 0 && self.byte_count > 0 {
|
||||
// Time to insert ICY metadata
|
||||
// Use try_read to avoid blocking in poll context
|
||||
let update = {
|
||||
if let Ok(meta) = self.metadata.try_read() {
|
||||
if meta.version > self.current_metadata_version {
|
||||
Some((meta.version, Self::format_icy_metadata(&meta)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((new_version, new_metadata)) = update {
|
||||
self.current_metadata_version = new_version;
|
||||
self.cached_icy_metadata = new_metadata;
|
||||
}
|
||||
|
||||
let icy_data = self.cached_icy_metadata.clone();
|
||||
self.buffer.extend(icy_data.iter());
|
||||
self.byte_count = 0; // Reset counter after metadata
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to receive audio data
|
||||
match self.rx.try_recv() {
|
||||
Ok(packet) => {
|
||||
self.current_epoch = packet.epoch;
|
||||
// Calculate how many bytes until next metadata block
|
||||
let until_metadata = self.metaint - (self.byte_count % self.metaint);
|
||||
let to_buffer = packet.payload.len().min(until_metadata);
|
||||
|
||||
self.buffer.extend(packet.payload[..to_buffer].iter());
|
||||
self.byte_count += to_buffer;
|
||||
|
||||
// If we have more data, we'll process it in the next iteration
|
||||
if to_buffer < packet.payload.len() {
|
||||
// Save remaining for next iteration
|
||||
// For now, we'll just drop it and get it again
|
||||
// TODO: Improve this
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
// No data available right now.
|
||||
// Schedule a wakeup after a small delay to avoid busy-loop polling.
|
||||
let waker = cx.waker().clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
waker.wake();
|
||||
});
|
||||
return Poll::Pending;
|
||||
}
|
||||
Err(TryRecvError::Lagged(skipped)) => {
|
||||
warn!("ICY client lagged, skipped {} messages", skipped);
|
||||
}
|
||||
Err(TryRecvError::Closed) => {
|
||||
self.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IcyClientStream {
|
||||
fn drop(&mut self) {
|
||||
let remaining = self.handle.client_disconnected();
|
||||
debug!("ICY client disconnected (remaining: {})", remaining);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
576
pmoaudio-ext/src/sinks/streaming_sink_common.rs
Normal file
576
pmoaudio-ext/src/sinks/streaming_sink_common.rs
Normal file
@@ -0,0 +1,576 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::AudioError;
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat};
|
||||
use pmometadata::TrackMetadata;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use crate::byte_stream_reader::{ByteStreamReader, PcmChunk};
|
||||
use crate::sinks::timed_broadcast::{self, TryRecvError};
|
||||
|
||||
/// Snapshot of track metadata shared across streaming sinks.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MetadataSnapshot {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<Duration>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_pk: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub track_number: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_artist: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genre: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
pub audio_timestamp_sec: f64,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
/// Configuration options shared by streaming sinks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StreamingSinkOptions {
|
||||
pub restart_encoder_on_track_boundary: bool,
|
||||
pub enable_total_samples: bool,
|
||||
pub default_title: Option<String>,
|
||||
pub default_artist: Option<String>,
|
||||
pub use_only_default_metadata: bool,
|
||||
pub server_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamingSinkOptions {
|
||||
pub fn flac_defaults() -> Self {
|
||||
Self {
|
||||
restart_encoder_on_track_boundary: false,
|
||||
enable_total_samples: false,
|
||||
default_title: None,
|
||||
default_artist: None,
|
||||
use_only_default_metadata: false,
|
||||
server_base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ogg_defaults() -> Self {
|
||||
Self {
|
||||
restart_encoder_on_track_boundary: true,
|
||||
enable_total_samples: true,
|
||||
default_title: None,
|
||||
default_artist: None,
|
||||
use_only_default_metadata: false,
|
||||
server_base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_restart(mut self, restart: bool) -> Self {
|
||||
self.restart_encoder_on_track_boundary = restart;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_total_samples(mut self, enable: bool) -> Self {
|
||||
self.enable_total_samples = enable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_default_title(mut self, title: impl Into<Option<String>>) -> Self {
|
||||
self.default_title = title.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_default_artist(mut self, artist: impl Into<Option<String>>) -> Self {
|
||||
self.default_artist = artist.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_only_default_metadata(mut self, only_default: bool) -> Self {
|
||||
self.use_only_default_metadata = only_default;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_server_base_url(mut self, url: impl Into<Option<String>>) -> Self {
|
||||
self.server_base_url = url.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared handle state for streaming sinks.
|
||||
pub struct SharedStreamHandleInner {
|
||||
pub broadcast: timed_broadcast::Sender<Bytes>,
|
||||
pub metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||
pub active_clients: Arc<AtomicUsize>,
|
||||
pub stop_token: CancellationToken,
|
||||
pub header: Arc<RwLock<Option<Bytes>>>,
|
||||
pub auto_stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl SharedStreamHandleInner {
|
||||
pub fn new(
|
||||
broadcast: timed_broadcast::Sender<Bytes>,
|
||||
metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||
stop_token: CancellationToken,
|
||||
header: Arc<RwLock<Option<Bytes>>>,
|
||||
auto_stop: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
broadcast,
|
||||
metadata,
|
||||
active_clients: Arc::new(AtomicUsize::new(0)),
|
||||
stop_token,
|
||||
header,
|
||||
auto_stop,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_client(&self) -> timed_broadcast::Receiver<Bytes> {
|
||||
self.broadcast.subscribe()
|
||||
}
|
||||
|
||||
pub fn client_connected(&self) -> usize {
|
||||
self.active_clients.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
pub fn client_disconnected(&self) -> usize {
|
||||
let prev = self.active_clients.fetch_sub(1, Ordering::SeqCst);
|
||||
let remaining = prev.saturating_sub(1);
|
||||
if prev == 1 && self.auto_stop.load(Ordering::SeqCst) {
|
||||
trace!("Last client disconnected, signaling pipeline stop (shared handle)");
|
||||
self.stop_token.cancel();
|
||||
}
|
||||
remaining
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamState {
|
||||
SendingHeader,
|
||||
Streaming,
|
||||
}
|
||||
|
||||
pub struct SharedClientStream {
|
||||
rx: timed_broadcast::Receiver<Bytes>,
|
||||
buffer: VecDeque<u8>,
|
||||
finished: bool,
|
||||
handle: Arc<SharedStreamHandleInner>,
|
||||
state: StreamState,
|
||||
current_epoch: u64,
|
||||
}
|
||||
|
||||
impl SharedClientStream {
|
||||
pub fn new(rx: timed_broadcast::Receiver<Bytes>, handle: Arc<SharedStreamHandleInner>) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buffer: VecDeque::new(),
|
||||
finished: false,
|
||||
handle,
|
||||
state: StreamState::SendingHeader,
|
||||
current_epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_epoch(&self) -> u64 {
|
||||
self.current_epoch
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> &Arc<SharedStreamHandleInner> {
|
||||
&self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for SharedClientStream {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
loop {
|
||||
if matches!(self.state, StreamState::SendingHeader) {
|
||||
let header_opt = if let Ok(guard) = self.handle.header.try_read() {
|
||||
guard.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
trace!(
|
||||
"Sending cached header to new client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = StreamState::Streaming;
|
||||
continue;
|
||||
} else {
|
||||
self.state = StreamState::Streaming;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
if to_copy == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let slice = self.buffer.make_contiguous();
|
||||
buf.put_slice(&slice[..to_copy]);
|
||||
self.buffer.drain(..to_copy);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
match self.rx.try_recv() {
|
||||
Ok(packet) => {
|
||||
self.current_epoch = packet.epoch;
|
||||
self.buffer.extend(packet.payload.iter());
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
let waker = cx.waker().clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
waker.wake();
|
||||
});
|
||||
return Poll::Pending;
|
||||
}
|
||||
Err(TryRecvError::Lagged(skipped)) => {
|
||||
warn!("Client lagged, skipped {} messages", skipped);
|
||||
}
|
||||
Err(TryRecvError::Closed) => {
|
||||
self.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EncoderState {
|
||||
pub broadcaster_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct SharedSinkContext {
|
||||
pub encoder_options: EncoderOptions,
|
||||
pub bits_per_sample: u8,
|
||||
/// Whether to propagate total_samples into STREAMINFO.
|
||||
/// For unbounded live streams (raw FLAC), this must stay false to avoid
|
||||
/// players stopping after they reach the advertised length.
|
||||
pub enable_total_samples: bool,
|
||||
pub restart_encoder_on_track_boundary: bool,
|
||||
pub default_title: Option<String>,
|
||||
pub default_artist: Option<String>,
|
||||
pub use_only_default_metadata: bool,
|
||||
pub pcm_tx: Option<mpsc::Sender<PcmChunk>>,
|
||||
pub pcm_rx: Option<mpsc::Receiver<PcmChunk>>,
|
||||
pub metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||
pub broadcast: timed_broadcast::Sender<Bytes>,
|
||||
pub header: Arc<RwLock<Option<Bytes>>>,
|
||||
pub encoder_state: Option<EncoderState>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub broadcast_max_lead_time: f64,
|
||||
pub first_chunk_timestamp_checked: bool,
|
||||
pub timestamp_offset_sec: f64,
|
||||
pub current_timestamp: Arc<RwLock<f64>>,
|
||||
pub pending_track_duration: Option<Duration>,
|
||||
pub pending_total_samples: Option<u64>,
|
||||
}
|
||||
|
||||
impl SharedSinkContext {
|
||||
pub async fn initialize_encoder<Fut, F>(
|
||||
&mut self,
|
||||
sample_rate: u32,
|
||||
timestamp_offset_sec: f64,
|
||||
broadcaster: F,
|
||||
) -> Result<(), AudioError>
|
||||
where
|
||||
F: FnOnce(
|
||||
FlacEncodedStream,
|
||||
timed_broadcast::Sender<Bytes>,
|
||||
Arc<RwLock<Option<Bytes>>>,
|
||||
Arc<RwLock<f64>>,
|
||||
Arc<RwLock<f64>>,
|
||||
f64,
|
||||
u32,
|
||||
f64,
|
||||
) -> Fut
|
||||
+ Send
|
||||
+ 'static,
|
||||
Fut: Future<Output = Result<(), AudioError>> + Send + 'static,
|
||||
{
|
||||
if self.encoder_state.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Initializing FLAC encoder with sample rate: {} Hz",
|
||||
sample_rate
|
||||
);
|
||||
|
||||
let pcm_rx = self
|
||||
.pcm_rx
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
let current_timestamp = self.current_timestamp.clone();
|
||||
let current_duration = Arc::new(RwLock::new(0.0f64));
|
||||
|
||||
let pcm_reader =
|
||||
ByteStreamReader::new(pcm_rx, current_timestamp.clone(), current_duration.clone());
|
||||
|
||||
let pcm_format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: self.bits_per_sample,
|
||||
};
|
||||
|
||||
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
||||
})?;
|
||||
|
||||
debug!("FLAC encoder initialized successfully");
|
||||
|
||||
let broadcast = self.broadcast.clone();
|
||||
let header = self.header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
let current_timestamp_clone = current_timestamp.clone();
|
||||
let current_duration_clone = current_duration.clone();
|
||||
|
||||
let broadcaster_task = tokio::spawn(async move {
|
||||
if let Err(e) = broadcaster(
|
||||
flac_stream,
|
||||
broadcast,
|
||||
header,
|
||||
current_timestamp_clone,
|
||||
current_duration_clone,
|
||||
max_lead,
|
||||
sample_rate,
|
||||
timestamp_offset_sec,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Broadcaster task error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.encoder_state = Some(EncoderState { broadcaster_task });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare encoder options for a new track so the next FLAC header embeds up-to-date metadata
|
||||
/// and duration (total_samples) when available.
|
||||
pub async fn prepare_encoder_options_for_track(
|
||||
&mut self,
|
||||
metadata_lock: &Arc<RwLock<dyn TrackMetadata>>,
|
||||
) -> Result<(), AudioError> {
|
||||
debug!("Encoder metadata: preparing metadata");
|
||||
// Always pass the metadata handle to the encoder so Vorbis comments are emitted.
|
||||
self.encoder_options.metadata = Some(metadata_lock.clone());
|
||||
|
||||
// Capture duration (if any) to set total_samples.
|
||||
let duration_opt = {
|
||||
let metadata = metadata_lock.read().await;
|
||||
metadata.get_duration().await.ok().flatten()
|
||||
};
|
||||
self.pending_track_duration = duration_opt;
|
||||
self.pending_total_samples = {
|
||||
let metadata = metadata_lock.read().await;
|
||||
metadata.get_total_samples().await.ok().flatten()
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}",
|
||||
self.pending_track_duration
|
||||
.as_ref()
|
||||
.map(|d| d.as_secs_f64()),
|
||||
self.pending_total_samples
|
||||
);
|
||||
|
||||
// In raw FLAC live streaming we must NOT advertise a total_samples value,
|
||||
// otherwise players think the stream ends after the first track.
|
||||
if !self.enable_total_samples {
|
||||
self.encoder_options.total_samples = None;
|
||||
debug!("Encoder metadata: total_samples disabled for this sink (enable_total_samples=false)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Compute total_samples only when we know the sample rate.
|
||||
self.refresh_total_samples_with_sample_rate();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh total_samples when the sample rate is learned after metadata was already set.
|
||||
pub fn refresh_total_samples_with_sample_rate(&mut self) {
|
||||
info!(
|
||||
"Encoder metadata: refresh_total_samples (pending_total_samples={:?}, pending_duration={:?}, sample_rate={:?})",
|
||||
self.pending_total_samples,
|
||||
self.pending_track_duration
|
||||
.as_ref()
|
||||
.map(Duration::as_secs_f64),
|
||||
self.sample_rate
|
||||
);
|
||||
|
||||
if !self.enable_total_samples {
|
||||
self.encoder_options.total_samples = None;
|
||||
info!("Encoder metadata: total_samples disabled for live streaming");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(total) = self.pending_total_samples {
|
||||
self.encoder_options.total_samples = Some(total);
|
||||
info!(
|
||||
"Encoder metadata: using provided total_samples={} from TrackBoundary",
|
||||
total
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let (Some(duration), Some(sr)) = (self.pending_track_duration, self.sample_rate) {
|
||||
let samples = (duration.as_secs_f64() * sr as f64).round() as u64;
|
||||
self.encoder_options.total_samples = Some(samples);
|
||||
info!(
|
||||
"Encoder metadata: computed total_samples={} (duration {:.3}s @ {} Hz)",
|
||||
samples,
|
||||
duration.as_secs_f64(),
|
||||
sr
|
||||
);
|
||||
} else {
|
||||
// Avoid leaking the previous track's length.
|
||||
self.encoder_options.total_samples = None;
|
||||
info!("Encoder metadata: no duration/total_samples available; clearing total_samples in encoder options");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restart_encoder_for_new_track<Fut, F>(
|
||||
&mut self,
|
||||
broadcaster: F,
|
||||
) -> Result<(), AudioError>
|
||||
where
|
||||
F: FnOnce(
|
||||
FlacEncodedStream,
|
||||
timed_broadcast::Sender<Bytes>,
|
||||
Arc<RwLock<Option<Bytes>>>,
|
||||
Arc<RwLock<f64>>,
|
||||
Arc<RwLock<f64>>,
|
||||
f64,
|
||||
u32,
|
||||
f64,
|
||||
) -> Fut
|
||||
+ Send
|
||||
+ 'static,
|
||||
Fut: Future<Output = Result<(), AudioError>> + Send + 'static,
|
||||
{
|
||||
let sample_rate = self
|
||||
.sample_rate
|
||||
.ok_or_else(|| AudioError::ProcessingError("Sample rate not initialized".into()))?;
|
||||
|
||||
debug!("Restarting FLAC encoder for new track");
|
||||
|
||||
let last_timestamp = *self.current_timestamp.read().await;
|
||||
debug!("Last timestamp before restart: {:.3}s", last_timestamp);
|
||||
|
||||
if let Some(tx) = self.pcm_tx.take() {
|
||||
drop(tx);
|
||||
trace!("Dropped PCM sender to signal encoder finish");
|
||||
}
|
||||
|
||||
if let Some(state) = self.encoder_state.take() {
|
||||
trace!("Waiting for broadcaster task to finish...");
|
||||
match state.broadcaster_task.await {
|
||||
Ok(_) => trace!("Broadcaster task finished successfully"),
|
||||
Err(e) => warn!("Broadcaster task error during restart: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
self.timestamp_offset_sec += last_timestamp;
|
||||
debug!("New timestamp offset: {:.3}s", self.timestamp_offset_sec);
|
||||
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(16);
|
||||
self.pcm_tx = Some(pcm_tx);
|
||||
self.pcm_rx = Some(pcm_rx);
|
||||
|
||||
// self.initialize_encoder(sample_rate, self.timestamp_offset_sec, broadcaster)
|
||||
// .await?;
|
||||
|
||||
self.initialize_encoder(sample_rate, 0.0, broadcaster)
|
||||
.await?;
|
||||
|
||||
debug!("FLAC encoder restarted successfully for new track");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_metadata(
|
||||
&mut self,
|
||||
metadata_lock: &Arc<RwLock<dyn TrackMetadata>>,
|
||||
timestamp_sec: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
let metadata = metadata_lock.read().await;
|
||||
let mut snapshot = self.metadata.write().await;
|
||||
|
||||
// Title / artist with default fallback or forced default.
|
||||
if self.use_only_default_metadata {
|
||||
snapshot.title = self.default_title.clone();
|
||||
snapshot.artist = self.default_artist.clone();
|
||||
} else {
|
||||
snapshot.title = metadata
|
||||
.get_title()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| self.default_title.clone());
|
||||
snapshot.artist = metadata
|
||||
.get_artist()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| self.default_artist.clone());
|
||||
}
|
||||
snapshot.album = metadata.get_album().await.ok().flatten();
|
||||
snapshot.duration = metadata.get_duration().await.ok().flatten();
|
||||
snapshot.cover_url = metadata.get_cover_url().await.ok().flatten();
|
||||
snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten();
|
||||
snapshot.year = metadata.get_year().await.ok().flatten();
|
||||
|
||||
if let Ok(Some(extra)) = metadata.get_extra().await {
|
||||
snapshot.genre = extra.get("genre").cloned();
|
||||
snapshot.track_number = extra
|
||||
.get("track_number")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
} else {
|
||||
snapshot.genre = None;
|
||||
snapshot.track_number = None;
|
||||
}
|
||||
|
||||
snapshot.audio_timestamp_sec = timestamp_sec;
|
||||
snapshot.version += 1;
|
||||
|
||||
debug!(
|
||||
"Metadata updated: v{} @ {:.2}s - {} - {} (cover_pk: {:?})",
|
||||
snapshot.version,
|
||||
timestamp_sec,
|
||||
snapshot.artist.as_deref().unwrap_or("?"),
|
||||
snapshot.title.as_deref().unwrap_or("?"),
|
||||
snapshot.cover_pk
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
606
pmoaudio-ext/src/sinks/timed_broadcast.rs
Normal file
606
pmoaudio-ext/src/sinks/timed_broadcast.rs
Normal file
@@ -0,0 +1,606 @@
|
||||
//! Broadcast channel avec TTL et propagation de TopZero.
|
||||
//! Inspiré de `tokio::sync::broadcast` mais ajoute :
|
||||
//! - Capacité bornée avec blocage des producteurs quand aucun slot n’est libre.
|
||||
//! - Expiration automatique des messages (TTL) pour libérer les slots.
|
||||
//! - Propagation d’un compteur `epoch` incrémenté sur chaque TopZeroSync.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fmt,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
||||
Arc, Mutex, Weak,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
/// Tolérance pour détecter un timestamp à zéro (TopZero).
|
||||
const TOP_ZERO_EPSILON: f64 = 1e-9;
|
||||
|
||||
pub const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
|
||||
/// Paquet diffusé contenant la charge utile + méta timing.
|
||||
#[derive(Clone)]
|
||||
pub struct TimedPacket<T> {
|
||||
/// Charge utile diffusée aux clients.
|
||||
pub payload: T,
|
||||
/// Timestamp audio relatif (en secondes) pour pacing côté client.
|
||||
pub audio_timestamp: f64,
|
||||
/// Compteur incrémenté lorsqu'un TopZeroSync est reçu.
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for TimedPacket<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TimedPacket")
|
||||
.field("audio_timestamp", &self.audio_timestamp)
|
||||
.field("epoch", &self.epoch)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Erreur remontée par `Receiver::try_recv`.
|
||||
#[derive(Debug)]
|
||||
pub enum TryRecvError {
|
||||
/// Aucun paquet n'est disponible pour le moment.
|
||||
Empty,
|
||||
/// Le receiver est en retard : le champ contient combien de paquets ont expiré
|
||||
/// ou ont déjà été consommés par les autres abonnés.
|
||||
///
|
||||
/// Ce cas survient lorsque `purge_expired()` avance `head_seq` et que ce
|
||||
/// `Receiver` réclamait encore l'un des numéros supprimés. Le client doit
|
||||
/// donc ignorer les données perdues et se resynchroniser sur les paquets
|
||||
/// courants.
|
||||
Lagged(u64),
|
||||
/// Le channel est fermé et plus aucun paquet n'est disponible.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Erreur remontée par `Receiver::recv`.
|
||||
#[derive(Debug)]
|
||||
pub enum RecvError {
|
||||
Lagged(u64),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Erreur remontée par `Sender::send`.
|
||||
#[derive(Debug)]
|
||||
/// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté.
|
||||
pub enum SendError<T> {
|
||||
Closed(T),
|
||||
Expired(T),
|
||||
}
|
||||
|
||||
struct Entry<T> {
|
||||
seq: u64,
|
||||
expires_at: Instant,
|
||||
payload: T,
|
||||
audio_timestamp: f64,
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
struct State<T> {
|
||||
name: String,
|
||||
buffer: VecDeque<Entry<T>>,
|
||||
head_seq: u64,
|
||||
next_seq: u64,
|
||||
closed: bool,
|
||||
epoch: u64,
|
||||
epoch_start: Instant,
|
||||
last_segment_end: Option<Instant>,
|
||||
cursors: Vec<Weak<ReceiverCursor>>,
|
||||
initialized: bool,
|
||||
last_purge: Instant,
|
||||
}
|
||||
|
||||
impl<T> State<T> {
|
||||
fn new(name: &str, capacity: usize, epoch_start: Instant) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
buffer: VecDeque::with_capacity(capacity),
|
||||
head_seq: 0,
|
||||
next_seq: 0,
|
||||
closed: false,
|
||||
epoch: 0,
|
||||
epoch_start,
|
||||
last_segment_end: None,
|
||||
cursors: Vec::new(),
|
||||
initialized: false,
|
||||
last_purge: epoch_start,
|
||||
}
|
||||
}
|
||||
|
||||
fn purge_expired(&mut self, now: Instant) -> bool {
|
||||
// Throttling : purger au maximum toutes les 20ms
|
||||
if now.duration_since(self.last_purge) < Duration::from_millis(20) {
|
||||
return false;
|
||||
}
|
||||
self.last_purge = now;
|
||||
|
||||
let mut purged = 0u64;
|
||||
while let Some(entry) = self.buffer.front() {
|
||||
if entry.expires_at <= now {
|
||||
let delta = now - entry.expires_at;
|
||||
trace!(
|
||||
"TimedBroadcast[{}]: purging expired packet (@{} epoch={},delta={})",
|
||||
self.name,
|
||||
entry.seq,
|
||||
entry.epoch,
|
||||
delta.as_millis()
|
||||
);
|
||||
self.buffer.pop_front();
|
||||
self.head_seq += 1;
|
||||
purged += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if purged > 0 {
|
||||
trace!(
|
||||
"TimedBroadcast[{}]: purged {} expired packet(s) (head_seq={})",
|
||||
self.name,
|
||||
purged,
|
||||
self.head_seq
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn prune_consumed(&mut self) -> bool {
|
||||
let mut min_next = self.next_seq;
|
||||
let mut has_cursor = false;
|
||||
self.cursors.retain(|weak| {
|
||||
if let Some(cursor) = weak.upgrade() {
|
||||
let pos = cursor.next_seq.load(Ordering::SeqCst);
|
||||
if pos < min_next {
|
||||
min_next = pos;
|
||||
}
|
||||
has_cursor = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if !has_cursor {
|
||||
return false;
|
||||
}
|
||||
|
||||
let removable = min_next.saturating_sub(self.head_seq) as usize;
|
||||
if removable == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
for _ in 0..removable {
|
||||
let oentry = self.buffer.pop_front();
|
||||
if oentry.is_some() {
|
||||
let entry = oentry.unwrap();
|
||||
trace!(
|
||||
"TimedBroadcast[{}]: pruning played packet (@{} epoch={})",
|
||||
self.name,
|
||||
entry.seq,
|
||||
entry.epoch
|
||||
);
|
||||
|
||||
self.head_seq += 1;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner<T> {
|
||||
state: Mutex<State<T>>,
|
||||
data_notify: Notify,
|
||||
space_notify: Notify,
|
||||
capacity: usize,
|
||||
sender_count: AtomicUsize,
|
||||
receiver_count: AtomicUsize,
|
||||
is_closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl<T> Inner<T> {
|
||||
fn new(name: &str, capacity: usize) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(State::new(name, capacity, Instant::now())),
|
||||
data_notify: Notify::new(),
|
||||
space_notify: Notify::new(),
|
||||
capacity,
|
||||
sender_count: AtomicUsize::new(1),
|
||||
receiver_count: AtomicUsize::new(0),
|
||||
is_closed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
if !self.is_closed.swap(true, Ordering::SeqCst) {
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
state.closed = true;
|
||||
}
|
||||
self.data_notify.notify_waiters();
|
||||
self.space_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Créé un channel broadcast temporisé.
|
||||
pub fn channel<T>(name: &str, capacity: usize) -> (Sender<T>, Receiver<T>) {
|
||||
assert!(capacity > 0, "capacity must be > 0");
|
||||
let inner = Arc::new(Inner::new(name, capacity));
|
||||
let next_seq = {
|
||||
let state = inner.state.lock().expect("timed broadcast mutex poisoned");
|
||||
state.next_seq
|
||||
};
|
||||
let sender = Sender {
|
||||
inner: inner.clone(),
|
||||
};
|
||||
let cursor = Arc::new(ReceiverCursor {
|
||||
next_seq: AtomicU64::new(next_seq),
|
||||
});
|
||||
{
|
||||
let mut state = inner.state.lock().expect("timed broadcast mutex poisoned");
|
||||
state.cursors.push(Arc::downgrade(&cursor));
|
||||
}
|
||||
inner.receiver_count.store(1, Ordering::SeqCst);
|
||||
let receiver = Receiver {
|
||||
inner,
|
||||
next_seq,
|
||||
cursor,
|
||||
};
|
||||
(sender, receiver)
|
||||
}
|
||||
|
||||
/// Sender côté producteur.
|
||||
pub struct Sender<T> {
|
||||
inner: Arc<Inner<T>>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Sender<T> {
|
||||
fn clone(&self) -> Self {
|
||||
self.inner.sender_count.fetch_add(1, Ordering::SeqCst);
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Sender<T> {
|
||||
/// Diffuse un paquet. Bloque si la capacité est atteinte avec des paquets non périmés.
|
||||
///
|
||||
/// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du
|
||||
/// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira
|
||||
/// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé.
|
||||
pub async fn send(
|
||||
&self,
|
||||
payload: T,
|
||||
audio_timestamp: f64,
|
||||
segment_duration: f64,
|
||||
) -> Result<usize, SendError<T>>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
let mut payload = Some(payload);
|
||||
loop {
|
||||
let mut wait_deadline = None;
|
||||
{
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.expect("timed broadcast mutex poisoned");
|
||||
|
||||
if state.closed {
|
||||
return Err(SendError::Closed(
|
||||
payload.expect("payload already consumed"),
|
||||
));
|
||||
}
|
||||
|
||||
// Capturer le temps UNE SEULE FOIS pour cohérence temporelle
|
||||
let now = Instant::now();
|
||||
|
||||
// 1. Purger d'abord les paquets expirés et consommés pour libérer l'espace
|
||||
// (skip pour le tout premier paquet)
|
||||
if state.buffer.len() > 0 {
|
||||
let consumed = state.prune_consumed();
|
||||
let expired = state.purge_expired(now);
|
||||
if consumed || expired {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Vérifier si un slot est disponible et insérer
|
||||
let is_top_zero = audio_timestamp.abs() < TOP_ZERO_EPSILON
|
||||
&& segment_duration >= TOP_ZERO_EPSILON;
|
||||
let is_zero_header =
|
||||
audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration < TOP_ZERO_EPSILON;
|
||||
if state.buffer.len() < self.inner.capacity {
|
||||
if !state.initialized {
|
||||
if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON {
|
||||
warn!(
|
||||
"TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway",
|
||||
state.name,
|
||||
audio_timestamp*1000.0,
|
||||
segment_duration*1000.0
|
||||
);
|
||||
}
|
||||
state.epoch_start = now;
|
||||
state.epoch = 0;
|
||||
state.initialized = true;
|
||||
info!(
|
||||
"TimedBroadcast[{}]: initialized (epoch=0, ts={:.1}ms - Duration={:.1}ms)",
|
||||
state.name,
|
||||
audio_timestamp*1000.0,
|
||||
segment_duration*1000.0
|
||||
);
|
||||
} else if is_top_zero || is_zero_header {
|
||||
// Restart epoch on TopZero relative to current wall-clock time to avoid
|
||||
// expired packets when there's a long gap between tracks. Also trigger
|
||||
// on zero-duration headers (OGG BOS/comment) so the epoch is reset
|
||||
// before testing expiration.
|
||||
state.epoch_start = state
|
||||
.last_segment_end
|
||||
.map(|end| end.max(now))
|
||||
.unwrap_or(now);
|
||||
// state.epoch_start = now;
|
||||
state.epoch = state.epoch.wrapping_add(1);
|
||||
info!(
|
||||
"TimedBroadcast[{}]: new epoch={} (continuous={} - Duration={}ms)",
|
||||
state.name,
|
||||
state.epoch,
|
||||
state.last_segment_end.is_some(),
|
||||
segment_duration * 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
let expires_at = state.epoch_start
|
||||
+ Duration::from_secs_f64(audio_timestamp + segment_duration);
|
||||
|
||||
let is_first_packet = state.next_seq == 0;
|
||||
if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now {
|
||||
let grace_period = Duration::from_millis(50);
|
||||
if now > expires_at + grace_period {
|
||||
warn!(
|
||||
"TimedBroadcast[{}]: rejecting already expired packet (ts={:.3}s, epoch={}, delta={}ms)",
|
||||
state.name,
|
||||
audio_timestamp,
|
||||
state.epoch,
|
||||
now.duration_since(expires_at).as_millis()
|
||||
);
|
||||
return Err(SendError::Expired(
|
||||
payload.expect("payload already consumed"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let entry = Entry {
|
||||
seq: state.next_seq,
|
||||
expires_at,
|
||||
payload: payload.take().expect("payload already consumed"),
|
||||
audio_timestamp,
|
||||
epoch: state.epoch,
|
||||
};
|
||||
state.next_seq += 1;
|
||||
state.buffer.push_back(entry);
|
||||
|
||||
// 5. Only advance segment end for real audio (skip 0-duration metadata)
|
||||
if segment_duration >= TOP_ZERO_EPSILON {
|
||||
let new_end = expires_at;
|
||||
state.last_segment_end = Some(match state.last_segment_end.take() {
|
||||
Some(prev) => prev.max(new_end),
|
||||
None => new_end,
|
||||
});
|
||||
}
|
||||
|
||||
let receivers = self.inner.receiver_count.load(Ordering::SeqCst);
|
||||
drop(state);
|
||||
self.inner.data_notify.notify_waiters();
|
||||
return Ok(receivers);
|
||||
}
|
||||
|
||||
wait_deadline = state.buffer.front().map(|entry| entry.expires_at);
|
||||
}
|
||||
|
||||
if let Some(deadline) = wait_deadline {
|
||||
let deadline = tokio::time::Instant::from_std(deadline);
|
||||
tokio::select! {
|
||||
_ = self.inner.space_notify.notified() => {},
|
||||
_ = tokio::time::sleep_until(deadline) => {},
|
||||
}
|
||||
} else {
|
||||
self.inner.space_notify.notified().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau receiver abonné au flux.
|
||||
pub fn subscribe(&self) -> Receiver<T> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.expect("timed broadcast mutex poisoned");
|
||||
let next_seq = state.next_seq;
|
||||
let cursor = Arc::new(ReceiverCursor {
|
||||
next_seq: AtomicU64::new(next_seq),
|
||||
});
|
||||
state.cursors.push(Arc::downgrade(&cursor));
|
||||
state.prune_consumed();
|
||||
drop(state);
|
||||
|
||||
self.inner.receiver_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
Receiver {
|
||||
inner: self.inner.clone(),
|
||||
next_seq,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nombre actuel de receivers abonnés.
|
||||
pub fn receiver_count(&self) -> usize {
|
||||
self.inner.receiver_count.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Ferme explicitement le channel.
|
||||
pub fn close(&self) {
|
||||
self.inner.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for Sender<T> {
|
||||
fn drop(&mut self) {
|
||||
if self.inner.sender_count.fetch_sub(1, Ordering::SeqCst) == 1 {
|
||||
self.inner.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver côté consommateur.
|
||||
///
|
||||
/// Chaque receiver garde son propre curseur `next_seq`. Si le producteur
|
||||
/// recycle un paquet via `purge_expired()` avant que ce curseur ne l’ait lu,
|
||||
/// la prochaine tentative de lecture retournera [`TryRecvError::Lagged`].
|
||||
pub struct Receiver<T> {
|
||||
inner: Arc<Inner<T>>,
|
||||
next_seq: u64,
|
||||
cursor: Arc<ReceiverCursor>,
|
||||
}
|
||||
|
||||
struct ReceiverCursor {
|
||||
next_seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl<T> Receiver<T>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
fn poll_entry(&mut self) -> Result<TimedPacket<T>, TryRecvError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.expect("timed broadcast mutex poisoned");
|
||||
|
||||
if state.closed && state.buffer.is_empty() {
|
||||
return Err(TryRecvError::Closed);
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if state.purge_expired(now) {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
|
||||
if self.next_seq < state.head_seq {
|
||||
let skipped = state.head_seq - self.next_seq;
|
||||
self.next_seq = state.head_seq;
|
||||
return Err(TryRecvError::Lagged(skipped));
|
||||
}
|
||||
|
||||
let offset = (self.next_seq - state.head_seq) as usize;
|
||||
if offset < state.buffer.len() {
|
||||
let entry = state.buffer.get(offset).expect("invalid buffer offset");
|
||||
let packet = TimedPacket {
|
||||
payload: entry.payload.clone(),
|
||||
audio_timestamp: entry.audio_timestamp,
|
||||
epoch: entry.epoch,
|
||||
};
|
||||
self.next_seq += 1;
|
||||
self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst);
|
||||
if state.prune_consumed() {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
return Ok(packet);
|
||||
}
|
||||
|
||||
if state.closed {
|
||||
Err(TryRecvError::Closed)
|
||||
} else {
|
||||
Err(TryRecvError::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Version synchrone utilisée dans `poll_read`.
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// * [`TryRecvError::Lagged`] — des paquets ont expiré avant d'être consommés.
|
||||
/// * [`TryRecvError::Empty`] — la file est vide pour l'instant.
|
||||
/// * [`TryRecvError::Closed`] — plus aucun paquet n'arrivera.
|
||||
pub fn try_recv(&mut self) -> Result<TimedPacket<T>, TryRecvError> {
|
||||
self.poll_entry()
|
||||
}
|
||||
|
||||
/// Attends qu'un paquet soit disponible.
|
||||
pub async fn recv(&mut self) -> Result<TimedPacket<T>, RecvError> {
|
||||
loop {
|
||||
match self.try_recv() {
|
||||
Ok(packet) => return Ok(packet),
|
||||
Err(TryRecvError::Empty) => {
|
||||
self.inner.data_notify.notified().await;
|
||||
}
|
||||
Err(TryRecvError::Lagged(skipped)) => return Err(RecvError::Lagged(skipped)),
|
||||
Err(TryRecvError::Closed) => return Err(RecvError::Closed),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Receiver<T> {
|
||||
fn clone(&self) -> Self {
|
||||
self.inner.receiver_count.fetch_add(1, Ordering::SeqCst);
|
||||
let cursor = Arc::new(ReceiverCursor {
|
||||
next_seq: AtomicU64::new(self.next_seq),
|
||||
});
|
||||
{
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.expect("timed broadcast mutex poisoned");
|
||||
state.cursors.push(Arc::downgrade(&cursor));
|
||||
}
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
next_seq: self.next_seq,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for Receiver<T> {
|
||||
fn drop(&mut self) {
|
||||
self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst);
|
||||
if let Ok(mut state) = self.inner.state.lock() {
|
||||
if state.prune_consumed() {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
if self.inner.receiver_count.fetch_sub(1, Ordering::SeqCst) == 1 {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate broadcast channel capacity based on max_lead_time.
|
||||
///
|
||||
/// Estimates the number of items needed to buffer max_lead_time seconds of audio.
|
||||
/// Assumes ~20 items per second (50ms per chunk).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max_lead_time` - Maximum lead time in seconds
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Broadcast channel capacity (minimum 100 items)
|
||||
pub(crate) fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
|
||||
// Estimation: ~20 items/second (chunks de 50ms en moyenne)
|
||||
// Pour 10s: 200 items
|
||||
let estimated_items_per_second = 20.0;
|
||||
let capacity = (max_lead_time * estimated_items_per_second) as usize;
|
||||
capacity.max(100) // Minimum 100 items
|
||||
}
|
||||
@@ -57,12 +57,50 @@
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Historique des morceaux joués
|
||||
//!
|
||||
//! Utilisez `PlaylistSource::with_history()` pour créer une source qui transfère
|
||||
//! automatiquement les morceaux joués vers une playlist historique :
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudio_ext::PlaylistSource;
|
||||
//! use pmoplaylist::PlaylistManager;
|
||||
//! use pmoaudiocache::cache::new_cache;
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let manager = PlaylistManager::get();
|
||||
//! let cache = Arc::new(new_cache("./cache", 500)?);
|
||||
//!
|
||||
//! // Playlist live (consommée par la source)
|
||||
//! let live_read = manager.get_read_handle("radio-live").await?;
|
||||
//!
|
||||
//! // Playlist historique (capacité 200 morceaux)
|
||||
//! let history_write = manager.create_persistent_playlist("radio-history".into()).await?;
|
||||
//! history_write.set_capacity(Some(200)).await?;
|
||||
//!
|
||||
//! // Créer la source avec historique
|
||||
//! let source = PlaylistSource::with_history(
|
||||
//! live_read,
|
||||
//! cache,
|
||||
//! Arc::new(history_write)
|
||||
//! );
|
||||
//!
|
||||
//! // Les morceaux joués seront automatiquement ajoutés à "radio-history"
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! **Note** : L'historique utilise `push()` sans TTL. Les morceaux restent dans l'historique
|
||||
//! jusqu'à ce que la capacité maximale soit atteinte (FIFO).
|
||||
//!
|
||||
//! # Comportement
|
||||
//!
|
||||
//! - **Polling** : Si la playlist est vide, attend `poll_interval_ms` avant de réessayer
|
||||
//! - **TrackBoundary** : Émet un marqueur avec metadata entre chaque piste
|
||||
//! - **Erreurs** : Si un fichier est inaccessible, émet un `Error` marker et continue
|
||||
//! - **Arrêt** : Via `CancellationToken`, émet `EndOfStream` avant de terminer
|
||||
//! - **Historique** : Si configuré, ajoute chaque piste jouée à la playlist historique
|
||||
//!
|
||||
//! # Synchronisation
|
||||
//!
|
||||
@@ -73,7 +111,7 @@
|
||||
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||
};
|
||||
@@ -98,6 +136,7 @@ pub struct PlaylistSourceLogic {
|
||||
cache: Arc<AudioCache>,
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
history_playlist: Option<Arc<pmoplaylist::WriteHandle>>,
|
||||
}
|
||||
|
||||
impl PlaylistSourceLogic {
|
||||
@@ -112,8 +151,14 @@ impl PlaylistSourceLogic {
|
||||
cache,
|
||||
chunk_frames,
|
||||
poll_interval_ms,
|
||||
history_playlist: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre une playlist historique pour sauvegarder les morceaux joués
|
||||
pub fn set_history_playlist(&mut self, history: Arc<pmoplaylist::WriteHandle>) {
|
||||
self.history_playlist = Some(history);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -130,16 +175,7 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
output.len()
|
||||
);
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
let node_name = std::any::type_name::<Self>();
|
||||
|
||||
let mut first_track = true;
|
||||
|
||||
@@ -148,7 +184,7 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
if stop_token.is_cancelled() {
|
||||
tracing::info!("PlaylistSourceLogic: stop requested, emitting EndOfStream");
|
||||
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||
send_to_children!(eos);
|
||||
send_to_children(node_name, &output, eos).await?;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -157,7 +193,7 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::info!("PlaylistSourceLogic: stop cancelled during pop");
|
||||
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||
send_to_children!(eos);
|
||||
send_to_children(node_name, &output, eos).await?;
|
||||
break;
|
||||
}
|
||||
result = self.playlist_handle.pop() => {
|
||||
@@ -167,7 +203,13 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
t
|
||||
},
|
||||
Ok(None) => {
|
||||
// Playlist vide, attendre avant retry
|
||||
// Playlist vide, attendre avant retry et réinitialiser la synchro
|
||||
if !first_track {
|
||||
tracing::debug!(
|
||||
"PlaylistSourceLogic: playlist drained, resetting top-zero sync"
|
||||
);
|
||||
}
|
||||
first_track = true;
|
||||
tracing::trace!(
|
||||
"PlaylistSourceLogic: playlist empty, waiting {}ms",
|
||||
self.poll_interval_ms
|
||||
@@ -185,74 +227,134 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
0.0,
|
||||
format!("Playlist error: {}", e)
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
send_to_children(node_name, &output, error_marker).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync pour la première piste seulement
|
||||
if first_track {
|
||||
tracing::debug!("PlaylistSourceLogic: emitting TopZeroSync");
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
send_to_children!(top_zero);
|
||||
first_track = false;
|
||||
}
|
||||
|
||||
// Émettre TrackBoundary avec metadata du cache
|
||||
let metadata = match track.track_metadata() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get metadata: {}", e),
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e));
|
||||
send_to_children(node_name, &output, error_marker).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let metadata_guard = metadata.read().await;
|
||||
let artist = metadata_guard
|
||||
.get_artist()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "Unknown artist".to_string());
|
||||
let title = metadata_guard
|
||||
.get_title()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let expected_duration = metadata_guard
|
||||
.get_duration()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|d| d.as_secs_f64());
|
||||
drop(metadata_guard);
|
||||
let remaining = self.playlist_handle.remaining().await.unwrap_or(0);
|
||||
tracing::info!(
|
||||
"PlaylistSource: starting track {} - {} ({} remaining)",
|
||||
artist,
|
||||
title,
|
||||
remaining
|
||||
);
|
||||
|
||||
let track_start = std::time::Instant::now();
|
||||
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
send_to_children!(boundary);
|
||||
let metadata_for_boundary = metadata.clone();
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary);
|
||||
send_to_children(node_name, &output, boundary).await?;
|
||||
|
||||
// Obtenir le chemin du fichier
|
||||
let file_path = match track.file_path() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get file path: {}", e),
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e));
|
||||
send_to_children(node_name, &output, error_marker).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path);
|
||||
let elapsed = track_start.elapsed();
|
||||
tracing::info!(
|
||||
"PlaylistSourceLogic: gap after TrackBoundary = {:.3}s, decoding: {:?}",
|
||||
elapsed.as_secs_f64(),
|
||||
file_path
|
||||
);
|
||||
|
||||
// Décoder et émettre les chunks PCM
|
||||
// Passer le cache et pk pour gérer le cache progressif
|
||||
let cache_pk = track.cache_pk();
|
||||
if let Err(e) = decode_and_emit_track(
|
||||
// Réinitialiser la synchro au début de chaque piste
|
||||
let emit_top_zero = true;
|
||||
first_track = false;
|
||||
|
||||
match decode_and_emit_track(
|
||||
node_name,
|
||||
&file_path,
|
||||
self.chunk_frames,
|
||||
&output,
|
||||
&stop_token,
|
||||
&self.cache,
|
||||
cache_pk,
|
||||
expected_duration,
|
||||
emit_top_zero,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("PlaylistSourceLogic: error decoding track: {}", e);
|
||||
let error_marker = AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
// Continue vers la piste suivante
|
||||
Ok(()) => {
|
||||
tracing::info!("PlaylistSource: finished track {} - {}", artist, title);
|
||||
// Piste décodée avec succès, transférer vers l'historique si configuré
|
||||
tracing::warn!(
|
||||
"🔍 HISTORY DEBUG: history_playlist is {:?}",
|
||||
if self.history_playlist.is_some() {
|
||||
"Some"
|
||||
} else {
|
||||
"None"
|
||||
}
|
||||
);
|
||||
if let Some(ref history) = self.history_playlist {
|
||||
tracing::warn!(
|
||||
"🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history",
|
||||
cache_pk
|
||||
);
|
||||
if let Err(e) = history.push(cache_pk.to_string()).await {
|
||||
tracing::warn!(
|
||||
"PlaylistSourceLogic: failed to add track to history: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"PlaylistSourceLogic: added track {} to history",
|
||||
cache_pk
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("PlaylistSourceLogic: error decoding track: {}", e);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e));
|
||||
send_to_children(node_name, &output, error_marker).await?;
|
||||
// Continue vers la piste suivante
|
||||
}
|
||||
}
|
||||
|
||||
// Boucler pour la piste suivante (pas d'EndOfStream entre pistes !)
|
||||
@@ -272,12 +374,15 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
/// Gère le cache progressif : si EOF est atteint et que le download est toujours en cours,
|
||||
/// attend et réessaie au lieu de terminer immédiatement.
|
||||
async fn decode_and_emit_track(
|
||||
node_name: &'static str,
|
||||
path: &PathBuf,
|
||||
chunk_frames: usize,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
stop_token: &CancellationToken,
|
||||
cache: &Arc<AudioCache>,
|
||||
cache_pk: &str,
|
||||
expected_duration_sec: Option<f64>,
|
||||
emit_top_zero: bool,
|
||||
) -> Result<(), AudioError> {
|
||||
// Attendre que le fichier soit suffisamment gros pour le sniffing
|
||||
// Le cache progressif permet de commencer la lecture après le prebuffer (512 KB)
|
||||
@@ -290,7 +395,10 @@ async fn decode_and_emit_track(
|
||||
const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size)
|
||||
|
||||
if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) {
|
||||
tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size);
|
||||
tracing::trace!(
|
||||
"decode_and_emit_track: file ready ({} bytes), starting decode",
|
||||
file_size
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -400,12 +508,14 @@ async fn decode_and_emit_track(
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
if emit_top_zero && total_frames == 0 {
|
||||
tracing::debug!("decode_and_emit_track: emitting TopZeroSync (first chunk)");
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
send_to_children(node_name, output, top_zero).await?;
|
||||
}
|
||||
|
||||
send_to_children(node_name, output, segment).await?;
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
}
|
||||
@@ -417,12 +527,9 @@ async fn decode_and_emit_track(
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
send_to_children(node_name, output, segment).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +539,27 @@ async fn decode_and_emit_track(
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||
|
||||
if !cache.is_download_complete(cache_pk) {
|
||||
tracing::warn!(
|
||||
"PlaylistSource: finished reading cache entry {} but download is not complete",
|
||||
cache_pk
|
||||
);
|
||||
}
|
||||
|
||||
let actual_duration = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let expected_str = expected_duration_sec
|
||||
.map(|d| format!("{:.3}s", d))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::info!(
|
||||
"PlaylistSource: emitted pk={} frames={} sr={}Hz bit_depth={} duration={:.3}s (expected={})",
|
||||
cache_pk,
|
||||
total_frames,
|
||||
stream_info.sample_rate,
|
||||
stream_info.bits_per_sample,
|
||||
actual_duration,
|
||||
expected_str,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -609,7 +737,29 @@ impl PlaylistSource {
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
) -> Self {
|
||||
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
let logic =
|
||||
PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une nouvelle source avec playlist historique
|
||||
///
|
||||
/// * `playlist_handle` - Handle de lecture sur la playlist live
|
||||
/// * `cache` - Cache audio contenant les fichiers
|
||||
/// * `history_playlist` - Handle d'écriture pour l'historique des morceaux joués
|
||||
///
|
||||
/// Après avoir joué chaque morceau, il sera automatiquement ajouté à la playlist historique.
|
||||
/// La playlist historique utilise push() sans TTL, donc les morceaux y restent jusqu'à
|
||||
/// ce que la capacité maximale soit atteinte (FIFO).
|
||||
pub fn with_history(
|
||||
playlist_handle: ReadHandle,
|
||||
cache: Arc<AudioCache>,
|
||||
history_playlist: Arc<pmoplaylist::WriteHandle>,
|
||||
) -> Self {
|
||||
let mut logic = PlaylistSourceLogic::new(playlist_handle, cache, 0, 100);
|
||||
logic.set_history_playlist(history_playlist);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
@@ -626,10 +776,7 @@ impl AudioPipelineNode for PlaylistSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -712,9 +859,9 @@ mod tests {
|
||||
// Frame 2: L=300, R=400
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // L1
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -732,18 +879,16 @@ mod tests {
|
||||
assert_eq!(segment.timestamp_sec, 0.0);
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -753,7 +898,7 @@ mod tests {
|
||||
// Create mock PCM data (2 frames, mono, 16-bit)
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // Frame 1
|
||||
200, 0, // Frame 2
|
||||
200, 0, // Frame 2
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -808,17 +953,15 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -843,16 +986,14 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
cpal = "0.15"
|
||||
once_cell = "1.20"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
@@ -6,7 +6,9 @@ use tokio::fs::File;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits <file.flac>");
|
||||
let path_str = std::env::args()
|
||||
.nth(1)
|
||||
.expect("Usage: check_flac_bits <file.flac>");
|
||||
let path = Path::new(&path_str);
|
||||
|
||||
println!("Checking: {}", path.display());
|
||||
|
||||
@@ -88,7 +88,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!();
|
||||
println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64());
|
||||
println!(
|
||||
"✓ Conversion completed successfully in {:.2}s",
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
println!(" Output file: {}", output_path);
|
||||
println!();
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Lecture de: {}", file_path);
|
||||
|
||||
// Créer la source audio (lit le fichier FLAC)
|
||||
let mut source = FileSource::new(file_path).await?;
|
||||
let mut source = FileSource::new(file_path);
|
||||
|
||||
// Créer le sink audio (joue sur la sortie audio)
|
||||
let sink = AudioSink::new();
|
||||
@@ -45,7 +45,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Gérer Ctrl+C pour arrêt propre
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
@@ -52,8 +52,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
resampler.register(Box::new(converter));
|
||||
converter.register(Box::new(sink));
|
||||
|
||||
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||
target_sample_rate);
|
||||
println!(
|
||||
"Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||
target_sample_rate
|
||||
);
|
||||
println!("Démarrage de la lecture...");
|
||||
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||
|
||||
@@ -63,7 +65,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Gérer Ctrl+C
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
@@ -678,9 +678,11 @@ impl AudioIntegerChunk {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()]))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => Box::new(
|
||||
d.get_frames()
|
||||
.iter()
|
||||
.map(|f| [f[0].as_i32(), f[1].as_i32()]),
|
||||
),
|
||||
AudioIntegerChunk::I32(d) => Box::new(d.get_frames().iter().map(|f| [f[0], f[1]])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +247,7 @@ fn i16_stereo_to_pairs_f32_inner(
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
|
||||
pub fn i16_stereo_to_pairs_f32(
|
||||
left: &[i16],
|
||||
right: &[i16],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) {
|
||||
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0);
|
||||
}
|
||||
|
||||
@@ -332,11 +328,7 @@ fn pairs_f32_to_i16_stereo_inner(
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
|
||||
pub fn pairs_f32_to_i16_stereo(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i16],
|
||||
right: &mut [i16],
|
||||
) {
|
||||
pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) {
|
||||
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0);
|
||||
}
|
||||
|
||||
@@ -399,11 +391,7 @@ fn i24_as_i32_stereo_to_pairs_f32_inner(
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
|
||||
pub fn i24_as_i32_stereo_to_pairs_f32(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) {
|
||||
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ pub use nodes::{
|
||||
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
||||
http_source::HttpSource,
|
||||
resampling_node::ResamplingNode,
|
||||
timer_buffer_node::TimerBufferNode,
|
||||
timer_node::TimerNode,
|
||||
AudioError, AudioNode, TypedAudioNode,
|
||||
};
|
||||
|
||||
@@ -220,20 +220,18 @@ impl AudioSinkLogic {
|
||||
chunk.sample_rate()
|
||||
);
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => {
|
||||
match **marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
tracing::debug!("AudioSink (null): TrackBoundary received");
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink (null): EndOfStream received");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tracing::trace!("AudioSink (null): sync marker");
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => match **marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
tracing::debug!("AudioSink (null): TrackBoundary received");
|
||||
}
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink (null): EndOfStream received");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tracing::trace!("AudioSink (null): sync marker");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +271,15 @@ impl NodeLogic for AudioSinkLogic {
|
||||
.default_output_device()
|
||||
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?;
|
||||
|
||||
tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string()));
|
||||
tracing::debug!(
|
||||
"Using audio device: {}",
|
||||
device.name().unwrap_or_else(|_| "Unknown".to_string())
|
||||
);
|
||||
|
||||
// Obtenir la config par défaut
|
||||
let config = device
|
||||
.default_output_config()
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?;
|
||||
let config = device.default_output_config().map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to get output config: {}", e))
|
||||
})?;
|
||||
|
||||
let sample_format = config.sample_format();
|
||||
let sample_rate = config.sample_rate().0;
|
||||
@@ -298,9 +299,9 @@ impl NodeLogic for AudioSinkLogic {
|
||||
let stream_thread = thread::spawn(move || {
|
||||
// Créer le stream selon le format hardware
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::I16 => {
|
||||
tracing::debug!("Using I16 output format");
|
||||
match device.build_output_stream(
|
||||
cpal::SampleFormat::I16 => {
|
||||
tracing::debug!("Using I16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -323,10 +324,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
tracing::debug!("Using U16 output format");
|
||||
match device.build_output_stream(
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
tracing::debug!("Using U16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -348,10 +349,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
tracing::debug!("Using F32 output format");
|
||||
match device.build_output_stream(
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
tracing::debug!("Using F32 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -371,10 +372,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Unsupported sample format: {:?}", sample_format);
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Unsupported sample format: {:?}", sample_format);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic {
|
||||
// Le buffer continue automatiquement - pas besoin d'action
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish");
|
||||
tracing::debug!(
|
||||
"AudioSink: EndOfStream received, waiting for playback to finish"
|
||||
);
|
||||
// Marquer la fin et attendre que le buffer se vide
|
||||
buffer.lock().unwrap().mark_end();
|
||||
|
||||
@@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink {
|
||||
panic!("AudioSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use crate::{
|
||||
nodes::AudioError,
|
||||
pipeline::{Node, NodeLogic},
|
||||
pipeline::{send_to_children, Node, NodeLogic},
|
||||
AudioChunk, AudioPipelineNode, AudioSegment,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -100,12 +100,7 @@ where
|
||||
segment
|
||||
};
|
||||
|
||||
// Envoyer à tous les enfants
|
||||
for tx in &output {
|
||||
tx.send(output_segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
send_to_children(std::any::type_name::<Self>(), &output, output_segment).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||
};
|
||||
@@ -41,23 +41,16 @@ impl NodeLogic for FileSourceLogic {
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len());
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
tracing::debug!(
|
||||
"FileSourceLogic::process started, path={:?}, {} children",
|
||||
self.path,
|
||||
output.len()
|
||||
);
|
||||
|
||||
// Ouvrir le fichier
|
||||
let file = File::open(&self.path).await.map_err(|e| {
|
||||
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e))
|
||||
})?;
|
||||
let file = File::open(&self.path)
|
||||
.await
|
||||
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?;
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(file)
|
||||
@@ -78,7 +71,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
|
||||
// Émettre TopZeroSync
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
send_to_children!(top_zero);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, top_zero).await?;
|
||||
|
||||
// Extraire et émettre les métadonnées du fichier
|
||||
if let Ok(file_metadata) = AudioFileMetadata::from_file(&self.path) {
|
||||
@@ -106,7 +99,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
send_to_children!(track_boundary);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||
}
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
@@ -164,7 +157,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
send_to_children!(segment);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
@@ -179,7 +172,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
send_to_children!(segment);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
||||
total_frames += frames as u64;
|
||||
chunk_index += 1;
|
||||
}
|
||||
@@ -188,7 +181,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
// Émettre EndOfStream
|
||||
let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp);
|
||||
send_to_children!(eos);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, eos).await?;
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
@@ -256,10 +249,7 @@ impl AudioPipelineNode for FileSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -399,7 +389,6 @@ fn bytes_to_segment(
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
impl TypedAudioNode for FileSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// FileSource est une source, elle ne consomme pas d'audio
|
||||
@@ -464,11 +453,7 @@ mod tests {
|
||||
impl TestCollectorNode {
|
||||
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
test_tx,
|
||||
}
|
||||
Self { tx, rx, test_tx }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,10 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
let mut rx = input.expect("FlacFileSink must have input");
|
||||
let mut track_number = 0;
|
||||
|
||||
tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path);
|
||||
tracing::debug!(
|
||||
"FlacFileSinkLogic::process started, base_path={:?}",
|
||||
self.base_path
|
||||
);
|
||||
|
||||
loop {
|
||||
// Vérifier si l'arrêt a été demandé
|
||||
@@ -81,13 +84,14 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
}
|
||||
|
||||
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary
|
||||
let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible ou arrêt demandé
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let (first_segment, track_metadata) =
|
||||
match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible ou arrêt demandé
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Extraire les informations du premier chunk
|
||||
let first_chunk = first_segment.as_chunk().unwrap();
|
||||
@@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
|
||||
tracing::debug!(
|
||||
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
|
||||
track_number, bits_per_sample, sample_rate
|
||||
track_number,
|
||||
bits_per_sample,
|
||||
sample_rate
|
||||
);
|
||||
|
||||
let format = PcmFormat {
|
||||
@@ -308,10 +314,7 @@ impl FlacFileSink {
|
||||
///
|
||||
/// * `base_path` - Chemin de base pour les fichiers FLAC
|
||||
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
|
||||
pub fn with_channel_size<P: Into<PathBuf>>(
|
||||
base_path: P,
|
||||
channel_size: usize,
|
||||
) -> Self {
|
||||
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
|
||||
Self::with_config(base_path, channel_size, EncoderOptions::default())
|
||||
}
|
||||
|
||||
@@ -360,7 +363,13 @@ fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf {
|
||||
async fn wait_for_first_audio_chunk_with_metadata(
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(Arc<AudioSegment>, Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>), AudioError> {
|
||||
) -> Result<
|
||||
(
|
||||
Arc<AudioSegment>,
|
||||
Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>,
|
||||
),
|
||||
AudioError,
|
||||
> {
|
||||
let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
|
||||
|
||||
loop {
|
||||
@@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink {
|
||||
panic!("FlacFileSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -768,7 +774,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_flac_file_sink_writes_metadata() {
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = temp_dir.path().join("output_with_metadata.flac");
|
||||
|
||||
@@ -779,9 +784,8 @@ mod tests {
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
let sink_handle =
|
||||
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
|
||||
|
||||
// Envoyer des segments avec métadonnées
|
||||
tokio::spawn(async move {
|
||||
@@ -792,13 +796,25 @@ mod tests {
|
||||
|
||||
// TrackBoundary avec métadonnées
|
||||
let mut metadata = MemoryTrackMetadata::new();
|
||||
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap();
|
||||
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap();
|
||||
metadata.set_album(Some("Test Album".to_string())).await.unwrap();
|
||||
metadata
|
||||
.set_title(Some("Test Track Title".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata
|
||||
.set_artist(Some("Test Artist".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata
|
||||
.set_album(Some("Test Album".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata.set_year(Some(2024)).await.unwrap();
|
||||
|
||||
let track_boundary =
|
||||
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
let track_boundary = crate::AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
tx.send(track_boundary).await.unwrap();
|
||||
|
||||
// Générer et envoyer des chunks audio
|
||||
@@ -900,9 +916,8 @@ mod tests {
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
let sink_handle =
|
||||
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
|
||||
|
||||
// Lire le fichier input et envoyer les segments au sink
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{Node, NodeLogic},
|
||||
pipeline::{send_to_children, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
|
||||
};
|
||||
@@ -114,7 +114,7 @@ impl HttpSourceLogic {
|
||||
self.url.clone()
|
||||
}
|
||||
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
self.chunk_frames
|
||||
}
|
||||
}
|
||||
@@ -127,22 +127,10 @@ impl NodeLogic for HttpSourceLogic {
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Effectuer la requête HTTP
|
||||
let response = reqwest::get(&self.url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
|
||||
})?;
|
||||
let response = reqwest::get(&self.url).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
|
||||
})?;
|
||||
|
||||
// Vérifier le status
|
||||
if !response.status().is_success() {
|
||||
@@ -158,9 +146,10 @@ impl NodeLogic for HttpSourceLogic {
|
||||
|
||||
// Convertir le stream de bytes en AsyncRead
|
||||
let bytes_stream = response.bytes_stream();
|
||||
let stream_reader = StreamReader::new(bytes_stream.map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}));
|
||||
let stream_reader =
|
||||
StreamReader::new(bytes_stream.map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}));
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(stream_reader)
|
||||
@@ -180,15 +169,17 @@ impl NodeLogic for HttpSourceLogic {
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync
|
||||
send_to_children!(AudioSegment::new_top_zero_sync());
|
||||
send_to_children(
|
||||
std::any::type_name::<Self>(),
|
||||
&output,
|
||||
AudioSegment::new_top_zero_sync(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Émettre TrackBoundary avec les métadonnées HTTP
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
send_to_children!(track_boundary);
|
||||
let track_boundary =
|
||||
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
|
||||
@@ -246,7 +237,7 @@ impl NodeLogic for HttpSourceLogic {
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
send_to_children!(segment);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
@@ -259,7 +250,7 @@ impl NodeLogic for HttpSourceLogic {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
send_to_children!(segment);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
||||
total_frames += frames as u64;
|
||||
chunk_index += 1;
|
||||
}
|
||||
@@ -268,7 +259,7 @@ impl NodeLogic for HttpSourceLogic {
|
||||
// Émettre EndOfStream
|
||||
let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp);
|
||||
send_to_children!(eos);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, eos).await?;
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
@@ -523,10 +514,7 @@ impl AudioPipelineNode for HttpSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -698,7 +686,10 @@ mod tests {
|
||||
}
|
||||
|
||||
// Vérifications
|
||||
assert_eq!(received_frames, frames, "Tous les frames doivent être reçus");
|
||||
assert_eq!(
|
||||
received_frames, frames,
|
||||
"Tous les frames doivent être reçus"
|
||||
);
|
||||
assert!(seen_top_zero, "TopZeroSync doit être émis");
|
||||
assert!(seen_track_boundary, "TrackBoundary doit être émis");
|
||||
assert!(seen_eos, "EndOfStream doit être émis");
|
||||
@@ -725,9 +716,10 @@ mod tests {
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_data)
|
||||
@@ -798,7 +790,10 @@ mod tests {
|
||||
assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404");
|
||||
|
||||
if let Err(AudioError::ProcessingError(msg)) = result {
|
||||
assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404");
|
||||
assert!(
|
||||
msg.contains("404"),
|
||||
"Le message d'erreur doit mentionner le code 404"
|
||||
);
|
||||
} else {
|
||||
panic!("Le type d'erreur doit être ProcessingError");
|
||||
}
|
||||
@@ -854,9 +849,10 @@ mod tests {
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_data)
|
||||
@@ -898,6 +894,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found_title, "Le nom du fichier doit être utilisé comme titre");
|
||||
assert!(
|
||||
found_title,
|
||||
"Le nom du fichier doit être utilisé comme titre"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod file_source;
|
||||
pub mod flac_file_sink;
|
||||
pub mod http_source;
|
||||
pub mod resampling_node;
|
||||
pub mod timer_buffer_node;
|
||||
pub mod timer_node;
|
||||
|
||||
// Modules temporairement désactivés
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
use crate::{
|
||||
dsp::resampling::{build_resampler, resampling, Resampler},
|
||||
nodes::{AudioError, TypedAudioNode},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
|
||||
};
|
||||
@@ -95,7 +95,9 @@ impl ResamplingLogic {
|
||||
bit_depth
|
||||
);
|
||||
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Resampler init failed: {}", e))
|
||||
})?;
|
||||
self.current_resampler = Some(ResamplerState {
|
||||
source_hz: source_sr,
|
||||
resampler,
|
||||
@@ -111,7 +113,12 @@ impl ResamplingLogic {
|
||||
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
|
||||
|
||||
// Recréer le chunk avec le nouveau sample rate
|
||||
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
|
||||
reconstruct_chunk(
|
||||
chunk,
|
||||
resampled_left,
|
||||
resampled_right,
|
||||
self.target_sample_rate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +172,7 @@ impl NodeLogic for ResamplingLogic {
|
||||
segment
|
||||
};
|
||||
|
||||
// Envoyer à tous les enfants
|
||||
for tx in &output {
|
||||
tx.send(output_segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
send_to_children(std::any::type_name::<Self>(), &output, output_segment).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -360,10 +362,7 @@ impl AudioPipelineNode for ResamplingNode {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -418,11 +417,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_reconstruct_chunk_i16() {
|
||||
let original = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200]],
|
||||
44100,
|
||||
0.0,
|
||||
));
|
||||
let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0));
|
||||
|
||||
let left = vec![100i32, 300i32];
|
||||
let right = vec![200i32, 400i32];
|
||||
@@ -495,7 +490,7 @@ mod tests {
|
||||
|
||||
// Créer un TrackBoundary
|
||||
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||
pmometadata::MemoryTrackMetadata::new()
|
||||
pmometadata::MemoryTrackMetadata::new(),
|
||||
));
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
|
||||
@@ -568,7 +563,11 @@ mod tests {
|
||||
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
|
||||
if let AudioChunk::I16(data) = chunk.as_ref() {
|
||||
let frames = data.get_frames().len();
|
||||
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
|
||||
assert!(
|
||||
frames >= 105 && frames <= 115,
|
||||
"Expected ~109 frames, got {}",
|
||||
frames
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("Expected audio chunk");
|
||||
|
||||
350
pmoaudio/src/nodes/timer_buffer_node.rs
Normal file
350
pmoaudio/src/nodes/timer_buffer_node.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! TimerBufferNode - Maintient un tampon temporel capacitif avant diffusion
|
||||
//!
|
||||
//! Ce node implémente un buffer capacitif qui accumule un temps configurable
|
||||
//! de données audio avant de les diffuser. Une fois le buffer rempli, il
|
||||
//! maintient ce niveau en diffusant les données au même rythme qu'elles arrivent.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - **Buffering initial**: Accumule N secondes de données avant de commencer la lecture
|
||||
//! - **Smoothing**: Absorbe les variations de débit entre source et sink
|
||||
//! - **Streaming**: Pré-charge un buffer pour éviter les coupures
|
||||
//!
|
||||
//! # Exemple
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use pmoaudio::{HttpSource, TimerBufferNode, AudioSink};
|
||||
//!
|
||||
//! let mut source = HttpSource::new(url);
|
||||
//! let mut buffer = TimerBufferNode::new(3.0); // Buffer 3s avant de commencer
|
||||
//! let mut sink = AudioSink::new();
|
||||
//!
|
||||
//! source.register(Box::new(buffer));
|
||||
//! buffer.register(Box::new(sink));
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! HttpSource → TimerBufferNode → AudioSink
|
||||
//! ↓ ↓ ↓
|
||||
//! Flux réseau Buffer 3s Lecture stable
|
||||
//! variable capacitif sans coupures
|
||||
//! ```
|
||||
//!
|
||||
//! Le TimerBufferNode:
|
||||
//! 1. Accumule les chunks dans un buffer jusqu'à atteindre `capacity_sec`
|
||||
//! 2. Une fois plein, diffuse les chunks en mode FIFO
|
||||
//! 3. Maintient un niveau constant d'environ `capacity_sec` secondes
|
||||
//!
|
||||
//! # Markers Supportés
|
||||
//!
|
||||
//! - **TopZeroSync**: Vide le buffer et reset le compteur
|
||||
//! - **TrackBoundary**: Passthrough transparent
|
||||
//! - **Heartbeat**: Passthrough transparent
|
||||
//! - **EndOfStream**: Flush le buffer restant avant propagation
|
||||
//!
|
||||
//! # Performance
|
||||
//!
|
||||
//! - **CPU**: Minimal (VecDeque efficace)
|
||||
//! - **Latency**: Ajoute `capacity_sec` de buffering initial
|
||||
//! - **Memory**: Proportionnel à `capacity_sec` (ex: ~3MB pour 3s @ 48kHz stéréo)
|
||||
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioSegment, SyncMarker, _AudioSegment,
|
||||
};
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TimerBufferNodeLogic - Logique pure de buffering capacitif
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de buffering temporel capacitif
|
||||
///
|
||||
/// Maintient un buffer de taille fixe (en secondes) et diffuse les segments
|
||||
/// en mode FIFO une fois le buffer rempli.
|
||||
pub struct TimerBufferNodeLogic {
|
||||
/// Capacité du buffer en secondes
|
||||
capacity_sec: f64,
|
||||
/// Temps actuellement bufferisé en secondes
|
||||
buffered_time_sec: f64,
|
||||
/// Durée par défaut d'un chunk (fallback)
|
||||
default_chunk_duration_sec: f64,
|
||||
/// Timestamp du chunk précédent (pour estimer les durées)
|
||||
prev_input_ts: Option<f64>,
|
||||
/// Buffer FIFO de segments avec leurs durées
|
||||
buffer: VecDeque<(Arc<AudioSegment>, f64)>,
|
||||
/// Nombre de chunks traités (pour instrumentation)
|
||||
chunk_count: u64,
|
||||
/// Nombre de chunks flushés (pour instrumentation)
|
||||
flush_count: u64,
|
||||
/// Dernier log d'instrumentation
|
||||
last_stats_log: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TimerBufferNodeLogic {
|
||||
pub fn new(capacity_sec: f64) -> Self {
|
||||
Self {
|
||||
capacity_sec: capacity_sec.max(0.0),
|
||||
buffered_time_sec: 0.0,
|
||||
default_chunk_duration_sec: DEFAULT_CHUNK_DURATION_MS / 1000.0,
|
||||
prev_input_ts: None,
|
||||
buffer: VecDeque::new(),
|
||||
chunk_count: 0,
|
||||
flush_count: 0,
|
||||
last_stats_log: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estime la durée d'un chunk basé sur le delta de timestamps
|
||||
fn estimate_duration(&mut self, ts: f64) -> f64 {
|
||||
if let Some(prev) = self.prev_input_ts {
|
||||
let delta = (ts - prev).clamp(0.0, 10.0);
|
||||
self.prev_input_ts = Some(ts);
|
||||
if delta == 0.0 {
|
||||
self.default_chunk_duration_sec
|
||||
} else {
|
||||
delta
|
||||
}
|
||||
} else {
|
||||
self.prev_input_ts = Some(ts);
|
||||
self.default_chunk_duration_sec
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush un segment du buffer vers les outputs
|
||||
async fn flush_one(
|
||||
&mut self,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
) -> Result<(), AudioError> {
|
||||
if let Some((segment, duration)) = self.buffer.pop_front() {
|
||||
self.flush_count += 1;
|
||||
self.buffered_time_sec = (self.buffered_time_sec - duration).max(0.0);
|
||||
|
||||
tracing::trace!(
|
||||
"TimerBufferNode: flushing segment (ts={:.3}s, duration={:.3}s, remaining={:.3}s, {} items in buffer)",
|
||||
segment.timestamp_sec,
|
||||
duration,
|
||||
self.buffered_time_sec,
|
||||
self.buffer.len()
|
||||
);
|
||||
|
||||
send_to_children(std::any::type_name::<Self>(), output, segment).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_log_stats(&mut self) {
|
||||
let now = Instant::now();
|
||||
let should_log = match self.last_stats_log {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last).as_secs() >= 1,
|
||||
};
|
||||
|
||||
if should_log {
|
||||
self.last_stats_log = Some(now);
|
||||
tracing::debug!(
|
||||
"TimerBufferNode stats: chunks_received={} chunks_flushed={} buffered={:.3}s capacity={:.3}s buffer_items={}",
|
||||
self.chunk_count,
|
||||
self.flush_count,
|
||||
self.buffered_time_sec,
|
||||
self.capacity_sec,
|
||||
self.buffer.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for TimerBufferNodeLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("TimerBufferNode must have input");
|
||||
tracing::info!(
|
||||
"TimerBufferNodeLogic::process started (capacity={:.1}s), {} children",
|
||||
self.capacity_sec,
|
||||
output.len()
|
||||
);
|
||||
|
||||
loop {
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ LOGIQUE CAPACITIVE PAR BACKPRESSURE NATURELLE ║
|
||||
// ║ ║
|
||||
// ║ Si le buffer >= capacity, on flush en continu (boucle) ║
|
||||
// ║ sans recevoir de nouveaux segments. Cela force la ║
|
||||
// ║ backpressure en amont si le sink en aval est lent. ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
if self.buffered_time_sec >= self.capacity_sec && !self.buffer.is_empty() {
|
||||
self.flush_one(&output).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let segment = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("TimerBufferNode cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
tracing::debug!("TimerBufferNode received EOF");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Sync(marker) => {
|
||||
match &**marker {
|
||||
SyncMarker::TopZeroSync => {
|
||||
// Reset le buffer complètement
|
||||
self.buffer.clear();
|
||||
self.buffered_time_sec = 0.0;
|
||||
self.prev_input_ts = Some(0.0);
|
||||
self.chunk_count = 0;
|
||||
self.flush_count = 0;
|
||||
tracing::debug!("TimerBufferNode: TopZeroSync received, buffer reset");
|
||||
}
|
||||
_ => {
|
||||
// Autres markers: passthrough transparent
|
||||
}
|
||||
}
|
||||
|
||||
// Propager le marker immédiatement
|
||||
send_to_children(std::any::type_name::<Self>(), &output, segment.clone())
|
||||
.await?;
|
||||
}
|
||||
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
self.chunk_count += 1;
|
||||
|
||||
// Calculer la durée du chunk
|
||||
let frames = chunk.len() as f64;
|
||||
let sample_rate = chunk.sample_rate() as f64;
|
||||
let duration = if frames > 0.0 && sample_rate > 0.0 {
|
||||
frames / sample_rate
|
||||
} else {
|
||||
self.estimate_duration(segment.timestamp_sec)
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"TimerBufferNode: received chunk (ts={:.3}s, duration={:.3}s, buffered={:.3}s, capacity={:.3}s)",
|
||||
segment.timestamp_sec,
|
||||
duration,
|
||||
self.buffered_time_sec,
|
||||
self.capacity_sec
|
||||
);
|
||||
|
||||
// Ajouter le chunk au buffer
|
||||
self.buffer.push_back((segment.clone(), duration));
|
||||
self.buffered_time_sec += duration;
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════╗
|
||||
// ║ FLUSH IMMÉDIAT : Vider aussi vite que possible ║
|
||||
// ║ ║
|
||||
// ║ Le send() bloquera si le sink est lent, créant ║
|
||||
// ║ naturellement la backpressure. Le buffer se remplit ║
|
||||
// ║ pendant que send() attend, jusqu'à atteindre capacity. ║
|
||||
// ╚═══════════════════════════════════════════════════════════╝
|
||||
self.flush_one(&output).await?;
|
||||
|
||||
self.maybe_log_stats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EOF reçu, flusher le buffer restant
|
||||
tracing::info!(
|
||||
"TimerBufferNode: EOF received, flushing remaining buffer ({:.3}s, {} items)",
|
||||
self.buffered_time_sec,
|
||||
self.buffer.len()
|
||||
);
|
||||
while !self.buffer.is_empty() {
|
||||
self.flush_one(&output).await?;
|
||||
}
|
||||
|
||||
tracing::debug!("TimerBufferNodeLogic::process finished");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TimerBufferNode - Wrapper utilisant Node<TimerBufferNodeLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub struct TimerBufferNode {
|
||||
inner: Node<TimerBufferNodeLogic>,
|
||||
}
|
||||
|
||||
impl TimerBufferNode {
|
||||
/// Crée un TimerBufferNode avec une capacité donnée
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `capacity_sec` - Capacité du buffer en secondes (ex: 3.0 pour 3s)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::TimerBufferNode;
|
||||
///
|
||||
/// // Buffer 3 secondes avant de commencer la diffusion
|
||||
/// let buffer = TimerBufferNode::new(3.0);
|
||||
/// ```
|
||||
pub fn new(capacity_sec: f64) -> Self {
|
||||
Self::with_channel_size(capacity_sec, DEFAULT_CHANNEL_SIZE)
|
||||
}
|
||||
|
||||
/// Crée un TimerBufferNode avec une taille de buffer MPSC personnalisée
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `capacity_sec` - Capacité du buffer en secondes
|
||||
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente)
|
||||
pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Self {
|
||||
let logic = TimerBufferNodeLogic::new(capacity_sec);
|
||||
Self {
|
||||
inner: Node::new_with_input(logic, channel_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for TimerBufferNode {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child);
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for TimerBufferNode {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// Accepte n'importe quel type
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// Passthrough: produit le même type qu'il consomme
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
pipeline::{send_to_children_with_timing, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioSegment, SyncMarker, _AudioSegment,
|
||||
};
|
||||
@@ -73,15 +73,46 @@ use tokio_util::sync::CancellationToken;
|
||||
pub struct TimerNodeLogic {
|
||||
/// Avance maximale tolérée en secondes (buffer)
|
||||
max_lead_time_sec: f64,
|
||||
/// Tolérance supplémentaire avant de resynchroniser l'horloge
|
||||
catchup_slack_sec: f64,
|
||||
/// Instant de référence (reset au TopZeroSync)
|
||||
start_time: Option<Instant>,
|
||||
/// Nombre de chunks traités (pour instrumentation)
|
||||
chunk_count: u64,
|
||||
/// Dernier log d'instrumentation
|
||||
last_stats_log: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TimerNodeLogic {
|
||||
pub fn new(max_lead_time_sec: f64) -> Self {
|
||||
let max_lead = max_lead_time_sec.max(0.0);
|
||||
let slack = (max_lead * 0.25).max(0.5);
|
||||
Self {
|
||||
max_lead_time_sec: max_lead_time_sec.max(0.0),
|
||||
max_lead_time_sec: max_lead,
|
||||
catchup_slack_sec: slack,
|
||||
start_time: None,
|
||||
chunk_count: 0,
|
||||
last_stats_log: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_log_stats(&mut self, chunk_timestamp: f64, elapsed: f64, lead_time: f64) {
|
||||
let now = Instant::now();
|
||||
let should_log = match self.last_stats_log {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= Duration::from_secs(1),
|
||||
};
|
||||
|
||||
if should_log {
|
||||
self.last_stats_log = Some(now);
|
||||
tracing::debug!(
|
||||
"TimerNode stats: chunks={} ts={:.3}s elapsed={:.3}s lead={:.3}s max={:.3}s",
|
||||
self.chunk_count,
|
||||
chunk_timestamp,
|
||||
elapsed,
|
||||
lead_time,
|
||||
self.max_lead_time_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,17 +132,6 @@ impl NodeLogic for TimerNodeLogic {
|
||||
output.len()
|
||||
);
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
@@ -143,15 +163,56 @@ impl NodeLogic for TimerNodeLogic {
|
||||
// Autres markers: passthrough transparent
|
||||
}
|
||||
}
|
||||
send_to_children!(segment);
|
||||
let segment_ts = segment.timestamp_sec;
|
||||
send_to_children_with_timing(
|
||||
std::any::type_name::<Self>(),
|
||||
&output,
|
||||
segment.clone(),
|
||||
|idx, send_duration, _capacity_before| {
|
||||
if send_duration.as_millis() >= 50 {
|
||||
tracing::debug!(
|
||||
"TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)",
|
||||
idx,
|
||||
send_duration.as_secs_f64(),
|
||||
segment_ts
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
_AudioSegment::Chunk(_) => {
|
||||
// Vérifier le pacing seulement si on a un timer de référence
|
||||
if let Some(start) = self.start_time {
|
||||
self.chunk_count += 1;
|
||||
let chunk_timestamp = segment.timestamp_sec;
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let lead_time = chunk_timestamp - elapsed;
|
||||
let mut elapsed = start.elapsed().as_secs_f64();
|
||||
let mut lead_time = chunk_timestamp - elapsed;
|
||||
|
||||
// Si on a accumulé beaucoup trop d'avance (source ultra rapide),
|
||||
// on recale l'horloge pour éviter de dormir pendant des dizaines de secondes.
|
||||
let catchup_threshold = self.max_lead_time_sec + self.catchup_slack_sec;
|
||||
if lead_time > catchup_threshold {
|
||||
let desired_elapsed =
|
||||
(chunk_timestamp - self.max_lead_time_sec).max(0.0);
|
||||
let adjust = (desired_elapsed - elapsed).max(0.0);
|
||||
let new_start =
|
||||
Instant::now() - Duration::from_secs_f64(desired_elapsed);
|
||||
self.start_time = Some(new_start);
|
||||
elapsed = desired_elapsed;
|
||||
lead_time = chunk_timestamp - elapsed;
|
||||
tracing::warn!(
|
||||
"TimerNode: lead {:.3}s > {:.3}s (max {:.3}s + slack {:.3}s) → fast-forward clock by {:.3}s",
|
||||
chunk_timestamp - start.elapsed().as_secs_f64(),
|
||||
catchup_threshold,
|
||||
self.max_lead_time_sec,
|
||||
self.catchup_slack_sec,
|
||||
adjust
|
||||
);
|
||||
}
|
||||
|
||||
self.maybe_log_stats(chunk_timestamp, elapsed, lead_time);
|
||||
|
||||
tracing::trace!(
|
||||
"TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)",
|
||||
@@ -159,9 +220,9 @@ impl NodeLogic for TimerNodeLogic {
|
||||
);
|
||||
|
||||
if lead_time > self.max_lead_time_sec {
|
||||
// On est trop en avance, attendre
|
||||
let sleep_duration = lead_time - self.max_lead_time_sec;
|
||||
tracing::debug!(
|
||||
// On est trop en avance, attendre juste assez pour retomber à max_lead_time
|
||||
let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0);
|
||||
tracing::trace!(
|
||||
"TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)",
|
||||
sleep_duration,
|
||||
lead_time,
|
||||
@@ -197,7 +258,23 @@ impl NodeLogic for TimerNodeLogic {
|
||||
tracing::warn!("TimerNodeLogic: NO TIMER SET - passthrough without pacing! (ts={:.3}s)", segment.timestamp_sec);
|
||||
}
|
||||
|
||||
send_to_children!(segment);
|
||||
let segment_ts = segment.timestamp_sec;
|
||||
send_to_children_with_timing(
|
||||
std::any::type_name::<Self>(),
|
||||
&output,
|
||||
segment.clone(),
|
||||
|idx, send_duration, _capacity_before| {
|
||||
if send_duration.as_millis() >= 50 {
|
||||
tracing::debug!(
|
||||
"TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)",
|
||||
idx,
|
||||
send_duration.as_secs_f64(),
|
||||
segment_ts
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
//! ```
|
||||
|
||||
use crate::{nodes::AudioError, AudioSegment};
|
||||
use std::sync::Arc;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -104,10 +107,7 @@ pub trait AudioPipelineNode: Send + 'static {
|
||||
/// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
|
||||
/// - L'enfant ne cancel JAMAIS le parent
|
||||
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError>;
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>;
|
||||
|
||||
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle
|
||||
///
|
||||
@@ -148,9 +148,7 @@ pub trait AudioPipelineNode: Send + 'static {
|
||||
let stop_token = CancellationToken::new();
|
||||
let token_for_task = stop_token.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
self.run(token_for_task).await
|
||||
});
|
||||
let join_handle = tokio::spawn(async move { self.run(token_for_task).await });
|
||||
|
||||
PipelineHandle {
|
||||
stop_token,
|
||||
@@ -309,6 +307,98 @@ pub trait NodeLogic: Send + 'static {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un segment à l'ensemble des enfants d'un nœud.
|
||||
///
|
||||
/// Cette fonction gère la logique de clonage d'`Arc<AudioSegment>` et la
|
||||
/// conversion de l'erreur `mpsc::error::SendError` en `AudioError::ChildDied`.
|
||||
|
||||
/// Tracker pour vérifier que les chunks audio après TopZeroSync ont timestamp=0
|
||||
/// HashMap<key, waiting_for_chunk>: true = attente du prochain chunk après TopZeroSync
|
||||
static FIRST_AUDIO_CHUNK_TRACKER: Lazy<Mutex<HashMap<usize, bool>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
const FIRST_CHUNK_EPSILON: f64 = 1e-6;
|
||||
|
||||
fn record_first_audio_chunk_timestamp(
|
||||
node_name: &'static str,
|
||||
outputs: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
segment: &Arc<AudioSegment>,
|
||||
) {
|
||||
if outputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = outputs.as_ptr() as usize;
|
||||
let mut tracker = FIRST_AUDIO_CHUNK_TRACKER
|
||||
.lock()
|
||||
.expect("invariant tracker mutex poisoned");
|
||||
|
||||
// Détecter TopZeroSync: marquer qu'on attend le prochain chunk audio
|
||||
if segment.is_top_zero_sync() {
|
||||
tracker.insert(key, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier les audio chunks
|
||||
if segment.is_audio_chunk() {
|
||||
let waiting = tracker.get(&key).copied();
|
||||
|
||||
// Vérifier ts=0 si c'est le premier chunk absolu (None) ou après TopZeroSync (Some(true))
|
||||
if waiting.is_none() || waiting == Some(true) {
|
||||
if segment.timestamp_sec.abs() > FIRST_CHUNK_EPSILON {
|
||||
tracing::warn!(
|
||||
"First audio chunk emitted by {node_name} {} started at {:.6}s (order={}), expected 0s",
|
||||
if waiting == Some(true) { "after TopZeroSync" } else { "" },
|
||||
segment.timestamp_sec,
|
||||
segment.order,
|
||||
node_name = node_name,
|
||||
);
|
||||
}
|
||||
// Marquer comme "ne plus attendre" pour ce node
|
||||
tracker.insert(key, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_to_children(
|
||||
node_name: &'static str,
|
||||
outputs: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
segment: Arc<AudioSegment>,
|
||||
) -> Result<(), AudioError> {
|
||||
record_first_audio_chunk_timestamp(node_name, outputs, &segment);
|
||||
for tx in outputs {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Variante de [`send_to_children`] qui expose le temps passé à envoyer à chaque enfant.
|
||||
///
|
||||
/// Utile pour les nœuds qui souhaitent instrumenter les blocages éventuels lors
|
||||
/// de l'envoi (ex: TimerNode).
|
||||
pub async fn send_to_children_with_timing<F>(
|
||||
node_name: &'static str,
|
||||
outputs: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
segment: Arc<AudioSegment>,
|
||||
mut inspector: F,
|
||||
) -> Result<(), AudioError>
|
||||
where
|
||||
F: FnMut(usize, Duration, usize),
|
||||
{
|
||||
record_first_audio_chunk_timestamp(node_name, outputs, &segment);
|
||||
for (idx, tx) in outputs.iter().enumerate() {
|
||||
let capacity_before = tx.capacity();
|
||||
let send_start = Instant::now();
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
inspector(idx, send_start.elapsed(), capacity_before);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle pour contrôler un pipeline en cours d'exécution
|
||||
///
|
||||
/// Retourné par la méthode `start()`, ce handle permet de :
|
||||
@@ -373,12 +463,14 @@ impl PipelineHandle {
|
||||
pub async fn wait(self) -> Result<(), AudioError> {
|
||||
match self.join_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task panicked: {}", e)
|
||||
)),
|
||||
Err(e) => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task cancelled: {}", e)
|
||||
)),
|
||||
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!(
|
||||
"Pipeline task panicked: {}",
|
||||
e
|
||||
))),
|
||||
Err(e) => Err(AudioError::ProcessingError(format!(
|
||||
"Pipeline task cancelled: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,10 +598,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
let Node {
|
||||
mut logic,
|
||||
rx,
|
||||
@@ -528,9 +617,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
for (i, child) in children.into_iter().enumerate() {
|
||||
tracing::debug!("Spawning child {}", i);
|
||||
let child_token = stop_token.child_token();
|
||||
let handle = tokio::spawn(async move {
|
||||
child.run(child_token).await
|
||||
});
|
||||
let handle = tokio::spawn(async move { child.run(child_token).await });
|
||||
child_handles.push(handle);
|
||||
}
|
||||
tracing::debug!("All {} children spawned", child_handles.len());
|
||||
@@ -573,9 +660,10 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
// Un enfant a paniqué
|
||||
tracing::error!("Child panicked: {}", e);
|
||||
if !has_error {
|
||||
first_error = Some(AudioError::ProcessingError(
|
||||
format!("Child task panicked: {}", e)
|
||||
));
|
||||
first_error = Some(AudioError::ProcessingError(format!(
|
||||
"Child task panicked: {}",
|
||||
e
|
||||
)));
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
@@ -595,78 +683,79 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor {
|
||||
// Il y a des enfants à surveiller
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), false)
|
||||
}
|
||||
let (stop_reason, process_result, child_monitor_consumed) =
|
||||
if let Some(monitor) = &mut child_monitor {
|
||||
// Il y a des enfants à surveiller
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), false)
|
||||
}
|
||||
|
||||
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
|
||||
child_result = monitor => {
|
||||
match child_result {
|
||||
Ok(Ok(())) => {
|
||||
// Tous les enfants terminés avec succès
|
||||
// Le parent devrait aussi terminer bientôt
|
||||
tracing::debug!("All children finished successfully");
|
||||
(StopReason::Completed, Ok(()), true)
|
||||
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
|
||||
child_result = monitor => {
|
||||
match child_result {
|
||||
Ok(Ok(())) => {
|
||||
// Tous les enfants terminés avec succès
|
||||
// Le parent devrait aussi terminer bientôt
|
||||
tracing::debug!("All children finished successfully");
|
||||
(StopReason::Completed, Ok(()), true)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Un enfant a eu une erreur - arrêter immédiatement
|
||||
tracing::warn!("Child error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true)
|
||||
}
|
||||
Err(e) => {
|
||||
// Le monitor task a paniqué
|
||||
let error = AudioError::ProcessingError(
|
||||
format!("Child monitor panicked: {}", e)
|
||||
);
|
||||
(StopReason::Error(error.clone()), Err(error), true)
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Un enfant a eu une erreur - arrêter immédiatement
|
||||
tracing::warn!("Child error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true)
|
||||
}
|
||||
Err(e) => {
|
||||
// Le monitor task a paniqué
|
||||
let error = AudioError::ProcessingError(
|
||||
format!("Child monitor panicked: {}", e)
|
||||
);
|
||||
(StopReason::Error(error.clone()), Err(error), true)
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
tracing::info!("Node logic.process() returned");
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::info!("Node process completed successfully");
|
||||
(StopReason::Completed, Ok(()), false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pas d'enfants (nœud terminal) - juste exécuter la logique
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
tracing::info!("Node logic.process() returned");
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::info!("Node process completed successfully");
|
||||
(StopReason::Completed, Ok(()), false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), false)
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Node process completed successfully (terminal)");
|
||||
(StopReason::Completed, Ok(()), true) // true car pas de monitor
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pas d'enfants (nœud terminal) - juste exécuter la logique
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Node process completed successfully (terminal)");
|
||||
(StopReason::Completed, Ok(()), true) // true car pas de monitor
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 4: CLEANUP COORDONNÉ
|
||||
|
||||
@@ -4,8 +4,13 @@ use tokio::sync::RwLock;
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
pub enum SyncMarker {
|
||||
TrackBoundary { metadata: Arc<RwLock<dyn TrackMetadata>> },
|
||||
StreamMetadata { key: String, value: String },
|
||||
TrackBoundary {
|
||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||
},
|
||||
StreamMetadata {
|
||||
key: String,
|
||||
value: String,
|
||||
},
|
||||
TopZeroSync,
|
||||
Heartbeat,
|
||||
EndOfStream,
|
||||
|
||||
@@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3";
|
||||
|
||||
println!("\nDownloading: {}", test_url);
|
||||
let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?;
|
||||
let pk = cache::add_with_metadata_extraction(cache, test_url, Some("test")).await?;
|
||||
|
||||
println!("\nPK: {}", pk);
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
|
||||
98
pmoaudiocache/src/api.rs
Normal file
98
pmoaudiocache/src/api.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! API REST handlers spécifiques au cache audio
|
||||
|
||||
use crate::metadata_ext::AudioTrackMetadataExt;
|
||||
use crate::Cache;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use pmometadata::TrackMetadata;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Réponse contenant l'URL de la cover avec fallback
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CoverUrlResponse {
|
||||
/// PK de la piste
|
||||
#[schema(example = "1a2b3c4d5e6f7a8b")]
|
||||
pub pk: String,
|
||||
/// URL de la cover (cover_pk, cover_url, ou data URL par défaut)
|
||||
#[schema(example = "https://example.com/cover.jpg")]
|
||||
pub cover_url: String,
|
||||
/// Source de l'URL: "cover_pk", "cover_url", ou "default"
|
||||
#[schema(example = "cover_pk")]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Récupère l'URL de la cover d'une piste avec logique de fallback
|
||||
///
|
||||
/// Cette route retourne l'URL de la cover en appliquant la logique de priorité suivante :
|
||||
/// 1. Si `cover_pk` est défini dans les métadonnées, retourne la clé du cache de covers
|
||||
/// 2. Sinon, si `cover_url` est défini, retourne l'URL externe
|
||||
/// 3. Sinon, retourne une image SVG par défaut (data URL)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de la piste audio
|
||||
///
|
||||
/// # Responses
|
||||
///
|
||||
/// * `200 OK` - Retourne l'URL de la cover avec la source
|
||||
/// * `404 NOT_FOUND` - Piste non trouvée
|
||||
/// * `500 INTERNAL_SERVER_ERROR` - Erreur lors de la lecture des métadonnées
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/{pk}/cover-url",
|
||||
tag = "audio",
|
||||
params(
|
||||
("pk" = String, Path, description = "Clé primaire de la piste")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "URL de la cover récupérée avec succès", body = CoverUrlResponse),
|
||||
(status = 404, description = "Piste non trouvée", body = pmocache::api::ErrorResponse),
|
||||
(status = 500, description = "Erreur interne", body = pmocache::api::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_cover_url(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Vérifier que la piste existe
|
||||
if cache.db.get(&pk, false).is_err() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(pmocache::api::ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Track with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Récupérer les métadonnées
|
||||
let metadata = cache.track_metadata(&pk);
|
||||
let metadata_guard = metadata.read().await;
|
||||
|
||||
// Déterminer la source et l'URL
|
||||
let (cover_url, source) = match metadata_guard.get_cover_pk().await {
|
||||
Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()),
|
||||
_ => match metadata_guard.get_cover_url().await {
|
||||
Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()),
|
||||
_ => (pmometadata::get_default_cover_url(), "default".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CoverUrlResponse {
|
||||
pk,
|
||||
cover_url,
|
||||
source,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user