Compare commits
20 Commits
push-zpokn
...
bcfffa6df8
| Author | SHA1 | Date | |
|---|---|---|---|
| bcfffa6df8 | |||
| e670e8e0d8 | |||
| c7f67e7c7c | |||
| a97bfe6148 | |||
| 7c10b22204 | |||
| 46e269a290 | |||
| 7bce0ffe19 | |||
| ba40d07f73 | |||
| 8bc0670de4 | |||
| 037435af20 | |||
| 2b3fae7778 | |||
| 06de4feb5e | |||
| 9b5a028f3f | |||
| 9ad7d7454d | |||
| 39d7c56a99 | |||
| 82197c8103 | |||
| 8ddee7d94f | |||
| 4b793cec59 | |||
| 9e447023a8 | |||
| 6285c8ccc8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
pmomusic_logs.txt
|
||||
|
||||
127
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
127
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Améliorations pmoqobuz inspirées de qbz
|
||||
|
||||
Analyse comparative avec le projet [qbz](../../../qbz) (`crates/qbz-qobuz/`), client Qobuz Rust plus avancé.
|
||||
Les items sont classés par priorité et état d'avancement.
|
||||
|
||||
---
|
||||
|
||||
## 1. Streaming CMAF — **FAIT**
|
||||
|
||||
**Problème** : l'endpoint legacy `/track/getFileUrl` est en cours de dépréciation. Qobuz bascule vers
|
||||
CMAF (Common Media Application Format) : segments AES-CTR chiffrés sur CDN Akamai.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `pmoqobuz::cmaf` — pipeline complet : dérivation HKDF, dérobage AES-CBC, déchiffrement AES-CTR par frame
|
||||
- `pmoqobuz::retry` — retry exponentiel avec classification Transient/Terminal
|
||||
- `QobuzClient::open_cmaf_stream()` — `AsyncRead` progressif via `tokio::io::duplex`, 3 segments en vol
|
||||
- `GET /qobuz/tracks/:id/flac` — endpoint REST local qui expose le flux ; `LazyProvider::get_url()`
|
||||
retourne cette URL locale (via `PMO_SERVER_URL`) → le progressive caching de pmocache est préservé sans
|
||||
modification
|
||||
|
||||
**Référence qbz** : `crates/qbz-qobuz/src/cmaf.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. Extraction du bundle Qobuz — **Fait**
|
||||
|
||||
**Problème** : `pmoqobuz` utilise un `app_id` et un `configvalue` statiques, hardcodés ou configurés
|
||||
manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS.
|
||||
|
||||
**Ce que fait qbz** (`bundle.rs`) :
|
||||
- Télécharge la page `https://play.qobuz.com/login`, extrait l'URL du bundle JS
|
||||
- Parse le bundle (~7 MB) avec des regex pour en extraire `app_id`, les secrets, et la `private_key` OAuth
|
||||
- Met en cache les tokens sur disque avec un hash de version du bundle (`bundle_version`)
|
||||
- Revalide automatiquement si la version change (rotation silencieuse de Qobuz)
|
||||
- Timeout de 45 s sur le fetch, 2 retry sur extraction
|
||||
|
||||
**Impact pour pmoqobuz** :
|
||||
- Le `Spoofer` actuel fait un fetch similaire mais sans cache disque ni détection de version
|
||||
- Ajouter `CachedBundle` (version + tokens + timestamp) dans `pmoconfig` ou dans le répertoire de données
|
||||
- Relire le cache au démarrage, re-extraire seulement si la version du bundle a changé
|
||||
|
||||
**Avantage** : ne jamais tomber en panne quand Qobuz rotate ses secrets sans préavis.
|
||||
|
||||
---
|
||||
|
||||
## 3. Chargement batch de tracks — `track/getList` — **FAIT**
|
||||
|
||||
**Problème** : les tracks retournées par `/playlist/get?extra=tracks` et
|
||||
`/favorite/getUserFavorites` ont des métadonnées incomplètes (parfois sans `performer`,
|
||||
jamais de `sample_rate`/`bit_depth`/`channels`). Cela déclenchait des appels individuels
|
||||
`get_track` lazy à la lecture.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `signing::sign_track_get_list(ids_csv, timestamp, secret)` — signature MD5 pour `track/getList`
|
||||
- `QobuzApi::post_json_with_query` — POST avec auth headers + query sig + JSON body
|
||||
- `QobuzApi::get_tracks_batch(&[&str])` — fenêtres de 50 IDs, appels en série
|
||||
- `QobuzClient::get_tracks_batch` — wrapper avec cache (skip les IDs déjà en cache)
|
||||
- `get_playlist_tracks` : phase 1 pagination existante, phase 2 enrichissement si secret disponible
|
||||
- `get_favorite_tracks` : même enrichissement en phase 2
|
||||
- Fallback gracieux si le secret est absent ou si `track/getList` échoue
|
||||
|
||||
**Ce que fait qbz** (`get_tracks_batch`, l.1323) :
|
||||
```
|
||||
POST /track/getList
|
||||
{ "tracks_id": [id1, id2, ..., id50] }
|
||||
→ { "tracks": { "total": N, "items": [...Track] } }
|
||||
```
|
||||
- Fenêtre de 50 IDs max par appel (limite API Qobuz)
|
||||
- Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas)
|
||||
|
||||
---
|
||||
|
||||
## 4. Pagination concurrente des playlists — **À FAIRE** (priorité moyenne)
|
||||
|
||||
**Problème** : `pmoqobuz` charge les pages de tracks d'une playlist séquentiellement (offset=0, puis
|
||||
offset=500, etc.). Chaque requête attend la précédente.
|
||||
|
||||
**Ce que fait qbz** (`get_playlist`, l.1397) :
|
||||
- Page 1 → récupère les métadonnées + `total` track count
|
||||
- Pages 2..N → lancées **concurremment** via `join_all` dès que `total` est connu
|
||||
- Résultats ré-ordonnés par offset avant fusion
|
||||
|
||||
**Impact pour pmoqobuz** : une playlist de 2 000 tracks (4 pages de 500) passe de 4 requêtes
|
||||
séquentielles (~1,6 s) à 1 + 3 en parallèle (~0,7 s).
|
||||
|
||||
**Note** : à implémenter avec un semaphore (comme le CMAF) pour ne pas surcharger l'API Qobuz.
|
||||
|
||||
---
|
||||
|
||||
## 5. Endpoint `release_watch` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Problème** : pmoqobuz ne supporte pas les nouvelles sorties d'artistes suivis.
|
||||
|
||||
**Ce que fait qbz** (`get_release_watch`, l.809) :
|
||||
```
|
||||
GET /favorite/getNewReleases?type=album&limit=50&offset=0
|
||||
→ { has_more: bool, items: [...Album] }
|
||||
```
|
||||
- Types disponibles : `album`, `live`, `ep_single`
|
||||
- Pas de champ `total` dans la réponse — pagination via `has_more`
|
||||
|
||||
**Impact pour pmoqobuz** : permettre un "quoi de neuf" dans l'interface — albums des artistes favoris
|
||||
sortis récemment. Utile pour le catalogue de la webapp.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chargement `playlist/get?extra=track_ids` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Ce que fait qbz** (`get_playlist_track_ids`, l.1296) :
|
||||
- Variante légère de `playlist/get` qui retourne uniquement les IDs (pas les objets Track complets)
|
||||
- Utile pour vérifier si une playlist a changé sans tout recharger
|
||||
- Combiné avec `get_tracks_batch` pour un chargement optimal en deux passes :
|
||||
1. `playlist/get?extra=track_ids` → liste d'IDs
|
||||
2. `track/getList` par fenêtres de 50 → objets Track complets
|
||||
|
||||
---
|
||||
|
||||
## Résumé de priorités
|
||||
|
||||
| # | Amélioration | Effort | Impact | État |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** |
|
||||
| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** |
|
||||
| 3 | Batch `track/getList` | Faible | Élevé (performances) | **Fait** |
|
||||
| 4 | Pagination concurrente playlists | Faible | Moyen | À faire |
|
||||
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |
|
||||
| 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire |
|
||||
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Refactoring Plan - Crate `pmocontrol`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `pmocontrol` implémente un control point UPnP multiprotocole pour contrôler des renderers audio (UPnP/DLNA, OpenHome, LinkPlay, Arylic TCP, Chromecast). Une refactorisation récente avait pour objectif de monter la logique vers les couches abstraites, mais des duplications et des problèmes de conception subsistent.
|
||||
|
||||
---
|
||||
|
||||
## Structure analysée
|
||||
|
||||
- `music_renderer/` : Implémentations concrètes + façade `MusicRenderer`
|
||||
- `queue/` : Gestion abstraite et concrète des files de lecture
|
||||
- `discovery/` : Découverte SSDP et gestion des appareils
|
||||
- `upnp_clients/` : Clients SOAP pour services UPnP
|
||||
- `control_point.rs` : Point de contrôle principal
|
||||
|
||||
Backends : `UpnpRenderer`, `OpenHomeRenderer`, `LinkPlayRenderer`, `ArylicTcpRenderer`, `ChromecastRenderer`, `HybridUpnpArylicRenderer`
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P0 : BUGS LOGIQUES (à corriger immédiatement)
|
||||
|
||||
### BUG-1 : `sync_queue` dans UpnpRenderer ignore le cancel_token
|
||||
|
||||
**Fichier :** `src/music_renderer/upnp_renderer.rs` (lignes ~354-364)
|
||||
|
||||
**Description :** Le paramètre `cancel_token` est reçu comme `_cancel_token` (ignoré) et remplacé par un `Arc::new(AtomicBool::new(false))` fraîchement créé. Les demandes d'annulation de synchronisation de queue sont silencieusement ignorées pour le backend UPnP.
|
||||
|
||||
**Correction :**
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>, // utiliser le param, pas _cancel_token
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, cancel_token, on_ready) // passer le vrai token
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Vérifier également les autres backends (OpenHome, LinkPlay, Arylic, Chromecast) s'ils propagent correctement le cancel_token.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P1 : DUPLICATIONS MAJEURES (à traiter en priorité)
|
||||
|
||||
### DUP-1 : Implémentation de `QueueBackend` répétée dans les 5+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~310-381)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~368-450)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~246-330)
|
||||
- `src/music_renderer/chromecast_renderer.rs` (~866+)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~629+)
|
||||
|
||||
**Description :** Chaque renderer implémente `QueueBackend` de manière identique : chaque méthode verrouille `self.queue` et délègue à la file sous-jacente. ~150+ lignes de boilerplate.
|
||||
|
||||
**Approche recommandée — Trait délégateur :**
|
||||
```rust
|
||||
// Dans queue/mod.rs ou music_renderer/mod.rs
|
||||
pub trait HasQueue {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||
}
|
||||
|
||||
// Impl automatique pour QueueBackend si le type implémente HasQueue
|
||||
impl<T: HasQueue> QueueBackend for T {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().unwrap().len()
|
||||
}
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue().lock().unwrap().track_ids()
|
||||
}
|
||||
// ... toutes les méthodes déléguantes
|
||||
}
|
||||
|
||||
// Dans chaque renderer : une seule ligne
|
||||
impl HasQueue for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { &self.queue }
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir le trait `HasQueue`, implémenter `QueueBackend for T where T: HasQueue`, supprimer les implémentations manuelles dans chaque renderer.
|
||||
|
||||
---
|
||||
|
||||
### DUP-2 : Logique commune de `play_from_queue` dupliquée dans 4+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~184-256)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~189-212)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~311-334)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~partie similaire)
|
||||
|
||||
**Description :** Les 10-12 premières lignes de `play_from_queue` sont identiques dans tous les renderers : verrouillage de queue, gestion de l'index courant, fallback sur index 0 si non défini, récupération de l'item. Seule la partie terminale (play effectif sur le backend) diffère.
|
||||
|
||||
**Approche recommandée — Méthode par défaut dans un trait :**
|
||||
```rust
|
||||
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
|
||||
// Primitive spécifique au backend
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
// Implémentation commune par défaut
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue().lock().unwrap();
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
let item = queue.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
drop(queue);
|
||||
|
||||
let is_stream = is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream().lock().unwrap() = is_stream;
|
||||
self.play_item(&item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `QueueTransportControl` avec une méthode par défaut, implémenter `play_item` dans chaque renderer, supprimer la logique commune dupliquée.
|
||||
|
||||
---
|
||||
|
||||
### DUP-3 : Initialisation redondante des champs partagés dans tous les renderers
|
||||
|
||||
**Fichiers :** Constructeurs dans tous les fichiers renderer
|
||||
|
||||
**Description :** Chaque renderer répète la même construction :
|
||||
```rust
|
||||
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||
// ...
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
```
|
||||
|
||||
**Approche recommandée :**
|
||||
```rust
|
||||
pub struct SharedRendererState {
|
||||
pub queue: Arc<Mutex<MusicQueue>>,
|
||||
pub continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl SharedRendererState {
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
Ok(Self {
|
||||
queue: Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?)),
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `SharedRendererState`, l'utiliser dans tous les constructeurs de renderers.
|
||||
|
||||
---
|
||||
|
||||
### DUP-4 : `parse_didl_duration` implémentée deux fois différemment
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~383-420) : parsing manuel par string search (fragile)
|
||||
- `src/music_renderer/musicrenderer.rs` (~2089-2117) : via parser DIDL-Lite structuré (robuste)
|
||||
|
||||
**Description :** Deux implémentations divergentes. L'une risque de mal parser du DIDL là où l'autre réussit.
|
||||
|
||||
**Tâche :** Conserver uniquement la version via `DIDLLite::parse`, l'exporter depuis `music_renderer/mod.rs`, supprimer la version par string search dans `upnp_renderer.rs`.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P2 : ALGORITHMES COMPLEXES ET ABSTRACTIONS MAL PLACÉES
|
||||
|
||||
### ALGO-1 : `schedule_sync` dans `music_queue.rs` — logique intriquée
|
||||
|
||||
**Fichier :** `src/queue/music_queue.rs` (~97-200)
|
||||
|
||||
**Description :** La méthode crée un thread worker avec :
|
||||
- Des `AtomicBool` pour synchronisation (sync_in_progress, sync_pending, sync_cancel_token)
|
||||
- Une boucle infinie interne qui re-tente si un nouveau job arrive
|
||||
- Un Guard RAII basé sur `Drop` pour le cleanup
|
||||
- Des closures capturées mêlant synchronisation et logique métier
|
||||
|
||||
Difficile à tester, à observer de l'extérieur, pas de timeout.
|
||||
|
||||
**Tâche :**
|
||||
1. Extraire la logique du worker dans une fonction `sync_worker_loop` avec signature claire
|
||||
2. Documenter le protocole de synchronisation avec les AtomicBool
|
||||
3. Ajouter une stratégie de timeout ou de sortie en cas de blocage
|
||||
|
||||
---
|
||||
|
||||
### ALGO-2 : Enum dispatch sprawl dans `MusicRendererBackend`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (~2134-2495)
|
||||
|
||||
**Description :** L'enum a 6 variantes. Chaque trait implémenté pour l'enum (`TransportControl`, `PlaybackStatus`, `PlaybackPosition`, `RendererBackend`, `QueueBackend`, etc.) contient un `match` sur les 6 variantes. Estimation : 200+ lignes de boilerplate purement mécanique. Ajouter une 7e variante requiert des mises à jour dans 25+ endroits.
|
||||
|
||||
**Approche recommandée — Macro de dispatch :**
|
||||
```rust
|
||||
macro_rules! dispatch {
|
||||
($self:expr, $method:ident($($arg:expr),*)) => {
|
||||
match $self {
|
||||
MusicRendererBackend::Upnp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::OpenHome(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::LinkPlay(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::ArylicTcp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::Chromecast(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
dispatch!(self, play_uri(uri, meta))
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir la macro `dispatch!`, remplacer les match statements redondants, valider les cas où HybridUpnpArylic a une logique spéciale.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-3 : Logique de protection des durées de streams dupliquée dans 3 endroits
|
||||
|
||||
**Fichiers :**
|
||||
- `src/queue/interne.rs` (~74-126) : `protect_stream_durations`
|
||||
- `src/queue/openhome.rs` (~400+) : logique similaire pour playlists OpenHome
|
||||
- `src/music_renderer/musicrenderer.rs` (~488-537) : dans `poll_and_emit_changes`
|
||||
|
||||
**Description :** La logique "refuser la diminution de durée pour un stream continu" est réimplémentée trois fois. Si la définition de "diminution acceptable" change, il faut modifier 3 fichiers.
|
||||
|
||||
**Tâche :** Créer `music_renderer/stream_utils.rs` (ou équivalent) avec une fonction `protect_stream_duration(old, new, is_stream) -> Option<String>` et l'utiliser dans les 3 endroits.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-4 : Détection de flux continu fragmentée
|
||||
|
||||
**Fichiers :** `stream_detection.rs`, `musicrenderer.rs`, `queue/interne.rs`, `queue/openhome.rs`
|
||||
|
||||
**Description :** La détection "est-ce un stream continu?" passe par plusieurs chemins non unifiés :
|
||||
1. `TrackMetadata::is_continuous_stream`
|
||||
2. Appel `is_continuous_stream_url(uri)` (réseau)
|
||||
3. Absence de durée dans les métadonnées
|
||||
|
||||
Un stream peut être marqué continu dans une couche mais pas l'autre.
|
||||
|
||||
**Tâche :** Créer une fonction canonique unique :
|
||||
```rust
|
||||
pub fn is_continuous_stream(metadata: Option<&TrackMetadata>, uri: &str) -> bool {
|
||||
metadata.map(|m| m.is_continuous_stream).unwrap_or(false)
|
||||
|| is_continuous_stream_url(uri)
|
||||
}
|
||||
```
|
||||
Faire passer tous les codepaths par cette fonction.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P3 : BONNES PRATIQUES (amélioration continue)
|
||||
|
||||
### BP-1 : `.unwrap()` sur mutex locks (>50 occurrences)
|
||||
|
||||
**Problème :** Si un mutex est empoisonné (panique dans une autre tâche), `.unwrap()` propage la panique. Aucun code ne gère ce cas.
|
||||
|
||||
**Tâche :** Remplacer `.unwrap()` par `.expect("message contextuel")` à court terme. À long terme, envisager `parking_lot::Mutex` (pas de concept de poison).
|
||||
|
||||
---
|
||||
|
||||
### BP-2 : Absence de gestion d'erreur dans les threads watcher et sync
|
||||
|
||||
**Fichiers :** `musicrenderer.rs` (watcher_loop), `music_queue.rs` (schedule_sync)
|
||||
|
||||
**Tâche :** Ajouter `error!` logs dans les threads et décider explicitement de la politique de redémarrage (continuer vs arrêter).
|
||||
|
||||
---
|
||||
|
||||
### BP-3 : Champs `pub` au lieu de `pub(crate)` dans `PlaylistBinding`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (struct `PlaylistBinding`)
|
||||
|
||||
**Tâche :** Rendre les champs `pub` → `pub(crate)` ou privés avec accesseurs.
|
||||
|
||||
---
|
||||
|
||||
### BP-4 : Documentation manquante sur les contrats des traits
|
||||
|
||||
**Fichiers :** `src/music_renderer/capabilities.rs`, `src/queue/backend.rs`
|
||||
|
||||
**Tâche :** Ajouter des doc-comments sur les traits clés (`TransportControl`, `PlaybackStatus`, `QueueBackend`) décrivant les invariants, les pré/post-conditions, et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## PLAN D'EXÉCUTION
|
||||
|
||||
### Phase 1 — Bugs (immédiat)
|
||||
- [ ] **BUG-1** : Corriger le cancel_token ignoré dans `upnp_renderer.rs::sync_queue`
|
||||
- [ ] Vérifier les autres renderers pour le même bug
|
||||
|
||||
### Phase 2 — Éliminer les duplications majeures (1-2 semaines)
|
||||
- [ ] **DUP-1** : Trait `HasQueue` + impl automatique de `QueueBackend`
|
||||
- [ ] **DUP-4** : Unifier `parse_didl_duration` sur la version DIDL-Lite
|
||||
- [ ] **DUP-3** : Créer `SharedRendererState` pour l'init commune
|
||||
- [ ] **DUP-2** : Trait `QueueTransportControl` avec `play_from_queue` par défaut
|
||||
|
||||
### Phase 3 — Simplifier les algorithmes (2-4 semaines)
|
||||
- [ ] **ALGO-2** : Macro `dispatch!` pour `MusicRendererBackend`
|
||||
- [ ] **ALGO-3** : Centraliser la protection des durées de stream
|
||||
- [ ] **ALGO-4** : Unifier la détection de flux continu
|
||||
- [ ] **ALGO-1** : Refactoriser `schedule_sync` (extraire `sync_worker_loop`)
|
||||
|
||||
### Phase 4 — Qualité continue
|
||||
- [ ] **BP-1** : Remplacer les `.unwrap()` critiques
|
||||
- [ ] **BP-2** : Gestion d'erreur dans les threads
|
||||
- [ ] **BP-3** : Visibilité des champs `PlaylistBinding`
|
||||
- [ ] **BP-4** : Documentation des traits
|
||||
46
Cargo.lock
generated
46
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.47"
|
||||
version = "0.3.51"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -715,6 +715,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -808,6 +817,15 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.46"
|
||||
@@ -1317,6 +1335,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2025,6 +2044,24 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -2408,6 +2445,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -4026,12 +4064,16 @@ dependencies = [
|
||||
name = "pmoqobuz"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"ctr",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"indexmap 2.12.0",
|
||||
"md-5",
|
||||
"mockito",
|
||||
@@ -4051,10 +4093,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.48"
|
||||
version = "0.3.51"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>webapp</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -257,6 +257,9 @@
|
||||
|
||||
.drawer-footer {
|
||||
padding: var(--spacing-md);
|
||||
/* On mobile, extend into the safe-area so the footer doesn't sit under
|
||||
the system navigation bar / home indicator. */
|
||||
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,10 @@ function handleRendererDrawerClick() {
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
/* Extend background into the system navigation bar area (iOS home indicator,
|
||||
Android gesture bar). Content stays in the 72px zone; only the visual
|
||||
background bleeds into the safe-area below. */
|
||||
padding: 0 var(--spacing-md) env(safe-area-inset-bottom, 0px);
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
@@ -323,7 +326,7 @@ function handleRendererDrawerClick() {
|
||||
@media (max-width: 768px) {
|
||||
.bottom-bar {
|
||||
height: 64px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
padding: 0 var(--spacing-sm) env(safe-area-inset-bottom, 0px);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -860,14 +860,8 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -231,7 +231,8 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
.queue-drawer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
/* Ancré juste au-dessus de la BottomTabBar (64px) et de la safe-area système */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
@@ -239,11 +240,11 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
|
||||
transform: translateY(calc(100% - 56px - 64px));
|
||||
/* Fermé: seul le toggle (56px) visible */
|
||||
transform: translateY(calc(100% - 56px));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
|
||||
max-height: 70vh;
|
||||
z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */
|
||||
max-height: calc(100vh - 64px - env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fermé: ne bloque pas les clics en dehors du toggle */
|
||||
@@ -251,9 +252,9 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer.open {
|
||||
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
|
||||
transform: translateY(64px);
|
||||
pointer-events: auto; /* Ouvert: capture les clics */
|
||||
/* Ouvert: le drawer occupe toute la hauteur disponible au-dessus de la tab bar */
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -266,6 +267,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
.queue-drawer-toggle {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -281,7 +283,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer-content {
|
||||
max-height: calc(70vh - 56px);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
@@ -292,12 +294,12 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 94; /* Entre la BottomTabBar et le drawer */
|
||||
pointer-events: auto; /* Capture les clics pour fermer le drawer */
|
||||
z-index: 94;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
|
||||
@@ -113,6 +113,20 @@ watch(drawerContentRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated)
|
||||
watch(browseData, async (data, oldData) => {
|
||||
if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, currentContainerId.value, false);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur reload après invalidation cache:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -275,8 +289,12 @@ function isPlayable(item: ContainerEntry): boolean {
|
||||
|
||||
// Détermine si un container est navigable
|
||||
function isNavigable(item: ContainerEntry): boolean {
|
||||
// Tous les containers sont navigables (on laisse le serveur décider si vide)
|
||||
return item.is_container;
|
||||
if (!item.is_container) return false;
|
||||
// Une playlist avec un seul item : on garde le caractère jouable mais on retire
|
||||
// la navigation pour éviter que l'utilisateur joue l'item directement et perde
|
||||
// le bénéfice des mises à jour de métadonnées de la playlist.
|
||||
if (isPlayable(item) && item.child_count === 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleItemClick(item: ContainerEntry) {
|
||||
@@ -1425,14 +1443,8 @@ function handleSettingsClick() {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -27,6 +27,12 @@ const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Compteur d'erreurs consécutives par renderer — on ne notifie l'utilisateur
|
||||
// qu'après SNAPSHOT_ERROR_THRESHOLD échecs d'affilée pour absorber les hoquets
|
||||
// transitoires des devices (Arylic, etc.).
|
||||
const consecutiveSnapshotErrors = new Map<string, number>();
|
||||
const SNAPSHOT_ERROR_THRESHOLD = 3;
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
@@ -367,19 +373,25 @@ async function fetchRendererSnapshot(
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// Notifier l'utilisateur
|
||||
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
|
||||
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
|
||||
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
|
||||
consecutiveSnapshotErrors.set(rendererId, count);
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
|
||||
|
||||
// Garder le dernier snapshot connu plutôt que de le supprimer — l'UI reste
|
||||
// affichable même si le device est momentanément muet.
|
||||
|
||||
// Notifier seulement après N échecs consécutifs pour absorber les hoquets passagers
|
||||
if (count >= SNAPSHOT_ERROR_THRESHOLD) {
|
||||
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
|
||||
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
|
||||
// Remettre à zéro pour ne pas spammer si le device reste instable
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
}
|
||||
} finally {
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.delete(rendererId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,11 +451,11 @@ async function resumeOrPlayFromQueue(id: string) {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
// Check if queue has content
|
||||
if (
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
// If queue has items, always try to play from queue regardless of transport
|
||||
// state. After a sleep timer stop, the device may report an unknown/transient
|
||||
// state ("load", network error → "UNKNOWN", etc.) that doesn't match the
|
||||
// expected "STOPPED"/"NO_MEDIA" strings, yet the queue is non-empty.
|
||||
if (snapshot.queue.items.length > 0) {
|
||||
return api.resume(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
|
||||
};
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::{
|
||||
|
||||
@@ -58,6 +58,15 @@ impl ControlPointError {
|
||||
ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string())
|
||||
}
|
||||
|
||||
/// Returns true if the error is a transient transport-level failure that
|
||||
/// may succeed on retry (TCP refused, connection reset, timeout).
|
||||
///
|
||||
/// Protocol-level errors (UPnP fault, HTTP 4xx/5xx with a valid body)
|
||||
/// are **not** transient: the device understood the request and rejected it.
|
||||
pub fn is_transient_soap_error(&self) -> bool {
|
||||
matches!(self, ControlPointError::SoapAction(_))
|
||||
}
|
||||
|
||||
pub fn upnp_missing_return_value(value: &str) -> Self {
|
||||
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ impl RendererEventBus {
|
||||
pub(crate) fn subscribe(&self) -> Receiver<RendererEvent> {
|
||||
let (tx, rx) = unbounded::<RendererEvent>();
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.push(tx);
|
||||
}
|
||||
rx
|
||||
@@ -27,7 +27,7 @@ impl RendererEventBus {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn broadcast(&self, event: RendererEvent) {
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,14 @@ impl MediaServerEventBus {
|
||||
pub fn subscribe(&self) -> Receiver<MediaServerEvent> {
|
||||
let (tx, rx) = unbounded::<MediaServerEvent>();
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.push(tx);
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub(crate) fn broadcast(&self, event: MediaServerEvent) {
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ pub struct MediaEntry {
|
||||
pub is_container: bool,
|
||||
pub class: String,
|
||||
pub resources: Vec<MediaResource>,
|
||||
pub child_count: Option<u32>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
@@ -709,6 +710,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
is_container: true,
|
||||
class: container.class,
|
||||
resources: Vec::new(),
|
||||
child_count: container.child_count.as_deref().and_then(|s| s.parse().ok()),
|
||||
artist: container.artist,
|
||||
album: None,
|
||||
genre: None,
|
||||
@@ -742,6 +744,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
is_container: false,
|
||||
class: item.class,
|
||||
resources,
|
||||
child_count: None,
|
||||
artist: item.artist,
|
||||
album: item.album,
|
||||
genre: item.genre,
|
||||
|
||||
@@ -12,14 +12,14 @@ use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::extract_linkplay_host;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
/// Raw response from Arylic MCU+PINFGET command
|
||||
@@ -137,7 +137,7 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||
impl ArylicTcpRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
/// Create an ArylicTcpRenderer with a shared queue (for HybridUpnpArylic)
|
||||
@@ -243,198 +243,44 @@ impl PlaybackStatus for ArylicTcpRenderer {
|
||||
|
||||
impl PlaybackPosition for ArylicTcpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let info = match self.fetch_playback_info() {
|
||||
Ok(info) => {
|
||||
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
|
||||
info
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let info = self.fetch_playback_info().map_err(|e| {
|
||||
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
let mut position_info = info.position_info();
|
||||
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
|
||||
|
||||
let mut position = info.position_info();
|
||||
tracing::debug!(
|
||||
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}, track_metadata={:?}, track_uri={:?}",
|
||||
position_info.track_duration,
|
||||
position_info.rel_time,
|
||||
position_info
|
||||
.track_metadata
|
||||
.as_ref()
|
||||
.map(|s| &s[..s.len().min(100)]),
|
||||
position_info.track_uri
|
||||
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}",
|
||||
position.track_duration,
|
||||
position.rel_time,
|
||||
);
|
||||
|
||||
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
|
||||
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
// Replace device metadata with queue metadata (queue is authoritative).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
// Build DIDL metadata XML from cached/protected TrackMetadata
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
tracing::debug!(
|
||||
"ArylicTcp playback_position: using queue metadata - title={:?}, artist={:?}, duration={:?}, is_stream={}",
|
||||
metadata.title,
|
||||
metadata.artist,
|
||||
metadata.duration,
|
||||
metadata.is_continuous_stream
|
||||
);
|
||||
position_info.track_metadata = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("ArylicTcp playback_position: queue item has no metadata");
|
||||
}
|
||||
position_info.track_uri = Some(current_item.uri.clone());
|
||||
} else {
|
||||
tracing::warn!("ArylicTcp playback_position: no current queue item");
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
Ok(position_info)
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for ArylicTcpRenderer {
|
||||
|
||||
impl QueueTransportControl for ArylicTcpRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl HasQueue for ArylicTcpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for ArylicTcpRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for ArylicTcpRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for ArylicTcpRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,96 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::{errors::ControlPointError, model::PlaybackState};
|
||||
use crate::queue::{MusicQueue, QueueBackend};
|
||||
use crate::{errors::ControlPointError, model::PlaybackState, PlaybackItem};
|
||||
|
||||
/// Backend-specific operations for renderers.
|
||||
/// Marker trait for renderer backends that own a `MusicQueue`.
|
||||
///
|
||||
/// This trait provides access to backend-specific resources like the queue.
|
||||
pub trait RendererBackend {
|
||||
/// Returns a reference to the queue associated with this backend.
|
||||
/// Implementing this trait automatically provides the full `QueueBackend`
|
||||
/// blanket implementation (see `queue/backend.rs`). Backends only need to
|
||||
/// return a reference to their `Arc<Mutex<MusicQueue>>` field.
|
||||
pub trait HasQueue {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||
}
|
||||
|
||||
/// Marker trait for renderer backends that track stream continuity.
|
||||
///
|
||||
/// The flag is `true` while the renderer is playing a continuous stream
|
||||
/// (e.g. an internet radio station) and `false` for bounded media files.
|
||||
/// It is used by the watcher to decide whether auto-advance should be
|
||||
/// suppressed when playback stops.
|
||||
pub trait HasContinuousStream {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>>;
|
||||
}
|
||||
|
||||
/// Queue-aware transport control operations.
|
||||
///
|
||||
/// These operations combine queue management with transport control,
|
||||
/// allowing navigation (next/previous) and track selection from the queue.
|
||||
#[allow(dead_code)]
|
||||
pub trait QueueTransportControl {
|
||||
/// Play the next track from the queue.
|
||||
fn play_next(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play the previous track from the queue.
|
||||
#[allow(dead_code)]
|
||||
fn play_previous(&self) -> Result<(), ControlPointError>;
|
||||
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
|
||||
/// Play a specific item from the queue (backend-specific implementation).
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play from the queue at the current index (or initialize to 0 if not set).
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError>;
|
||||
/// This is the default implementation that handles queue navigation.
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
drop(queue);
|
||||
|
||||
let is_stream = crate::music_renderer::is_continuous_stream(item.metadata.as_ref(), &item.uri);
|
||||
*self.continuous_stream().lock().expect("continuous_stream mutex poisoned") = is_stream;
|
||||
|
||||
self.play_item(&item)
|
||||
}
|
||||
|
||||
/// Play the next track from the queue.
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
/// Play the previous track from the queue.
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
/// Play from a specific index in the queue.
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError>;
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical playback position across backends.
|
||||
@@ -47,52 +107,97 @@ pub struct PlaybackPositionInfo {
|
||||
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
|
||||
pub track_uri: Option<String>, // Current track URI
|
||||
}
|
||||
/// Provides the current playback position and track metadata.
|
||||
///
|
||||
/// All time fields use the format `"HH:MM:SS"` (or `None` when unavailable).
|
||||
/// `track_metadata` carries a raw DIDL-Lite XML fragment returned by the device;
|
||||
/// callers that only need structured metadata should use `extract_track_metadata`
|
||||
/// from the watcher module instead.
|
||||
pub trait PlaybackPosition {
|
||||
/// Returns the current playback position information.
|
||||
///
|
||||
/// Returns `Err` if the renderer is unreachable or the query fails.
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Generic abstraction for playback status (transport state).
|
||||
/// Generic abstraction for the current transport state.
|
||||
///
|
||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||
/// # Implementations
|
||||
///
|
||||
/// - **UPnP AV**: backed by `AVTransport::GetTransportInfo`.
|
||||
/// - **OpenHome**: adapted from OH `Info` / `Time` services.
|
||||
/// - **LinkPlay / Arylic**: mapped from the vendor status response.
|
||||
///
|
||||
/// # Postconditions
|
||||
///
|
||||
/// The returned `PlaybackState` must be one of the canonical values defined by
|
||||
/// the `PlaybackState` enum. Backend-specific states that have no canonical
|
||||
/// equivalent should be mapped to the closest approximation (e.g. "BUFFERING"
|
||||
/// → `PlaybackState::Transitioning`).
|
||||
pub trait PlaybackStatus {
|
||||
/// Returns the current transport state of the renderer.
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
|
||||
/// Generic transport control abstraction (play / pause / stop / seek),
|
||||
/// independent of the underlying protocol (UPnP AV, OpenHome, …).
|
||||
///
|
||||
/// # Invariants
|
||||
///
|
||||
/// - `play_uri` sets the active resource and begins playback atomically from the
|
||||
/// caller's perspective. Implementations may split this into two protocol steps
|
||||
/// (e.g. `SetAVTransportURI` + `Play` for UPnP AV) but the caller should not
|
||||
/// need to know.
|
||||
/// - `play` / `pause` / `stop` operate on whatever resource is currently loaded;
|
||||
/// they do not change the queue pointer.
|
||||
/// - `seek_rel_time` uses the format `"HH:MM:SS"`. Backends that do not support
|
||||
/// seeking should return `ControlPointError::NotSupported`.
|
||||
///
|
||||
/// # Relation to `QueueTransportControl`
|
||||
///
|
||||
/// `TransportControl` knows nothing about the queue. `QueueTransportControl`
|
||||
/// extends it with queue-aware navigation (`play_next`, `play_previous`, …).
|
||||
pub trait TransportControl {
|
||||
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
|
||||
/// Load a resource (URI + DIDL-Lite metadata) and begin playback.
|
||||
///
|
||||
/// Selon l'implémentation, cette méthode peut soit :
|
||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||
/// Depending on the backend this may execute as a single atomic operation or as
|
||||
/// two sequential commands (set resource, then play).
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Démarre ou reprend la lecture.
|
||||
/// Start or resume playback of the currently loaded resource.
|
||||
fn play(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Met la lecture en pause.
|
||||
/// Pause the current playback.
|
||||
fn pause(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Arrête la lecture.
|
||||
/// Stop the current playback and release the loaded resource.
|
||||
fn stop(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||
/// Seek to a relative time position expressed as `"HH:MM:SS"`.
|
||||
///
|
||||
/// Returns `ControlPointError::NotSupported` when the backend does not
|
||||
/// implement seeking.
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||
/// Generic volume and mute control abstraction.
|
||||
///
|
||||
/// # Volume scale
|
||||
///
|
||||
/// Volume values are expressed on the native scale of each renderer.
|
||||
/// UPnP AV and OpenHome renderers typically use 0–100. Callers should
|
||||
/// not assume any particular scale; use the values returned by `volume()`
|
||||
/// as the baseline for relative adjustments.
|
||||
pub trait VolumeControl {
|
||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||
/// Returns the current logical volume (renderer-specific scale).
|
||||
fn volume(&self) -> Result<u16, ControlPointError>;
|
||||
|
||||
/// Définit le volume logique (échelle dépendante du renderer).
|
||||
/// Sets the logical volume (renderer-specific scale).
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Indique si le renderer est muet (mute activé).
|
||||
/// Returns `true` when the renderer is muted.
|
||||
fn mute(&self) -> Result<bool, ControlPointError>;
|
||||
|
||||
/// Active ou désactive le mute.
|
||||
/// Enables (`true`) or disables (`false`) mute.
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError>;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ use crate::discovery::chromecast_discovery::{
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
@@ -35,7 +36,7 @@ use crate::DeviceIdentity;
|
||||
use rust_cast::{
|
||||
channels::{
|
||||
heartbeat::HeartbeatResponse,
|
||||
media::{Media, PlayerState as CastPlayerState, StreamType},
|
||||
media::{Media, PlayerState as CastPlayerState, StatusEntry, StreamType},
|
||||
receiver::CastDeviceApp,
|
||||
},
|
||||
CastDevice, ChannelMessage,
|
||||
@@ -171,7 +172,51 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||
impl ChromecastRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
/// Returns `(transport_id, media_entry)` for the currently active Cast session.
|
||||
///
|
||||
/// This encapsulates the repeated sequence:
|
||||
/// connect device → get receiver status → get active app → connect to app → get media status
|
||||
///
|
||||
/// Used by all transport operations (play/pause/stop/seek) and by
|
||||
/// playback_state/playback_position to avoid duplicating this boilerplate.
|
||||
fn get_active_media_entry<'d>(
|
||||
&self,
|
||||
device: &'d CastDevice<'d>,
|
||||
) -> Result<(String, StatusEntry), ControlPointError> {
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError("No active app found".into()))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let entry = media_status
|
||||
.entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError("No media session found".into()))?;
|
||||
|
||||
Ok((app.transport_id, entry))
|
||||
}
|
||||
|
||||
/// Connect to the device with retry on connection failures.
|
||||
@@ -202,15 +247,6 @@ impl TransportControl for ChromecastRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play_uri({})", uri);
|
||||
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"ChromecastRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Signal any existing play thread to stop
|
||||
if let Ok(mut stop) = self.stop_signal.lock() {
|
||||
*stop = true;
|
||||
@@ -377,186 +413,53 @@ impl TransportControl for ChromecastRenderer {
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play()");
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
// Send play command
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.play(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.play(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: pause()");
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.pause(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: stop()");
|
||||
|
||||
// Signal the play thread to stop
|
||||
// Signal the play thread to stop first.
|
||||
// The thread terminates on its own; play_uri() will wait for it if needed.
|
||||
if let Ok(mut stop) = self.stop_signal.lock() {
|
||||
*stop = true;
|
||||
}
|
||||
|
||||
// Note: We don't wait for the thread here as stop() should be quick.
|
||||
// The thread will terminate on its own when it checks stop_signal.
|
||||
// If a new play_uri() is called, it will properly wait for this thread.
|
||||
|
||||
// Also send stop command to the device
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.stop(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
||||
|
||||
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.seek(
|
||||
app.transport_id.as_str(),
|
||||
media_entry.media_session_id,
|
||||
Some(total_seconds),
|
||||
None,
|
||||
)
|
||||
.seek(transport_id.as_str(), entry.media_session_id, Some(total_seconds), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -565,125 +468,57 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
// If no app is running there is no media — return NoMedia without error.
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
tracing::debug!("Chromecast playback_state: {} apps running", status.applications.len());
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} apps running",
|
||||
status.applications.len()
|
||||
);
|
||||
if status.applications.is_empty() {
|
||||
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
|
||||
// If no app is running, return NoMedia
|
||||
let app = match status.applications.first() {
|
||||
Some(app) => {
|
||||
tracing::debug!("Chromecast playback_state: app={}", app.display_name);
|
||||
app
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} media entries",
|
||||
media_status.entries.len()
|
||||
);
|
||||
|
||||
// If no media entry, return NoMedia
|
||||
let media_entry = match media_status.entries.first() {
|
||||
Some(entry) => {
|
||||
match self.get_active_media_entry(&device) {
|
||||
Ok((_, entry)) => {
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: player_state={:?}, current_time={:?}",
|
||||
entry.player_state,
|
||||
entry.current_time
|
||||
entry.player_state, entry.current_time
|
||||
);
|
||||
entry
|
||||
Ok(map_player_state(&entry.player_state))
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
// No media session → device is idle
|
||||
Err(_) => {
|
||||
tracing::debug!("Chromecast playback_state: no media session, returning NoMedia");
|
||||
Ok(PlaybackState::NoMedia)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(map_player_state(&media_entry.player_state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ChromecastRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let device = self.connect_with_retry()?;
|
||||
let (_, entry) = self.get_active_media_entry(&device)?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
let rel_time = entry.current_time.map(|t| format_hhmmss_f64(t as f64));
|
||||
let track_duration = entry.media.as_ref().and_then(|m| m.duration).map(|d| format_hhmmss_f64(d as f64));
|
||||
let track_uri = entry.media.as_ref().map(|m| m.content_id.clone());
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
// Extract position information
|
||||
let rel_time = media_entry
|
||||
.current_time
|
||||
.map(|time| format_hhmmss_f64(time as f64));
|
||||
|
||||
let track_duration = media_entry
|
||||
.media
|
||||
.as_ref()
|
||||
.and_then(|m| m.duration)
|
||||
.map(|dur| format_hhmmss_f64(dur as f64));
|
||||
|
||||
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
let mut position = PlaybackPositionInfo {
|
||||
track: Some(1),
|
||||
rel_time,
|
||||
abs_time: None,
|
||||
track_duration,
|
||||
track_metadata: None, // Chromecast doesn't use DIDL-Lite
|
||||
track_metadata: None,
|
||||
track_uri,
|
||||
})
|
||||
};
|
||||
|
||||
// Replace device metadata with queue metadata (queue is authoritative;
|
||||
// Chromecast does not return DIDL-Lite natively so the queue is the only source).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,139 +634,22 @@ impl VolumeControl for ChromecastRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for ChromecastRenderer {
|
||||
|
||||
impl QueueTransportControl for ChromecastRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl HasQueue for ChromecastRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for ChromecastRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for ChromecastRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for ChromecastRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ use crate::linkplay_client::{
|
||||
};
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
@@ -91,21 +91,12 @@ impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||
impl LinkPlayRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"LinkPlayRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
@@ -155,163 +146,29 @@ impl PlaybackStatus for LinkPlayRenderer {
|
||||
|
||||
impl PlaybackPosition for LinkPlayRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let mut position_info = self.fetch_status()?.position_info();
|
||||
|
||||
// Use queue metadata instead of direct status metadata to benefit from duration protection
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
position_info.track_metadata = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
}
|
||||
position_info.track_uri = Some(current_item.uri.clone());
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
Ok(position_info)
|
||||
let mut position = self.fetch_status()?.position_info();
|
||||
// Replace device metadata with queue metadata (queue is authoritative).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for LinkPlayRenderer {
|
||||
|
||||
impl QueueTransportControl for LinkPlayRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl HasQueue for LinkPlayRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for LinkPlayRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for LinkPlayRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for LinkPlayRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ mod upnp_renderer;
|
||||
mod openhome;
|
||||
mod openhome_renderer;
|
||||
|
||||
mod capabilities;
|
||||
pub mod capabilities;
|
||||
mod chromecast_renderer;
|
||||
|
||||
mod musicrenderer;
|
||||
@@ -18,13 +18,14 @@ pub mod watcher;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||
pub use crate::music_renderer::sleep_timer::SleepTimer;
|
||||
pub use crate::music_renderer::stream_detection::is_continuous_stream_url;
|
||||
pub use crate::music_renderer::stream_detection::{is_continuous_stream, is_continuous_stream_url};
|
||||
use crate::{
|
||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||
errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, RendererInfo,
|
||||
};
|
||||
|
||||
pub trait RendererFromMediaRendererInfo {
|
||||
|
||||
@@ -19,10 +19,6 @@ use crate::events::RendererEventBus;
|
||||
use crate::model::RendererEvent;
|
||||
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
||||
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||
@@ -33,6 +29,10 @@ use crate::music_renderer::watcher::{
|
||||
WatchedState,
|
||||
};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::{
|
||||
HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::online::DeviceConnectionState;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
@@ -46,9 +46,9 @@ use tracing::warn;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaylistBinding {
|
||||
/// MediaServer that owns the playlist container.
|
||||
pub server_id: DeviceId,
|
||||
pub(crate) server_id: DeviceId,
|
||||
/// DIDL-Lite object id of the playlist container.
|
||||
pub container_id: String,
|
||||
pub(crate) container_id: String,
|
||||
/// True once at least one ContainerUpdateIDs notification has been seen.
|
||||
pub(crate) has_seen_update: bool,
|
||||
/// Flag used internally to signal that the queue should be refreshed
|
||||
@@ -58,6 +58,18 @@ pub struct PlaylistBinding {
|
||||
pub(crate) auto_play_on_refresh: bool,
|
||||
}
|
||||
|
||||
impl PlaylistBinding {
|
||||
/// Returns the ID of the MediaServer that owns this playlist container.
|
||||
pub fn server_id(&self) -> &DeviceId {
|
||||
&self.server_id
|
||||
}
|
||||
|
||||
/// Returns the DIDL-Lite object ID of the playlist container.
|
||||
pub fn container_id(&self) -> &str {
|
||||
&self.container_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MusicRendererBackend {
|
||||
@@ -311,6 +323,14 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Main loop for the watcher thread.
|
||||
///
|
||||
/// # Error policy
|
||||
///
|
||||
/// The watcher thread runs for the lifetime of the renderer and never restarts
|
||||
/// automatically. Network/device errors during polling are logged at `debug` level
|
||||
/// and ignored — they are transient and expected when a device is temporarily
|
||||
/// unreachable. Panics inside `poll_and_emit_changes` are caught and logged at
|
||||
/// `error` level so they do not kill the watcher thread.
|
||||
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
||||
let Some(base_interval) = strategy.polling_interval() else {
|
||||
// Pure push strategy - no polling needed (future implementation)
|
||||
@@ -341,7 +361,16 @@ impl MusicRenderer {
|
||||
};
|
||||
|
||||
if self.is_online() {
|
||||
self.poll_and_emit_changes(tick);
|
||||
// Wrap in catch_unwind so a panic in poll logic does not terminate the watcher.
|
||||
let poll_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.poll_and_emit_changes(tick);
|
||||
}));
|
||||
if let Err(_panic) = poll_result {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"poll_and_emit_changes panicked; watcher continues"
|
||||
);
|
||||
}
|
||||
last_activity_time = SystemTime::now();
|
||||
}
|
||||
|
||||
@@ -393,14 +422,19 @@ impl MusicRenderer {
|
||||
|
||||
// Step 2: Do all network calls WITHOUT holding any locks
|
||||
// This prevents blocking other threads that need to read watched_state
|
||||
let position = self.playback_position().ok();
|
||||
let raw_state = self.playback_state().ok();
|
||||
// Errors are logged at trace level — device temporarily unreachable is expected.
|
||||
let position = self.playback_position()
|
||||
.inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_position failed"))
|
||||
.ok();
|
||||
let raw_state = self.playback_state()
|
||||
.inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_state failed"))
|
||||
.ok();
|
||||
|
||||
// Poll volume and mute every other tick (1 second at 500ms interval)
|
||||
let (volume, mute, is_stream) = if tick % 2 == 0 {
|
||||
(
|
||||
self.volume().ok(),
|
||||
self.mute().ok(),
|
||||
self.volume().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "volume poll failed")).ok(),
|
||||
self.mute().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "mute poll failed")).ok(),
|
||||
Some(self.is_playing_a_stream()),
|
||||
)
|
||||
} else {
|
||||
@@ -490,41 +524,21 @@ impl MusicRenderer {
|
||||
if let Some(stream_flag) = is_stream {
|
||||
if stream_flag {
|
||||
if let Some(ref new_duration) = position.track_duration {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
// Parse durations to compare (HH:MM:SS format)
|
||||
let parse_duration = |dur_str: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur_str.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
|
||||
match &state.current_track_duration {
|
||||
Some(stored_duration) => {
|
||||
// Compare new duration with stored one
|
||||
if let (Some(stored_secs), Some(new_secs)) = (
|
||||
parse_duration(stored_duration),
|
||||
parse_duration(new_duration),
|
||||
) {
|
||||
if new_secs > stored_secs {
|
||||
// Duration increased: update stored value and use new one
|
||||
tracing::debug!(
|
||||
"MusicRenderer [{}]: Stream duration increased: {} -> {}",
|
||||
self.info.friendly_name(),
|
||||
stored_duration,
|
||||
new_duration
|
||||
);
|
||||
state.current_track_duration = Some(new_duration.clone());
|
||||
} else {
|
||||
// Duration decreased or equal: keep stored value
|
||||
position.track_duration = Some(stored_duration.clone());
|
||||
}
|
||||
if crate::queue::stream_duration_increased(stored_duration, new_duration) {
|
||||
tracing::debug!(
|
||||
"MusicRenderer [{}]: Stream duration increased: {} -> {}",
|
||||
self.info.friendly_name(),
|
||||
stored_duration,
|
||||
new_duration
|
||||
);
|
||||
state.current_track_duration = Some(new_duration.clone());
|
||||
} else if crate::queue::stream_duration_decreased(stored_duration, new_duration) {
|
||||
// Duration decreased: keep stored value
|
||||
position.track_duration = Some(stored_duration.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -667,7 +681,7 @@ impl MusicRenderer {
|
||||
match state {
|
||||
PlaybackState::Stopped => {
|
||||
{
|
||||
let s = self.state.lock().unwrap();
|
||||
let s = self.state.lock().expect("RendererState mutex poisoned");
|
||||
tracing::debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
has_played = s.has_played_since_track_start,
|
||||
@@ -700,7 +714,7 @@ impl MusicRenderer {
|
||||
// On autorise l'auto-avance si:
|
||||
// 1. On a bien vu PLAYING OU
|
||||
// 2. Le titre a été lancé depuis plus de 20 secondes
|
||||
let track_start = self.state.lock().unwrap().track_start_time;
|
||||
let track_start = self.state.lock().expect("RendererState mutex poisoned").track_start_time;
|
||||
let elapsed = track_start
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.unwrap_or_default();
|
||||
@@ -757,7 +771,7 @@ impl MusicRenderer {
|
||||
PlaybackState::NoMedia => {
|
||||
// Handle end of track (Chromecast returns NoMedia when track ends)
|
||||
// This is equivalent to Stopped for auto-advance purposes
|
||||
let s = self.state.lock().unwrap();
|
||||
let s = self.state.lock().expect("RendererState mutex poisoned");
|
||||
let playback_source = s.playback_source;
|
||||
let has_played = s.has_played_since_track_start;
|
||||
let user_stop = s.user_stop_requested;
|
||||
@@ -824,7 +838,7 @@ impl MusicRenderer {
|
||||
self.set_has_played_flag();
|
||||
}
|
||||
PlaybackState::Transitioning => {
|
||||
let s = self.state.lock().unwrap();
|
||||
let s = self.state.lock().expect("RendererState mutex poisoned");
|
||||
tracing::trace!(
|
||||
renderer = self.info.friendly_name(),
|
||||
has_played = s.has_played_since_track_start,
|
||||
@@ -998,7 +1012,7 @@ impl MusicRenderer {
|
||||
/// Get a clone of the queue Arc (for async sync operations).
|
||||
pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
|
||||
let backend = self.lock_backend_for("queue");
|
||||
crate::music_renderer::capabilities::RendererBackend::queue(&*backend).clone()
|
||||
crate::music_renderer::capabilities::HasQueue::queue(&*backend).clone()
|
||||
}
|
||||
|
||||
/// Get the current queue item without advancing.
|
||||
@@ -1666,13 +1680,13 @@ impl MusicRenderer {
|
||||
|
||||
/// Gets the last known track metadata.
|
||||
pub fn last_metadata(&self) -> Option<TrackMetadata> {
|
||||
self.state.lock().unwrap().last_metadata.clone()
|
||||
self.state.lock().expect("RendererState mutex poisoned").last_metadata.clone()
|
||||
}
|
||||
|
||||
/// Sets the last known track metadata.
|
||||
/// Updates track_start_time and resets current_track_duration only if the metadata actually changes.
|
||||
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
let metadata_changed = state.last_metadata != metadata;
|
||||
if metadata_changed {
|
||||
// Pour les flux continus: utiliser dc:date comme track_start_time réel de diffusion.
|
||||
@@ -1693,23 +1707,23 @@ impl MusicRenderer {
|
||||
|
||||
/// Gets the timestamp when the current track started playing.
|
||||
pub fn track_start_time(&self) -> Option<SystemTime> {
|
||||
self.state.lock().unwrap().track_start_time
|
||||
self.state.lock().expect("RendererState mutex poisoned").track_start_time
|
||||
}
|
||||
|
||||
/// Gets the current playback source.
|
||||
pub fn playback_source(&self) -> PlaybackSource {
|
||||
self.state.lock().unwrap().playback_source
|
||||
self.state.lock().expect("RendererState mutex poisoned").playback_source
|
||||
}
|
||||
|
||||
/// Sets the playback source.
|
||||
pub fn set_playback_source(&self, source: PlaybackSource) {
|
||||
self.state.lock().unwrap().playback_source = source;
|
||||
self.state.lock().expect("RendererState mutex poisoned").playback_source = source;
|
||||
}
|
||||
|
||||
/// Checks if currently playing from queue.
|
||||
pub fn is_playing_from_queue(&self) -> bool {
|
||||
matches!(
|
||||
self.state.lock().unwrap().playback_source,
|
||||
self.state.lock().expect("RendererState mutex poisoned").playback_source,
|
||||
PlaybackSource::FromQueue
|
||||
)
|
||||
}
|
||||
@@ -1720,7 +1734,7 @@ impl MusicRenderer {
|
||||
/// Does NOT change None -> External because that would break queue playback
|
||||
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
|
||||
pub fn mark_external_if_idle(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
if matches!(state.playback_source, PlaybackSource::External) {
|
||||
// Keep External if we were already playing externally
|
||||
} else {
|
||||
@@ -1731,12 +1745,12 @@ impl MusicRenderer {
|
||||
|
||||
/// Marks that the user requested a stop (to prevent auto-advance).
|
||||
pub fn mark_user_stop_requested(&self) {
|
||||
self.state.lock().unwrap().user_stop_requested = true;
|
||||
self.state.lock().expect("RendererState mutex poisoned").user_stop_requested = true;
|
||||
}
|
||||
|
||||
/// Checks and clears the user stop requested flag.
|
||||
pub fn check_and_clear_user_stop_requested(&self) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
let was_requested = state.user_stop_requested;
|
||||
state.user_stop_requested = false;
|
||||
was_requested
|
||||
@@ -1747,21 +1761,21 @@ impl MusicRenderer {
|
||||
/// Sets the has_played_since_track_start flag to true.
|
||||
/// Called when PLAYING state is detected.
|
||||
fn set_has_played_flag(&self) {
|
||||
self.state.lock().unwrap().has_played_since_track_start = true;
|
||||
self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = true;
|
||||
}
|
||||
|
||||
/// Clears the has_played_since_track_start flag.
|
||||
/// Called when stopping playback or starting a new track.
|
||||
/// This is public so that ControlPoint can reset it when jumping to a new track.
|
||||
pub fn clear_has_played_flag(&self) {
|
||||
self.state.lock().unwrap().has_played_since_track_start = false;
|
||||
self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = false;
|
||||
}
|
||||
|
||||
/// Checks and clears the has_played_since_track_start flag.
|
||||
/// Returns true if PLAYING was seen since last track start, false otherwise.
|
||||
/// Used to determine if auto-advance should be allowed.
|
||||
fn check_and_clear_has_played_flag(&self) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
let has_played = state.has_played_since_track_start;
|
||||
state.has_played_since_track_start = false;
|
||||
has_played
|
||||
@@ -1777,7 +1791,7 @@ impl MusicRenderer {
|
||||
/// # Errors
|
||||
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
|
||||
pub fn start_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
state
|
||||
.sleep_timer
|
||||
.start(duration_seconds)
|
||||
@@ -1793,7 +1807,7 @@ impl MusicRenderer {
|
||||
/// # Errors
|
||||
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
|
||||
pub fn update_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
state
|
||||
.sleep_timer
|
||||
.update(duration_seconds)
|
||||
@@ -1804,32 +1818,32 @@ impl MusicRenderer {
|
||||
|
||||
/// Cancels the sleep timer.
|
||||
pub fn cancel_sleep_timer(&self) {
|
||||
self.state.lock().unwrap().sleep_timer.cancel();
|
||||
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.cancel();
|
||||
}
|
||||
|
||||
/// Returns the remaining seconds of the sleep timer, or None if no timer is active.
|
||||
pub fn sleep_timer_remaining(&self) -> Option<u32> {
|
||||
self.state.lock().unwrap().sleep_timer.remaining_seconds()
|
||||
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.remaining_seconds()
|
||||
}
|
||||
|
||||
/// Returns the configured duration of the sleep timer in seconds.
|
||||
pub fn sleep_timer_duration(&self) -> u32 {
|
||||
self.state.lock().unwrap().sleep_timer.duration_seconds()
|
||||
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.duration_seconds()
|
||||
}
|
||||
|
||||
/// Returns true if the sleep timer is active.
|
||||
pub fn is_sleep_timer_active(&self) -> bool {
|
||||
self.state.lock().unwrap().sleep_timer.is_active()
|
||||
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_active()
|
||||
}
|
||||
|
||||
/// Returns true if the sleep timer has expired.
|
||||
pub fn is_sleep_timer_expired(&self) -> bool {
|
||||
self.state.lock().unwrap().sleep_timer.is_expired()
|
||||
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_expired()
|
||||
}
|
||||
|
||||
/// Gets the sleep timer state as a tuple (is_active, duration_seconds, remaining_seconds).
|
||||
pub fn sleep_timer_state(&self) -> (bool, u32, Option<u32>) {
|
||||
let state = self.state.lock().unwrap();
|
||||
let state = self.state.lock().expect("RendererState mutex poisoned");
|
||||
(
|
||||
state.sleep_timer.is_active(),
|
||||
state.sleep_timer.duration_seconds(),
|
||||
@@ -1934,6 +1948,33 @@ pub(crate) fn build_didl_lite_metadata(
|
||||
didl.to_xml()
|
||||
}
|
||||
|
||||
/// Enriches a [`PlaybackPositionInfo`] with metadata from the backend's queue.
|
||||
///
|
||||
/// Overwrites `track_metadata` and `track_uri` with the values stored for the
|
||||
/// current queue item. This is a no-op when the queue is empty or has no
|
||||
/// current item.
|
||||
///
|
||||
/// All backends that hold a local queue (UPnP, LinkPlay, Arylic, Chromecast)
|
||||
/// should call this after fetching the raw device position, so that callers
|
||||
/// always receive consistent, queue-authoritative metadata rather than
|
||||
/// potentially stale or absent metadata coming from the device itself.
|
||||
pub(crate) fn enrich_position_from_queue<B: HasQueue>(
|
||||
backend: &B,
|
||||
position: &mut PlaybackPositionInfo,
|
||||
) {
|
||||
let mut queue = backend.queue().lock().expect("queue mutex poisoned");
|
||||
if let Ok(Some((current_item, _))) = queue.peek_current() {
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
position.track_metadata = Some(build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
));
|
||||
}
|
||||
position.track_uri = Some(current_item.uri.clone());
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceIdentity for MusicRenderer {
|
||||
fn id(&self) -> DeviceId {
|
||||
self.info.id()
|
||||
@@ -2091,7 +2132,7 @@ impl RendererFromMediaRendererInfo for MusicRendererBackend {
|
||||
/// Extracts the duration attribute from the <res> element in DIDL metadata.
|
||||
/// This is used as a fallback when the renderer doesn't provide track_duration
|
||||
/// in GetPositionInfo or similar calls.
|
||||
fn parse_didl_duration(didl_xml: &str) -> Option<String> {
|
||||
pub(crate) fn parse_didl_duration(didl_xml: &str) -> Option<String> {
|
||||
// Parse DIDL-Lite XML properly using pmodidl
|
||||
let didl = match DIDLLite::parse(didl_xml) {
|
||||
Ok(d) => d,
|
||||
@@ -2129,6 +2170,37 @@ fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
|
||||
}
|
||||
|
||||
|
||||
/// Dispatch a method call to the inner backend, using the UPnP field for
|
||||
/// HybridUpnpArylic.
|
||||
macro_rules! dispatch_upnp {
|
||||
($self:expr, $method:ident($($arg:expr),*)) => {
|
||||
match $self {
|
||||
MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Dispatch a method call to the inner backend, using the Arylic field for
|
||||
/// HybridUpnpArylic.
|
||||
macro_rules! dispatch_arylic {
|
||||
($self:expr, $method:ident($($arg:expr),*)) => {
|
||||
match $self {
|
||||
MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.$method($($arg),*),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||
/// the request, returning a standardized error if the backend lacks support.
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
@@ -2145,38 +2217,9 @@ impl TransportControl for MusicRendererBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||||
}
|
||||
}
|
||||
fn play(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, play()) }
|
||||
fn pause(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, pause()) }
|
||||
fn stop(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, stop()) }
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
@@ -2197,49 +2240,10 @@ impl TransportControl for MusicRendererBackend {
|
||||
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
||||
/// always depend on a single [`VolumeControl`] entry point.
|
||||
impl VolumeControl for MusicRendererBackend {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
||||
}
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
||||
MusicRendererBackend::OpenHome(r) => r.mute(),
|
||||
MusicRendererBackend::Upnp(r) => r.mute(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
||||
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
||||
MusicRendererBackend::Upnp(r) => r.set_mute(m),
|
||||
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
|
||||
}
|
||||
}
|
||||
fn volume(&self) -> Result<u16, ControlPointError> { dispatch_arylic!(self, volume()) }
|
||||
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { dispatch_upnp!(self, set_volume(vol)) }
|
||||
fn mute(&self) -> Result<bool, ControlPointError> { dispatch_arylic!(self, mute()) }
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { dispatch_arylic!(self, set_mute(m)) }
|
||||
}
|
||||
|
||||
/// Playback-state queries sourced from the backend best suited for the job.
|
||||
@@ -2263,234 +2267,28 @@ impl PlaybackStatus for MusicRendererBackend {
|
||||
/// regardless of the backend providing the raw transport data.
|
||||
impl PlaybackPosition for MusicRendererBackend {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
||||
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||||
}
|
||||
dispatch_arylic!(self, playback_position())
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for MusicRendererBackend {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.queue(),
|
||||
MusicRendererBackend::OpenHome(r) => r.queue(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.queue(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.queue(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.queue(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue(),
|
||||
}
|
||||
impl HasQueue for MusicRendererBackend {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { dispatch_upnp!(self, queue()) }
|
||||
}
|
||||
|
||||
impl HasContinuousStream for MusicRendererBackend {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
dispatch_arylic!(self, continuous_stream())
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for MusicRendererBackend {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
dispatch_upnp!(self, play_item(item))
|
||||
}
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.play_from_queue(),
|
||||
MusicRendererBackend::OpenHome(r) => r.play_from_queue(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.play_from_queue(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.play_from_queue(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play_from_queue(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_queue(),
|
||||
}
|
||||
dispatch_upnp!(self, play_from_queue())
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.play_next(),
|
||||
MusicRendererBackend::OpenHome(r) => r.play_next(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.play_next(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.play_next(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play_next(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_next(),
|
||||
}
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.play_previous(),
|
||||
MusicRendererBackend::OpenHome(r) => r.play_previous(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.play_previous(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.play_previous(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play_previous(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_previous(),
|
||||
}
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.play_from_index(index),
|
||||
MusicRendererBackend::OpenHome(r) => r.play_from_index(index),
|
||||
MusicRendererBackend::LinkPlay(r) => r.play_from_index(index),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.play_from_index(index),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play_from_index(index),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_index(index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for MusicRendererBackend {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.len(),
|
||||
MusicRendererBackend::OpenHome(r) => r.len(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.len(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.len(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.len(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.track_ids(),
|
||||
MusicRendererBackend::OpenHome(r) => r.track_ids(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.track_ids(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.track_ids(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.track_ids(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.track_ids(),
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.id_to_position(id),
|
||||
MusicRendererBackend::OpenHome(r) => r.id_to_position(id),
|
||||
MusicRendererBackend::LinkPlay(r) => r.id_to_position(id),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.id_to_position(id),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.id_to_position(id),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.id_to_position(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.position_to_id(id),
|
||||
MusicRendererBackend::OpenHome(r) => r.position_to_id(id),
|
||||
MusicRendererBackend::LinkPlay(r) => r.position_to_id(id),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.position_to_id(id),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.position_to_id(id),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.position_to_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.current_track(),
|
||||
MusicRendererBackend::OpenHome(r) => r.current_track(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.current_track(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.current_track(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.current_track(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_track(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.current_index(),
|
||||
MusicRendererBackend::OpenHome(r) => r.current_index(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.current_index(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.current_index(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.current_index(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_index(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.queue_snapshot(),
|
||||
MusicRendererBackend::OpenHome(r) => r.queue_snapshot(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.queue_snapshot(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.queue_snapshot(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.queue_snapshot(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue_snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.set_index(index),
|
||||
MusicRendererBackend::OpenHome(r) => r.set_index(index),
|
||||
MusicRendererBackend::LinkPlay(r) => r.set_index(index),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.set_index(index),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.set_index(index),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_index(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.replace_queue(items, current_index),
|
||||
MusicRendererBackend::OpenHome(r) => r.replace_queue(items, current_index),
|
||||
MusicRendererBackend::LinkPlay(r) => r.replace_queue(items, current_index),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.replace_queue(items, current_index),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.replace_queue(items, current_index),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
||||
upnp.replace_queue(items, current_index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
||||
upnp.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.get_item(index),
|
||||
MusicRendererBackend::OpenHome(r) => r.get_item(index),
|
||||
MusicRendererBackend::LinkPlay(r) => r.get_item(index),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.get_item(index),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.get_item(index),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.get_item(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.replace_item(index, item),
|
||||
MusicRendererBackend::OpenHome(r) => r.replace_item(index, item),
|
||||
MusicRendererBackend::LinkPlay(r) => r.replace_item(index, item),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.replace_item(index, item),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.replace_item(index, item),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.replace_item(index, item),
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.enqueue_items(items, mode),
|
||||
MusicRendererBackend::OpenHome(r) => r.enqueue_items(items, mode),
|
||||
MusicRendererBackend::LinkPlay(r) => r.enqueue_items(items, mode),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.enqueue_items(items, mode),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.enqueue_items(items, mode),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.enqueue_items(items, mode),
|
||||
}
|
||||
dispatch_upnp!(self, play_from_index(index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
||||
use crate::DeviceIdentity;
|
||||
@@ -15,6 +15,7 @@ use crate::music_renderer::openhome::{
|
||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||
build_time_client, build_volume_client,
|
||||
};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
@@ -88,7 +89,7 @@ impl OpenHomeRenderer {
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
pub fn has_playlist(&self) -> bool {
|
||||
@@ -175,14 +176,14 @@ impl OpenHomeRenderer {
|
||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
||||
// Use queue.len() which uses cached track_ids() internally
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
queue.len()
|
||||
}
|
||||
|
||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue.track_ids()
|
||||
} else {
|
||||
@@ -208,7 +209,7 @@ impl OpenHomeRenderer {
|
||||
let insert_after = match after_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue
|
||||
.track_ids()?
|
||||
@@ -266,11 +267,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for OpenHomeRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for OpenHomeRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
@@ -369,7 +365,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let mut cache = self.position_cache.lock().unwrap();
|
||||
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
|
||||
|
||||
// Track calls for warning detection
|
||||
cache.calls_in_last_second.push(now);
|
||||
@@ -410,7 +406,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
let mut track_metadata_xml = None;
|
||||
|
||||
// Get track ID from queue (uses cached data)
|
||||
let queue_guard_for_id = self.queue.lock().unwrap();
|
||||
let queue_guard_for_id = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
|
||||
match oh_queue.current_track() {
|
||||
Ok(id_opt) => track_id = id_opt,
|
||||
@@ -423,7 +419,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
drop(queue_guard_for_id);
|
||||
|
||||
// Use queue API to get current item with cached metadata
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let mut queue_guard = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Ok(Some((current_item, _))) = queue_guard.peek_current() {
|
||||
// Use metadata from queue cache (updated via OpenHome events)
|
||||
track_uri = Some(current_item.uri.clone());
|
||||
@@ -440,7 +436,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
}
|
||||
|
||||
// Check if the URI has changed to detect track changes
|
||||
let mut cached_uri = self.current_track_uri.lock().unwrap();
|
||||
let mut cached_uri = self.current_track_uri.lock().expect("current_track_uri mutex poisoned");
|
||||
let uri_changed = cached_uri.as_ref() != Some(¤t_item.uri);
|
||||
|
||||
if uri_changed {
|
||||
@@ -452,7 +448,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Détecte si la nouvelle URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(¤t_item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream;
|
||||
tracing::debug!("OpenHome URI changed, continuous_stream={}", is_stream);
|
||||
|
||||
*cached_uri = Some(current_item.uri.clone());
|
||||
@@ -488,7 +484,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Update cache with fresh data
|
||||
{
|
||||
let mut cache = self.position_cache.lock().unwrap();
|
||||
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
|
||||
cache.last_position = Some(position_info.clone());
|
||||
cache.last_update = Some(now);
|
||||
}
|
||||
@@ -496,28 +492,6 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
Ok(position_info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
|
||||
#[allow(dead_code)]
|
||||
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
|
||||
// Search for duration attribute in <res> element
|
||||
let res_start = didl.find("<res ")?;
|
||||
let after_res = &didl[res_start..];
|
||||
let tag_close = after_res.find('>')?;
|
||||
let tag_attrs = &after_res[..tag_close];
|
||||
|
||||
if let Some(duration_start) = tag_attrs.find("duration=\"") {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("OpenHome: No duration found in DIDL metadata");
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||
match raw.trim().to_ascii_uppercase().as_str() {
|
||||
"PLAYING" => PlaybackState::Playing,
|
||||
@@ -529,6 +503,11 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||
}
|
||||
|
||||
impl QueueTransportControl for OpenHomeRenderer {
|
||||
fn play_item(&self, _item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("play_item")?;
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let queue = self
|
||||
@@ -553,50 +532,6 @@ impl QueueTransportControl for OpenHomeRenderer {
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self
|
||||
.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
|
||||
let len = queue.len().unwrap_or(0);
|
||||
let current = queue.current_index().ok().flatten();
|
||||
let current_track_id = queue.current_track().ok().flatten();
|
||||
let all_ids = queue.track_ids().ok().unwrap_or_default();
|
||||
tracing::trace!(
|
||||
queue_len = len,
|
||||
current_index = ?current,
|
||||
current_track_id = ?current_track_id,
|
||||
all_track_ids = ?all_ids,
|
||||
"OpenHome play_next: advancing queue"
|
||||
);
|
||||
if !queue.advance()? {
|
||||
tracing::trace!(
|
||||
queue_len = len,
|
||||
current_index = ?current,
|
||||
"OpenHome play_next: advance() returned false — no next track"
|
||||
);
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self
|
||||
.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
// For OpenHome, we need to convert index to track_id
|
||||
let track_id = {
|
||||
@@ -626,90 +561,37 @@ impl QueueTransportControl for OpenHomeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.len()
|
||||
impl HasQueue for OpenHomeRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.track_ids()
|
||||
impl HasContinuousStream for OpenHomeRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
impl OpenHomeRenderer {
|
||||
pub fn replace_queue_with_background(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// ✅ CORRECTION BUG PRODUCTION: On ne charge PAS toutes les métadonnées
|
||||
// dans le thread principal. OpenHome sur 1000 titres inondait la base SQLite
|
||||
// et bloquait TOUS les autres threads (mutex >500ms).
|
||||
//
|
||||
// On fait juste l'insertion minimaliste maintenant. Le préchargement
|
||||
// des métadonnées est délégué à un thread background.
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
|
||||
.replace_queue(items, current_index)?;
|
||||
|
||||
// Background worker: charge les métadonnées petit à petit sans bloquer personne
|
||||
let queue = self.queue.clone();
|
||||
std::thread::spawn(move || {
|
||||
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
|
||||
if let Ok(mut queue) = queue.lock() {
|
||||
// On ne fait que les 10 prochains titres maintenant, le reste on s'en fout
|
||||
if let Ok(Some(idx)) = queue.current_index() {
|
||||
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
|
||||
for i in idx..end {
|
||||
let _ = queue.get_item(i);
|
||||
// Petit délai pour ne pas noyer la base de données
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
@@ -719,41 +601,4 @@ impl QueueBackend for OpenHomeRenderer {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.enqueue_items(items, mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = STREAM_CACHE.lock().unwrap();
|
||||
let cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
|
||||
if let Some(&cached_result) = cache.get(url) {
|
||||
trace!("Cache hit for {}: is_stream={}", url, cached_result);
|
||||
return cached_result;
|
||||
@@ -62,7 +62,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Check if already being verified
|
||||
{
|
||||
let mut pending = PENDING_CHECKS.lock().unwrap();
|
||||
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
|
||||
if pending.contains(url) {
|
||||
debug!(
|
||||
"Stream detection already in progress for {}, returning false temporarily",
|
||||
@@ -93,13 +93,13 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Store in cache
|
||||
{
|
||||
let mut cache = STREAM_CACHE.lock().unwrap();
|
||||
let mut cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
|
||||
cache.insert(url_owned.clone(), result);
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
{
|
||||
let mut pending = PENDING_CHECKS.lock().unwrap();
|
||||
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
|
||||
pending.remove(&url_owned);
|
||||
}
|
||||
|
||||
@@ -185,12 +185,27 @@ fn check_stream_headers(url: &str) -> Result<bool, String> {
|
||||
|
||||
trace!(
|
||||
"Stream detection for {}: content-length={}, chunked={}, streaming_mime={}, is_stream={}",
|
||||
url, has_content_length, is_chunked, is_streaming_mime, is_stream
|
||||
url,
|
||||
has_content_length,
|
||||
is_chunked,
|
||||
is_streaming_mime,
|
||||
is_stream
|
||||
);
|
||||
|
||||
Ok(is_stream)
|
||||
}
|
||||
|
||||
/// Canonical check: returns `true` if this item should be treated as a continuous stream.
|
||||
///
|
||||
/// Checks `metadata.is_continuous_stream` first (already computed at ingest time),
|
||||
/// then falls back to the URL-based HTTP detection.
|
||||
///
|
||||
/// Use this function everywhere transport-layer code needs to decide whether playback is
|
||||
/// a continuous stream (radio) vs bounded media (file/album track).
|
||||
pub fn is_continuous_stream(metadata: Option<&crate::model::TrackMetadata>, uri: &str) -> bool {
|
||||
metadata.map(|m| m.is_continuous_stream).unwrap_or(false) || is_continuous_stream_url(uri)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,10 +3,13 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::PlaybackState;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
|
||||
use crate::music_renderer::musicrenderer::{
|
||||
build_didl_lite_metadata, parse_didl_duration, MusicRendererBackend,
|
||||
};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
@@ -114,7 +117,7 @@ impl UpnpRenderer {
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,80 +177,27 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for UpnpRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
// Get or initialize current index
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the item
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
drop(queue);
|
||||
|
||||
// Build metadata - handle optional TrackMetadata
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let metadata = if let Some(ref track_metadata) = item.metadata {
|
||||
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
|
||||
} else {
|
||||
// Fallback to minimal DIDL-Lite if no metadata
|
||||
format!(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
|
||||
item.protocol_info, item.uri
|
||||
)
|
||||
};
|
||||
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
|
||||
// Log current queue state for debugging
|
||||
let queue_state = {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let idx = queue.current_index().unwrap_or(None);
|
||||
let len = queue.len().unwrap_or(0);
|
||||
let uri = item.uri.clone();
|
||||
let title = item.metadata.as_ref().and_then(|m| m.title.clone());
|
||||
(idx, len, uri, title)
|
||||
};
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
|
||||
queue_state.0,
|
||||
queue_state.1,
|
||||
queue_state.2,
|
||||
queue_state.3,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse et cache la durée du DIDL (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(&metadata);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::debug!("Caching duration from queue DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
|
||||
} else {
|
||||
tracing::debug!("No duration to cache from queue DIDL");
|
||||
*self.cached_duration.lock().unwrap() = None;
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
|
||||
}
|
||||
|
||||
// UPNP: SetAVTransportURI + Play
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_av_transport_uri(&item.uri, &metadata)?;
|
||||
avt.play(0, "1")?;
|
||||
@@ -255,160 +205,19 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
let current_idx = {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let idx = queue.current_index().unwrap_or(None);
|
||||
let len = queue.len().unwrap_or(0);
|
||||
tracing::debug!(
|
||||
current_index = ?idx,
|
||||
queue_len = len,
|
||||
"play_next: attempting to advance"
|
||||
);
|
||||
idx
|
||||
};
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
let new_idx = queue.current_index().unwrap_or(None);
|
||||
tracing::debug!(
|
||||
previous_index = ?current_idx,
|
||||
new_index = ?new_idx,
|
||||
"play_next: advanced"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
// CORRECTIF: Quand on change l'index manuellement (shuffle, sélection d'un titre)
|
||||
// on logue pour être sûr que c'est bien appelé
|
||||
tracing::debug!(index = index, "✅ SHUFFLE / SEEK: play_from_index appelé");
|
||||
|
||||
self.play_from_queue()
|
||||
impl HasQueue for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for UpnpRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for UpnpRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse le DIDL-Lite pour extraire la durée du premier élément <res>
|
||||
fn parse_didl_duration(didl: &str) -> Option<String> {
|
||||
// Recherche de l'élément <res> (avec ou sans espace après)
|
||||
let res_start = didl
|
||||
.find("<res ")
|
||||
.or_else(|| didl.find("<res>"))
|
||||
.or_else(|| didl.find("<res\n"))
|
||||
.or_else(|| didl.find("<res\t"))?;
|
||||
|
||||
let after_res = &didl[res_start..];
|
||||
|
||||
// Recherche de l'attribut duration dans cet élément <res>
|
||||
// Il doit être avant la fermeture du tag (avant '>')
|
||||
if let Some(tag_close) = after_res.find('>') {
|
||||
let tag_attrs = &after_res[..tag_close];
|
||||
|
||||
if let Some(duration_start) = tag_attrs.find("duration=\"") {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("No duration attribute found in DIDL <res> element");
|
||||
None
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||
@@ -422,23 +231,14 @@ impl TransportControl for UpnpRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(meta);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::debug!("Caching duration from DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
|
||||
} else {
|
||||
tracing::debug!("No duration to cache from DIDL");
|
||||
*self.cached_duration.lock().unwrap() = None;
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
|
||||
}
|
||||
|
||||
let avt = self.avtransport()?;
|
||||
@@ -517,57 +317,30 @@ impl PlaybackPosition for UpnpRenderer {
|
||||
);
|
||||
|
||||
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
|
||||
let normalized_duration = raw.track_duration.as_ref().and_then(|d| {
|
||||
if d == "00:00:00" || d == "0:00:00" {
|
||||
None
|
||||
} else {
|
||||
Some(d.clone())
|
||||
}
|
||||
let track_duration = raw.track_duration.as_ref().and_then(|d| {
|
||||
if d == "00:00:00" || d == "0:00:00" { None } else { Some(d.clone()) }
|
||||
});
|
||||
|
||||
let track_duration = normalized_duration;
|
||||
|
||||
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
|
||||
// plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes
|
||||
let mut track_metadata_xml = None;
|
||||
let mut track_uri = raw.track_uri.clone();
|
||||
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
|
||||
// Récupérer l'item courant de la queue
|
||||
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
track_uri = Some(current_item.uri.clone());
|
||||
|
||||
// Build DIDL metadata XML from cached/protected TrackMetadata
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
track_metadata_xml = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
tracing::trace!(
|
||||
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
|
||||
track_duration,
|
||||
raw.rel_time,
|
||||
track_metadata_xml.is_some()
|
||||
);
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
let mut position = PlaybackPositionInfo {
|
||||
track: Some(raw.track),
|
||||
rel_time: raw.rel_time,
|
||||
abs_time: raw.abs_time,
|
||||
track_duration,
|
||||
track_metadata: track_metadata_xml,
|
||||
track_uri,
|
||||
})
|
||||
track_metadata: None,
|
||||
track_uri: raw.track_uri,
|
||||
};
|
||||
|
||||
// Replace device metadata with queue metadata (queue is authoritative and protected
|
||||
// against stale / decreasing durations for streams).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
tracing::trace!(
|
||||
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
|
||||
position.track_duration,
|
||||
position.rel_time,
|
||||
position.track_metadata.is_some()
|
||||
);
|
||||
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2211,7 +2211,7 @@ async fn browse_container(
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None,
|
||||
child_count: e.child_count,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri,
|
||||
@@ -2496,7 +2496,7 @@ async fn search_server(
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None,
|
||||
child_count: e.child_count,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
|
||||
@@ -28,8 +28,85 @@
|
||||
//! - This identity is used by the sync helpers to preserve the current
|
||||
//! track across queue rebuilds when the MediaServer content changes.
|
||||
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
|
||||
/// Blanket implementation of QueueBackend for types that have a queue.
|
||||
/// All methods simply delegate to the underlying MusicQueue.
|
||||
impl<T: HasQueue> QueueBackend for T {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").enqueue_items(items, mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level enqueue mode.
|
||||
///
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{errors::ControlPointError, RendererInfo};
|
||||
|
||||
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
/// Used to protect stream durations from decreasing for the same track.
|
||||
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
pub(crate) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
@@ -30,7 +30,7 @@ pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||
pub(crate) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
|
||||
@@ -106,7 +106,7 @@ impl MusicQueue {
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().unwrap();
|
||||
let q = queue_arc.lock().expect("MusicQueue mutex poisoned");
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
@@ -129,6 +129,14 @@ impl MusicQueue {
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
// Protocol for the three AtomicBools:
|
||||
// sync_in_progress : set to true before spawn, cleared on Drop via Guard.
|
||||
// sync_pending : set to true by a concurrent caller that arrives while
|
||||
// a sync is already running. The worker re-fetches items
|
||||
// and loops when it detects this flag on exit.
|
||||
// sync_cancel_token: set to true when a new sync request interrupts an
|
||||
// in-progress one. Passed into QueueBackend::sync_queue
|
||||
// so it can abort early.
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
@@ -136,113 +144,137 @@ impl MusicQueue {
|
||||
}
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
|
||||
let mut current_items = items;
|
||||
let mut current_on_ready = Some(on_ready);
|
||||
let mut on_complete = Some(on_complete);
|
||||
|
||||
// Error policy: sync errors are logged by sync_worker_loop (warn level) and
|
||||
// the thread exits normally. No restart — a new sync can be scheduled via
|
||||
// schedule_sync. The Guard Drop clears sync_in_progress unconditionally,
|
||||
// even on panic, keeping the AtomicBool protocol consistent.
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
// Extract the real on_ready BEFORE locking the queue.
|
||||
// on_ready may call play_from_queue() which re-locks the queue,
|
||||
// so we must NOT call it while holding queue_arc.
|
||||
let real_on_ready = current_on_ready.take().flatten();
|
||||
let on_ready_triggered = Arc::new(AtomicBool::new(false));
|
||||
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
|
||||
real_on_ready.as_ref().map(|_| {
|
||||
let flag = Arc::clone(&on_ready_triggered);
|
||||
Box::new(move || {
|
||||
flag.store(true, SeqCst);
|
||||
}) as Box<dyn FnOnce() + Send + 'static>
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
items = current_items.len(),
|
||||
has_on_ready = real_on_ready.is_some(),
|
||||
"queue-sync: calling sync_queue"
|
||||
);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().unwrap();
|
||||
<MusicQueue as QueueBackend>::sync_queue(
|
||||
&mut q,
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
proxy_on_ready,
|
||||
)
|
||||
};
|
||||
// Queue lock is released here.
|
||||
// Now safe to call on_ready (which may re-lock the queue).
|
||||
// If on_ready was triggered by the proxy, consume and call it.
|
||||
// If not (cancelled before first insert), keep it to pass to retry.
|
||||
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready triggered, calling callback"
|
||||
);
|
||||
if let Some(f) = real_on_ready {
|
||||
f();
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if real_on_ready.is_some() {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready not triggered, carrying to next attempt"
|
||||
);
|
||||
}
|
||||
real_on_ready
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: cancelled"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: completed successfully"
|
||||
);
|
||||
if let Some(cb) = on_complete.take() {
|
||||
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||
cb(queue_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(carry_on_ready);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::sync_worker_loop(
|
||||
queue_arc,
|
||||
items,
|
||||
pending_items_fn,
|
||||
on_ready,
|
||||
on_complete,
|
||||
sync_pending,
|
||||
sync_cancel_token,
|
||||
);
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
|
||||
/// Inner loop executed by the sync worker thread.
|
||||
///
|
||||
/// Runs at least once with `initial_items`. If a new sync request arrives while the
|
||||
/// loop is running (`sync_pending` becomes true), it re-fetches items via
|
||||
/// `pending_items_fn` and iterates again, allowing the latest playlist state to win.
|
||||
fn sync_worker_loop(
|
||||
queue_arc: Arc<Mutex<MusicQueue>>,
|
||||
initial_items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send>,
|
||||
initial_on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut current_items = initial_items;
|
||||
let mut current_on_ready = Some(initial_on_ready);
|
||||
let mut on_complete = Some(on_complete);
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
// Extract the real on_ready BEFORE locking the queue.
|
||||
// on_ready may call play_from_queue() which re-locks the queue,
|
||||
// so we must NOT call it while holding queue_arc.
|
||||
let real_on_ready = current_on_ready.take().flatten();
|
||||
let on_ready_triggered = Arc::new(AtomicBool::new(false));
|
||||
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
|
||||
real_on_ready.as_ref().map(|_| {
|
||||
let flag = Arc::clone(&on_ready_triggered);
|
||||
Box::new(move || {
|
||||
flag.store(true, SeqCst);
|
||||
}) as Box<dyn FnOnce() + Send + 'static>
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
items = current_items.len(),
|
||||
has_on_ready = real_on_ready.is_some(),
|
||||
"queue-sync: calling sync_queue"
|
||||
);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().expect("MusicQueue mutex poisoned");
|
||||
<MusicQueue as QueueBackend>::sync_queue(
|
||||
&mut q,
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
proxy_on_ready,
|
||||
)
|
||||
};
|
||||
// Queue lock is released here.
|
||||
// Now safe to call on_ready (which may re-lock the queue).
|
||||
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready triggered, calling callback"
|
||||
);
|
||||
if let Some(f) = real_on_ready {
|
||||
f();
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if real_on_ready.is_some() {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready not triggered, carrying to next attempt"
|
||||
);
|
||||
}
|
||||
real_on_ready
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: cancelled"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: completed successfully"
|
||||
);
|
||||
if let Some(cb) = on_complete.take() {
|
||||
let queue_len = queue_arc.lock().expect("MusicQueue mutex poisoned").len().unwrap_or(0);
|
||||
cb(queue_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(carry_on_ready);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for MusicQueue {
|
||||
|
||||
@@ -233,16 +233,16 @@ impl OpenHomeQueue {
|
||||
|
||||
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
|
||||
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
|
||||
}
|
||||
|
||||
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
|
||||
fn invalidate_all_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.uri_by_id.lock().unwrap().clear();
|
||||
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
|
||||
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
|
||||
self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned").invalidate();
|
||||
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").clear();
|
||||
}
|
||||
|
||||
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
|
||||
@@ -390,8 +390,8 @@ impl OpenHomeQueue {
|
||||
new_metadata: Option<crate::model::TrackMetadata>,
|
||||
uri: &str,
|
||||
) {
|
||||
let mut cache = self.metadata_cache.lock().unwrap();
|
||||
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||
let mut cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
|
||||
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
|
||||
|
||||
// Update URI cache
|
||||
if !uri.is_empty() {
|
||||
@@ -481,7 +481,7 @@ impl OpenHomeQueue {
|
||||
// Le cache contient les métadonnées stables mises lors de l'insertion
|
||||
// Les métadonnées de l'entry (venant de ReadList) changent pour les streams
|
||||
let metadata = {
|
||||
let cache = self.metadata_cache.lock().unwrap();
|
||||
let cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
|
||||
if let Some(cached_meta) = cache.get(&entry.id) {
|
||||
// Utiliser les métadonnées stables du cache
|
||||
tracing::trace!(
|
||||
@@ -557,7 +557,7 @@ impl OpenHomeQueue {
|
||||
}
|
||||
if track_id as usize != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ impl OpenHomeQueue {
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -858,7 +858,7 @@ impl OpenHomeQueue {
|
||||
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
}
|
||||
} else {
|
||||
for idx in (0..current_track_ids.len()).rev() {
|
||||
@@ -868,7 +868,7 @@ impl OpenHomeQueue {
|
||||
if !keep_current[idx] {
|
||||
let track_id = current_track_ids[idx];
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1099,7 +1099,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.track_ids_cache.lock().unwrap();
|
||||
let mut cache = self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_ids) = cache.get() {
|
||||
@@ -1147,7 +1147,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
// Hold lock during entire operation to prevent race conditions
|
||||
let mut cache = self.current_track_id_cache.lock().unwrap();
|
||||
let mut cache = self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned");
|
||||
|
||||
// Return cached value if valid
|
||||
if let Some(cached_id) = cache.get() {
|
||||
@@ -1192,7 +1192,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
const MAX_BATCH: usize = 256;
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(MAX_BATCH) {
|
||||
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||
if let Some(cached) = self.read_list_cache.lock().expect("read_list_cache mutex poisoned").get(chunk) {
|
||||
trace!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"ReadList cache hit for {} IDs",
|
||||
@@ -1283,7 +1283,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.invalidate_all_caches();
|
||||
@@ -1340,7 +1340,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"sync_queue: Empty playlist - clearing queue with delete_all"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
@@ -1364,7 +1364,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
|
||||
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
|
||||
for item in &new_items {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
@@ -1583,8 +1583,8 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.uri_by_id.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").remove(&track_id);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
if ci == Some(index) {
|
||||
|
||||
@@ -4,13 +4,18 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
||||
use tracing::{debug, trace, warn};
|
||||
use tracing::{debug, trace, warn, info};
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
|
||||
|
||||
/// Number of retries for transient transport failures on control actions.
|
||||
const SOAP_CONTROL_MAX_RETRIES: usize = 2;
|
||||
/// Delay between retries (ms). Kept short: devices usually recover in <200 ms.
|
||||
const SOAP_CONTROL_RETRY_DELAY_MS: u64 = 300;
|
||||
|
||||
fn get_soap_agent() -> Arc<Agent> {
|
||||
SOAP_AGENT
|
||||
.get_or_init(|| {
|
||||
@@ -43,19 +48,44 @@ pub fn build_soap_body(
|
||||
build_soap_request(service_type, action, args)
|
||||
}
|
||||
|
||||
/// Invoke a UPnP SOAP action on a control URL.
|
||||
/// Invoke a UPnP SOAP control action with automatic retry on transient failures.
|
||||
///
|
||||
/// - `control_url`: full HTTP URL of the service control endpoint
|
||||
/// - `service_type`: service URN
|
||||
/// - `action`: action name
|
||||
/// - `args`: list of (name, value)
|
||||
/// Use this for **control operations** (Play, Pause, Stop, SetAVTransportURI, …)
|
||||
/// where a transient TCP error should be absorbed silently.
|
||||
///
|
||||
/// Retries are attempted only on [`ControlPointError::is_transient_soap_error`]
|
||||
/// (i.e. transport-level failures). Protocol-level errors (UPnP faults,
|
||||
/// HTTP 4xx/5xx with a valid body) propagate immediately without retry.
|
||||
///
|
||||
/// For **polling reads** (GetTransportInfo, GetPositionInfo, …) prefer
|
||||
/// [`invoke_upnp_action_with_timeout`] directly so that a slow device does
|
||||
/// not hold the watcher thread for multiple retry cycles.
|
||||
pub fn invoke_upnp_action(
|
||||
control_url: &str,
|
||||
service_type: &str,
|
||||
action: &str,
|
||||
args: &[(&str, &str)],
|
||||
) -> Result<SoapCallResult, ControlPointError> {
|
||||
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
|
||||
let retry_delay = Duration::from_millis(SOAP_CONTROL_RETRY_DELAY_MS);
|
||||
|
||||
for attempt in 0..=SOAP_CONTROL_MAX_RETRIES {
|
||||
match invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if e.is_transient_soap_error() && attempt < SOAP_CONTROL_MAX_RETRIES => {
|
||||
info!(
|
||||
url = control_url,
|
||||
action = action,
|
||||
attempt = attempt + 1,
|
||||
error = %e,
|
||||
"Transient SOAP error, retrying"
|
||||
);
|
||||
std::thread::sleep(retry_delay);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
// Unreachable: the loop either returns Ok or propagates Err above.
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub fn invoke_upnp_action_with_timeout(
|
||||
|
||||
@@ -12,7 +12,11 @@ use crate::{
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
/// Timeout for slow/long control actions (SetAVTransportURI, SetNextAVTransportURI).
|
||||
const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Timeout for fast polling-read actions (GetTransportInfo, GetPositionInfo).
|
||||
/// Must be well below the watcher short-interval (500 ms) to avoid cascading lateness.
|
||||
const AVTRANSPORT_POLL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AvTransportClient {
|
||||
@@ -40,11 +44,12 @@ impl AvTransportClient {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetTransportInfo",
|
||||
&args,
|
||||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
@@ -381,11 +386,12 @@ impl AvTransportClient {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetPositionInfo",
|
||||
&args,
|
||||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
|
||||
@@ -817,7 +817,7 @@ impl OhProductClient {
|
||||
|
||||
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.source_xml_cache.lock().unwrap();
|
||||
let mut cache = self.source_xml_cache.lock().expect("source_xml_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_sources) = cache.get() {
|
||||
@@ -841,7 +841,7 @@ impl OhProductClient {
|
||||
|
||||
pub fn source_index(&self) -> Result<u32, ControlPointError> {
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.source_index_cache.lock().unwrap();
|
||||
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_index) = cache.get() {
|
||||
@@ -881,7 +881,7 @@ impl OhProductClient {
|
||||
|
||||
// Invalidate cache after write operation
|
||||
if result.is_ok() {
|
||||
let mut cache = self.source_index_cache.lock().unwrap();
|
||||
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
||||
cache.invalidate();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
soap_client::{
|
||||
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
|
||||
invoke_upnp_action, parse_upnp_error,
|
||||
invoke_upnp_action, invoke_upnp_action_with_timeout, parse_upnp_error,
|
||||
},
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Timeout for fast polling-read actions (GetVolume, GetMute).
|
||||
const RENDERING_CONTROL_POLL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderingControlClient {
|
||||
pub control_url: String,
|
||||
@@ -30,8 +35,13 @@ impl RenderingControlClient {
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetVolume",
|
||||
&args,
|
||||
Some(RENDERING_CONTROL_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
ensure_success("GetVolume", &call_result)?;
|
||||
|
||||
@@ -90,8 +100,13 @@ impl RenderingControlClient {
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetMute",
|
||||
&args,
|
||||
Some(RENDERING_CONTROL_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
ensure_success("GetMute", &call_result)?;
|
||||
|
||||
|
||||
@@ -29,9 +29,17 @@ sha1 = "0.10"
|
||||
hex = "0.4"
|
||||
md-5 = "0.10"
|
||||
|
||||
# Crypto CMAF (AES-CTR déchiffrement frames, AES-CBC dérobage clé, HKDF dérivation)
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
ctr = "0.9"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
# Cache en mémoire avec TTL
|
||||
moka = { version = "0.12", features = ["future"] }
|
||||
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -53,6 +61,7 @@ pmodidl = { path = "../pmodidl" }
|
||||
# Intégration avec pmoserver pour l'API HTTP
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
tokio-util = { workspace = true, optional = true }
|
||||
rusqlite = { version = "0.37", features = ["bundled"], optional = true }
|
||||
|
||||
# Documentation OpenAPI
|
||||
@@ -68,7 +77,7 @@ pmocache = { path = "../pmocache" }
|
||||
[features]
|
||||
default = ["async-trait-support"]
|
||||
# Feature pour activer les extensions pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
|
||||
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa", "dep:tokio-util"]
|
||||
# Feature pour activer le support serveur (cache registry)
|
||||
server = ["pmosource/server"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée de l'API
|
||||
@@ -182,6 +183,17 @@ struct FileUrlResponse {
|
||||
format_id: u8,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint track/getList
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListResponse {
|
||||
tracks: TrackListItems,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListItems {
|
||||
items: Vec<TrackResponse>,
|
||||
}
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -213,6 +225,66 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails de plusieurs tracks en une ou plusieurs requêtes batch.
|
||||
///
|
||||
/// Utilise l'endpoint `track/getList` (max 50 IDs par appel). Les fenêtres
|
||||
/// supérieures à 50 sont découpées et appellées en série. L'ordre de sortie
|
||||
/// correspond à l'ordre des `track_ids` en entrée.
|
||||
///
|
||||
/// Requiert que le secret s4 soit configuré.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
const MAX_PER_CALL: usize = 50;
|
||||
if track_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if track_ids.len() <= MAX_PER_CALL {
|
||||
return self.get_tracks_batch_chunk(track_ids).await;
|
||||
}
|
||||
debug!("get_tracks_batch: {} IDs en fenêtres de {}", track_ids.len(), MAX_PER_CALL);
|
||||
let mut all = Vec::with_capacity(track_ids.len());
|
||||
for chunk in track_ids.chunks(MAX_PER_CALL) {
|
||||
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
|
||||
all.append(&mut tracks);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
async fn get_tracks_batch_chunk(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
use super::signing;
|
||||
|
||||
let secret = self.secret().ok_or_else(|| {
|
||||
QobuzError::Configuration("Secret s4 requis pour track/getList".to_string())
|
||||
})?;
|
||||
|
||||
let ids_csv = track_ids.join(",");
|
||||
let timestamp = signing::get_timestamp();
|
||||
let signature = signing::sign_track_get_list(&ids_csv, ×tamp, &secret);
|
||||
|
||||
let query_params = [
|
||||
("request_ts", timestamp.as_str()),
|
||||
("request_sig", signature.as_str()),
|
||||
];
|
||||
|
||||
// Les IDs sont envoyés comme tableau d'entiers dans le body JSON
|
||||
let ids_as_numbers: Vec<u64> = track_ids
|
||||
.iter()
|
||||
.filter_map(|id| id.parse().ok())
|
||||
.collect();
|
||||
let body = serde_json::json!({ "tracks_id": ids_as_numbers });
|
||||
|
||||
debug!("get_tracks_batch_chunk POST {} IDs", track_ids.len());
|
||||
let response: TrackListResponse = self
|
||||
.post_json_with_query("/track/getList", &query_params, body)
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| Self::parse_track(t, None))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère les détails d'une track
|
||||
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
||||
debug!("Fetching track {}", track_id);
|
||||
@@ -339,13 +411,20 @@ impl QobuzApi {
|
||||
Ok(Self::parse_playlist(response))
|
||||
}
|
||||
|
||||
/// Récupère les tracks d'une playlist
|
||||
/// Récupère les tracks d'une playlist.
|
||||
///
|
||||
/// Phase 1 : pagination de `/playlist/get?extra=tracks` pour collecter les IDs
|
||||
/// et les données de base.
|
||||
/// Phase 2 (si secret disponible) : enrichissement via `track/getList` pour
|
||||
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth, channels).
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
let mut all_tracks = Vec::new();
|
||||
let mut ordered_ids: Vec<String> = Vec::new();
|
||||
let mut fallback_tracks: Vec<Track> = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
|
||||
// Phase 1 : pagination pour collecter les IDs et les tracks de base
|
||||
loop {
|
||||
let offset_str = offset.to_string();
|
||||
let limit_str = PAGE_SIZE.to_string();
|
||||
@@ -360,7 +439,10 @@ impl QobuzApi {
|
||||
if let Some(tracks) = response.tracks {
|
||||
let total = tracks.total.unwrap_or(0);
|
||||
let count = tracks.items.len() as u32;
|
||||
all_tracks.extend(tracks.items.into_iter().map(|t| Self::parse_track(t, None)));
|
||||
for t in tracks.items {
|
||||
ordered_ids.push(t.id.clone());
|
||||
fallback_tracks.push(Self::parse_track(t, None));
|
||||
}
|
||||
offset += count;
|
||||
if count == 0 || offset >= total {
|
||||
break;
|
||||
@@ -370,8 +452,23 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Fetched {} tracks total for playlist {}", all_tracks.len(), playlist_id);
|
||||
Ok(all_tracks)
|
||||
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
|
||||
|
||||
if ordered_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Phase 2 : enrichissement via track/getList pour métadonnées complètes
|
||||
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||
let mut track_map: HashMap<String, Track> =
|
||||
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||
let enriched: Vec<Track> = ordered_ids
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!("Fetched {} tracks for playlist {} via track/getList", enriched.len(), playlist_id);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Récupère la liste des genres
|
||||
|
||||
261
pmoqobuz/src/api/cmaf.rs
Normal file
261
pmoqobuz/src/api/cmaf.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! Endpoints API CMAF de Qobuz : session/start et file/url.
|
||||
//!
|
||||
//! Ces endpoints implémentent le nouveau pipeline de streaming Qobuz
|
||||
//! qui remplace progressivement `/track/getFileUrl`.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cmaf::crypto::{compute_request_sig, CMAF_SEED};
|
||||
use crate::error::{QobuzError, Result};
|
||||
|
||||
/// État d'une session CMAF active.
|
||||
pub struct CmafSession {
|
||||
pub session_id: String,
|
||||
pub infos: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /session/start.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SessionStartResponse {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
infos: Option<String>,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /file/url (CMAF).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CmafFileUrlResponse {
|
||||
/// Modèle d'URL avec le placeholder `$SEGMENT$`.
|
||||
#[serde(default)]
|
||||
pub url_template: Option<String>,
|
||||
/// Clé de contenu enveloppée, format `"qbz-1.wrapped_b64url.iv_b64url"`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// Nombre de segments audio (hors segment init).
|
||||
pub n_segments: u8,
|
||||
#[serde(default)]
|
||||
pub format_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sampling_rate: Option<u32>,
|
||||
/// Profondeur de bits (champ v1 de l'API).
|
||||
#[serde(default)]
|
||||
pub bits_depth: Option<u32>,
|
||||
/// Profondeur de bits (champ v2 de l'API).
|
||||
#[serde(default)]
|
||||
pub bit_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl CmafFileUrlResponse {
|
||||
/// Retourne la profondeur de bits en préférant `bits_depth` puis `bit_depth`.
|
||||
pub fn resolved_bit_depth(&self) -> Option<u32> {
|
||||
self.bits_depth.or(self.bit_depth)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Signe une requête /session/start.
|
||||
fn sign_session_start(timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("profile", "qbz-1".to_string());
|
||||
compute_request_sig("sessionstart", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Signe une requête /file/url.
|
||||
fn sign_file_url(track_id: &str, format_id: u32, timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("format_id", format_id.to_string());
|
||||
args.insert("intent", "stream".to_string());
|
||||
args.insert("track_id", track_id.to_string());
|
||||
compute_request_sig("fileurl", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Gestionnaire de session CMAF avec renouvellement automatique.
|
||||
///
|
||||
/// Utilise le pattern double-checked lock : fast path sous read guard,
|
||||
/// slow path sous write guard exclusif pour éviter les renouvellements
|
||||
/// concurrents qui produiraient des `infos` incohérents avec les `key`
|
||||
/// retournées par `/file/url`.
|
||||
pub struct CmafSessionManager {
|
||||
session: RwLock<Option<CmafSession>>,
|
||||
}
|
||||
|
||||
impl CmafSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self { session: RwLock::new(None) }
|
||||
}
|
||||
|
||||
/// Retourne `(session_id, infos)` d'une session valide, en en démarrant
|
||||
/// une nouvelle si la session courante est absente ou expire dans < 60s.
|
||||
pub async fn ensure_session(
|
||||
&self,
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let now = current_timestamp();
|
||||
|
||||
// Fast path : session existante avec > 60s restants.
|
||||
{
|
||||
let guard = self.session.read().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path : prend le verrou d'écriture et vérifie à nouveau.
|
||||
let mut guard = self.session.write().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
info!("[CMAF] Démarrage d'une nouvelle session");
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_session_start(timestamp);
|
||||
|
||||
let url = format!("{}/session/start", BASE_URL);
|
||||
let response = http
|
||||
.post(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.form(&[
|
||||
("profile", "qbz-1"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(QobuzError::ApiError {
|
||||
code: status.as_u16(),
|
||||
message: format!("session/start échoué: {}", body),
|
||||
});
|
||||
}
|
||||
|
||||
let resp: SessionStartResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse session/start: {}", e))
|
||||
})?;
|
||||
|
||||
let infos = resp.infos.unwrap_or_default();
|
||||
info!(
|
||||
"[CMAF] Session démarrée: id={}..., expires_at={}",
|
||||
&resp.session_id[..resp.session_id.len().min(8)],
|
||||
resp.expires_at,
|
||||
);
|
||||
|
||||
let session_id = resp.session_id.clone();
|
||||
let infos_clone = infos.clone();
|
||||
|
||||
*guard = Some(CmafSession {
|
||||
session_id: resp.session_id,
|
||||
infos,
|
||||
expires_at: resp.expires_at,
|
||||
});
|
||||
|
||||
Ok((session_id, infos_clone))
|
||||
}
|
||||
|
||||
/// Invalide la session courante (utile en cas d'erreur de déchiffrement).
|
||||
pub async fn invalidate(&self) {
|
||||
*self.session.write().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'URL de fichier CMAF pour un track.
|
||||
///
|
||||
/// Inclut le retry sur les erreurs transitoires (5xx, 429, erreurs réseau).
|
||||
/// Un 404 retourne immédiatement une erreur `NotFound`.
|
||||
pub async fn get_file_url(
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
session_id: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<CmafFileUrlResponse> {
|
||||
use crate::retry::{classify_reqwest, classify_status, retry_transient, DEFAULT_MAX_ATTEMPTS};
|
||||
|
||||
let url = format!("{}/file/url", BASE_URL);
|
||||
|
||||
let result = retry_transient(
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
"CMAF file/url",
|
||||
|e: &QobuzError| matches!(e, QobuzError::RateLimitExceeded | QobuzError::ApiError { code: 500..=599, .. }),
|
||||
|_attempt| {
|
||||
let url = url.clone();
|
||||
async move {
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_file_url(track_id, format_id, timestamp);
|
||||
|
||||
let response = http
|
||||
.get(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.header("X-Session-Id", session_id)
|
||||
.query(&[
|
||||
("track_id", track_id),
|
||||
("format_id", &format_id.to_string()),
|
||||
("intent", "stream"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
tracing::info!("[CMAF] file/url track_id={} format_id={} status={}", track_id, format_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
let code = status.as_u16();
|
||||
return Err(match code {
|
||||
404 => QobuzError::NotFound(format!("track {} non disponible", track_id)),
|
||||
429 => QobuzError::RateLimitExceeded,
|
||||
_ => QobuzError::ApiError {
|
||||
code,
|
||||
message: format!("file/url status {}", code),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let file_url: CmafFileUrlResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse file/url: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[CMAF] file/url: n_segments={}, mime={:?}, sampling_rate={:?}",
|
||||
file_url.n_segments,
|
||||
file_url.mime_type,
|
||||
file_url.sampling_rate,
|
||||
);
|
||||
|
||||
Ok(file_url)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod cmaf;
|
||||
pub mod signing;
|
||||
pub mod spoofer;
|
||||
pub mod user;
|
||||
|
||||
use crate::api::cmaf::CmafSessionManager;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::AudioFormat;
|
||||
use reqwest::{Client, Response};
|
||||
@@ -95,6 +97,8 @@ pub struct QobuzApi {
|
||||
user_id: RwLock<Option<String>>,
|
||||
/// Format audio par défaut
|
||||
format_id: AudioFormat,
|
||||
/// Gestionnaire de session CMAF (renouvellement automatique thread-safe)
|
||||
pub(crate) cmaf_session: CmafSessionManager,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
@@ -114,6 +118,7 @@ impl QobuzApi {
|
||||
user_auth_token: RwLock::new(None),
|
||||
user_id: RwLock::new(None),
|
||||
format_id: AudioFormat::default(),
|
||||
cmaf_session: CmafSessionManager::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,6 +274,37 @@ impl QobuzApi {
|
||||
self.request("POST", endpoint, params).await
|
||||
}
|
||||
|
||||
/// Effectue un POST avec signature en query params et données en JSON body.
|
||||
///
|
||||
/// Utilisé par les endpoints qui attendent une structure JSON complexe
|
||||
/// (ex: `track/getList` avec `{"tracks_id": [...]}`).
|
||||
pub(crate) async fn post_json_with_query<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
query_params: &[(&str, &str)],
|
||||
body: serde_json::Value,
|
||||
) -> Result<T> {
|
||||
let url = format!("{}{}", API_BASE_URL, endpoint);
|
||||
debug!("POST JSON {} with {} query params", url, query_params.len());
|
||||
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let mut builder = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-App-Id", &app_id)
|
||||
.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2")
|
||||
.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id")
|
||||
.query(query_params)
|
||||
.json(&body);
|
||||
|
||||
if let Some(token) = self.auth_token() {
|
||||
builder = builder.header("X-User-Auth-Token", token);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
self.handle_response(response, endpoint, query_params).await
|
||||
}
|
||||
|
||||
/// Effectue une requête à l'API (générique)
|
||||
async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
@@ -316,6 +352,57 @@ impl QobuzApi {
|
||||
self.handle_response(response, endpoint, params).await
|
||||
}
|
||||
|
||||
/// Retourne le client HTTP interne (utilisé par les endpoints CMAF).
|
||||
pub(crate) fn http_client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Assure qu'une session CMAF valide existe et retourne `(session_id, infos)`.
|
||||
///
|
||||
/// Crée ou renouvelle automatiquement la session si elle est absente ou expire
|
||||
/// dans moins de 60 secondes. Les appels concurrents sont sérialisés pour
|
||||
/// éviter des sessions incohérentes.
|
||||
pub async fn ensure_cmaf_session(&self) -> Result<(String, String)> {
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let auth_token = self
|
||||
.user_auth_token
|
||||
.read()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
|
||||
|
||||
self.cmaf_session
|
||||
.ensure_session(&self.client, &app_id, &auth_token)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère l'URL de fichier CMAF pour un track avec le format donné.
|
||||
pub async fn get_cmaf_file_url(
|
||||
&self,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<crate::api::cmaf::CmafFileUrlResponse> {
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let auth_token = self
|
||||
.user_auth_token
|
||||
.read()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
|
||||
|
||||
let (session_id, _infos) = self.ensure_cmaf_session().await?;
|
||||
|
||||
crate::api::cmaf::get_file_url(
|
||||
&self.client,
|
||||
&app_id,
|
||||
&auth_token,
|
||||
&session_id,
|
||||
track_id,
|
||||
format_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Traite la réponse HTTP
|
||||
async fn handle_response<T: DeserializeOwned>(
|
||||
&self,
|
||||
|
||||
@@ -88,6 +88,20 @@ pub fn sign_track_get_file_url(
|
||||
/// # Returns
|
||||
///
|
||||
/// Signature MD5 hexadécimale
|
||||
/// Signe une requête track/getList
|
||||
///
|
||||
/// Chaîne signée : `"trackgetList" + "tracks_id" + ids_csv + timestamp + secret`
|
||||
/// où `ids_csv` est la liste des IDs séparés par des virgules.
|
||||
pub fn sign_track_get_list(ids_csv: &str, timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(b"trackgetList");
|
||||
hasher.update(b"tracks_id");
|
||||
hasher.update(ids_csv.as_bytes());
|
||||
hasher.update(timestamp.as_bytes());
|
||||
hasher.update(secret);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
|
||||
|
||||
@@ -3,36 +3,40 @@ use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use indexmap::IndexMap;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Timeout par requête HTTP — le bundle fait ~7 MB et le CDN Qobuz peut être lent.
|
||||
const BUNDLE_FETCH_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Tentatives supplémentaires après un échec d'extraction.
|
||||
const BUNDLE_EXTRACTION_RETRIES: usize = 2;
|
||||
|
||||
pub struct Spoofer {
|
||||
bundle: String,
|
||||
/// Version du bundle extrait, ex. `"8.1.0-b019"`.
|
||||
bundle_version: String,
|
||||
seed_timezone_regex: Regex,
|
||||
info_extras_regex_template: String,
|
||||
app_id_regex: Regex,
|
||||
}
|
||||
|
||||
impl Spoofer {
|
||||
/// Crée un nouveau Spoofer et télécharge le bundle.js
|
||||
pub async fn new() -> Result<Self> {
|
||||
// Expressions régulières (équivalent Python)
|
||||
let seed_timezone_regex = Regex::new(
|
||||
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
|
||||
)?;
|
||||
/// Version du bundle Qobuz actuellement chargé.
|
||||
pub fn bundle_version(&self) -> &str {
|
||||
&self.bundle_version
|
||||
}
|
||||
|
||||
let info_extras_regex_template =
|
||||
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
|
||||
.to_string();
|
||||
|
||||
let app_id_regex = Regex::new(
|
||||
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
|
||||
)?;
|
||||
|
||||
// Créer un client HTTP
|
||||
/// Récupère uniquement la version du bundle courant sans télécharger les 7 MB.
|
||||
///
|
||||
/// Utile pour savoir si le bundle a changé avant de déclencher une extraction
|
||||
/// complète. Ne télécharge que la page de login (~5 KB).
|
||||
pub async fn fetch_current_bundle_version() -> Result<String> {
|
||||
let client = Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
|
||||
.timeout(BUNDLE_FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
|
||||
println!("Récupération de la page de login...");
|
||||
let login_page = client
|
||||
.get("https://play.qobuz.com/login")
|
||||
.send()
|
||||
@@ -40,31 +44,80 @@ impl Spoofer {
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
// Extraire l'URL du bundle
|
||||
let bundle_url_regex = Regex::new(
|
||||
r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
|
||||
let re = Regex::new(
|
||||
r#"<script src="/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js"></script>"#,
|
||||
)?;
|
||||
let bundle_url = bundle_url_regex
|
||||
.captures(&login_page)
|
||||
re.captures(&login_page)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))?
|
||||
.as_str();
|
||||
|
||||
println!("Téléchargement du bundle depuis: {}", bundle_url);
|
||||
let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url);
|
||||
let bundle = client.get(&bundle_full_url).send().await?.text().await?;
|
||||
|
||||
println!("Bundle téléchargé ({} bytes)", bundle.len());
|
||||
|
||||
Ok(Self {
|
||||
bundle,
|
||||
seed_timezone_regex,
|
||||
info_extras_regex_template,
|
||||
app_id_regex,
|
||||
})
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Version bundle introuvable dans la page de login"))
|
||||
}
|
||||
|
||||
/// Extrait l'App ID depuis le bundle
|
||||
/// Télécharge et parse le bundle Qobuz. Retry jusqu'à `BUNDLE_EXTRACTION_RETRIES`
|
||||
/// fois en cas d'échec réseau ou d'extraction.
|
||||
pub async fn new() -> Result<Self> {
|
||||
let seed_timezone_regex = Regex::new(
|
||||
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
|
||||
)?;
|
||||
let info_extras_regex_template =
|
||||
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
|
||||
.to_string();
|
||||
let app_id_regex = Regex::new(
|
||||
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
|
||||
)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
|
||||
.timeout(BUNDLE_FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=(BUNDLE_EXTRACTION_RETRIES + 1) {
|
||||
match Self::fetch_bundle(&client).await {
|
||||
Ok((bundle, bundle_version)) => {
|
||||
info!("[Spoofer] Bundle {} téléchargé ({} bytes)", bundle_version, bundle.len());
|
||||
return Ok(Self {
|
||||
bundle,
|
||||
bundle_version,
|
||||
seed_timezone_regex,
|
||||
info_extras_regex_template,
|
||||
app_id_regex,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[Spoofer] Tentative {}/{} échouée : {}", attempt, BUNDLE_EXTRACTION_RETRIES + 1, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Échec téléchargement bundle")))
|
||||
}
|
||||
|
||||
async fn fetch_bundle(client: &Client) -> Result<(String, String)> {
|
||||
let login_page = client
|
||||
.get("https://play.qobuz.com/login")
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let bundle_url_regex = Regex::new(
|
||||
r#"<script src="(/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js)"></script>"#,
|
||||
)?;
|
||||
let caps = bundle_url_regex
|
||||
.captures(&login_page)
|
||||
.ok_or_else(|| anyhow::anyhow!("URL bundle introuvable dans la page de login"))?;
|
||||
let bundle_path = caps.get(1).unwrap().as_str();
|
||||
let bundle_version = caps.get(2).unwrap().as_str().to_string();
|
||||
|
||||
let bundle_url = format!("https://play.qobuz.com{}", bundle_path);
|
||||
debug!("[Spoofer] Téléchargement bundle depuis {}", bundle_url);
|
||||
|
||||
let bundle = client.get(&bundle_url).send().await?.text().await?;
|
||||
Ok((bundle, bundle_version))
|
||||
}
|
||||
|
||||
/// Extrait l'App ID depuis le bundle.
|
||||
pub fn get_app_id(&self) -> Result<String> {
|
||||
let captures = self
|
||||
.app_id_regex
|
||||
@@ -78,10 +131,7 @@ impl Spoofer {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères)
|
||||
///
|
||||
/// Ce secret est utilisé directement par Qobuz (nouvelle méthode)
|
||||
/// au lieu d'être XORé avec l'app_id
|
||||
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères).
|
||||
pub fn get_app_secret(&self) -> Result<String> {
|
||||
let captures = self
|
||||
.app_id_regex
|
||||
@@ -95,37 +145,25 @@ impl Spoofer {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Extrait les secrets depuis le bundle
|
||||
/// Extrait les secrets timezone depuis le bundle.
|
||||
pub fn get_secrets(&self) -> Result<IndexMap<String, String>> {
|
||||
// Étape 1: Extraire tous les seed/timezone pairs
|
||||
let mut secrets: IndexMap<String, Vec<String>> = IndexMap::new();
|
||||
|
||||
for captures in self.seed_timezone_regex.captures_iter(&self.bundle) {
|
||||
let seed = captures
|
||||
.name("seed")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))?
|
||||
.as_str();
|
||||
let timezone = captures
|
||||
.name("timezone")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
|
||||
.as_str();
|
||||
|
||||
let seed = captures.name("seed").unwrap().as_str();
|
||||
let timezone = captures.name("timezone").unwrap().as_str();
|
||||
secrets
|
||||
.entry(timezone.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.or_default()
|
||||
.push(seed.to_string());
|
||||
}
|
||||
|
||||
println!("Timezones trouvées: {:?}", secrets.keys());
|
||||
debug!("[Spoofer] Timezones trouvées : {:?}", secrets.keys().collect::<Vec<_>>());
|
||||
|
||||
// Étape 2: Réordonner - on met la deuxième timezone en premier
|
||||
// (comme le fait le code Python avec move_to_end)
|
||||
if secrets.len() >= 2 {
|
||||
let keys: Vec<String> = secrets.keys().cloned().collect();
|
||||
let second_key = keys[1].clone();
|
||||
let second_value = secrets.get(&second_key).unwrap().clone();
|
||||
|
||||
// Retirer et réinsérer pour le mettre en premier
|
||||
secrets.shift_remove(&second_key);
|
||||
let mut new_secrets = IndexMap::new();
|
||||
new_secrets.insert(second_key, second_value);
|
||||
@@ -135,7 +173,6 @@ impl Spoofer {
|
||||
secrets = new_secrets;
|
||||
}
|
||||
|
||||
// Étape 3: Construire la regex pour info/extras
|
||||
let timezones_capitalized: Vec<String> = secrets
|
||||
.keys()
|
||||
.map(|tz| {
|
||||
@@ -150,24 +187,12 @@ impl Spoofer {
|
||||
let info_extras_regex_str = self
|
||||
.info_extras_regex_template
|
||||
.replace("{timezones}", &timezones_capitalized.join("|"));
|
||||
|
||||
let info_extras_regex = Regex::new(&info_extras_regex_str)?;
|
||||
|
||||
// Étape 4: Extraire info et extras pour chaque timezone
|
||||
for captures in info_extras_regex.captures_iter(&self.bundle) {
|
||||
let timezone_cap = captures
|
||||
.name("timezone")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
|
||||
.as_str();
|
||||
let info = captures
|
||||
.name("info")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))?
|
||||
.as_str();
|
||||
let extras = captures
|
||||
.name("extras")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))?
|
||||
.as_str();
|
||||
|
||||
let timezone_cap = captures.name("timezone").unwrap().as_str();
|
||||
let info = captures.name("info").unwrap().as_str();
|
||||
let extras = captures.name("extras").unwrap().as_str();
|
||||
let timezone_lower = timezone_cap.to_lowercase();
|
||||
if let Some(vec) = secrets.get_mut(&timezone_lower) {
|
||||
vec.push(info.to_string());
|
||||
@@ -175,31 +200,19 @@ impl Spoofer {
|
||||
}
|
||||
}
|
||||
|
||||
// Étape 5: Décoder les secrets en base64
|
||||
let mut decoded_secrets = IndexMap::new();
|
||||
for (timezone, parts) in secrets {
|
||||
let concatenated = parts.join("");
|
||||
|
||||
// Retirer les 44 derniers caractères (comme Python [:-44])
|
||||
if concatenated.len() > 44 {
|
||||
let trimmed = &concatenated[..concatenated.len() - 44];
|
||||
|
||||
// Décoder en base64
|
||||
match STANDARD.decode(trimmed) {
|
||||
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
|
||||
Ok(decoded_str) => {
|
||||
decoded_secrets.insert(timezone, decoded_str);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e);
|
||||
}
|
||||
Err(e) => warn!("[Spoofer] UTF-8 invalide pour timezone {}: {}", timezone, e),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Erreur de décodage base64 pour timezone {}: {}",
|
||||
timezone, e
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("[Spoofer] Base64 invalide pour timezone {}: {}", timezone, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée
|
||||
@@ -86,7 +87,11 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les tracks favorites de l'utilisateur
|
||||
/// Récupère les tracks favorites de l'utilisateur.
|
||||
///
|
||||
/// Si le secret s4 est disponible, les données de base retournées par
|
||||
/// `/favorite/getUserFavorites` sont enrichies via `track/getList` pour
|
||||
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth).
|
||||
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite tracks for user {}", user_id);
|
||||
@@ -99,16 +104,32 @@ impl QobuzApi {
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
Ok(tracks
|
||||
let base_tracks: Vec<Track> = match response.tracks {
|
||||
Some(tracks) => tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| QobuzApi::parse_track(t, None))
|
||||
.filter(|t| t.streamable)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
.collect(),
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
if base_tracks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Enrichissement via track/getList pour métadonnées complètes
|
||||
let ordered_ids: Vec<String> = base_tracks.iter().map(|t| t.id.clone()).collect();
|
||||
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||
let mut track_map: HashMap<String, Track> =
|
||||
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||
let enriched: Vec<Track> = ordered_ids
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!("Fetched {} favorite tracks via track/getList", enriched.len());
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Récupère les playlists de l'utilisateur
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn create_router(state: QobuzState) -> Router {
|
||||
// Tracks
|
||||
.route("/tracks/:id", axum::routing::get(get_track))
|
||||
.route("/tracks/:id/stream", axum::routing::get(get_stream_url))
|
||||
.route("/tracks/:id/flac", axum::routing::get(get_flac_stream))
|
||||
// Artists
|
||||
.route("/artists/:id/albums", axum::routing::get(get_artist_albums))
|
||||
.route(
|
||||
@@ -154,6 +155,35 @@ async fn get_stream_url(
|
||||
Ok(Json(serde_json::json!({ "url": url })))
|
||||
}
|
||||
|
||||
/// `GET /tracks/:id/flac` — flux FLAC déchiffré en streaming progressif via CMAF.
|
||||
///
|
||||
/// Retourne les bytes FLAC directement dans le corps de la réponse HTTP, au fur
|
||||
/// et à mesure que les segments CMAF sont téléchargés et déchiffrés. Le client
|
||||
/// (typiquement `pmocache::add_from_url`) peut commencer à consommer le contenu
|
||||
/// avant la fin du téléchargement, préservant le progressive caching.
|
||||
///
|
||||
/// `Content-Length` est une estimation calculée depuis la table des segments du
|
||||
/// segment init — la valeur réelle peut différer de quelques octets.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn get_flac_stream(
|
||||
State(state): State<QobuzState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
use axum::http::header;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let (reader, estimated_size) = state.client.open_cmaf_stream(&id).await?;
|
||||
let stream = ReaderStream::new(reader);
|
||||
let body = axum::body::Body::from_stream(stream);
|
||||
|
||||
axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "audio/flac")
|
||||
.header(header::CONTENT_LENGTH, estimated_size)
|
||||
.body(body)
|
||||
.map_err(|e| AppError(QobuzError::Other(e.to_string())))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn get_artist_albums(
|
||||
State(state): State<QobuzState>,
|
||||
|
||||
@@ -236,14 +236,32 @@ impl QobuzClient {
|
||||
/// - Quand aucun appid/secret n'est configuré
|
||||
/// - Quand les credentials configurés sont invalides/expirés
|
||||
async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> {
|
||||
// Vérification bon marché : si la version du bundle n'a pas changé,
|
||||
// re-télécharger les 7 MB ne donnera pas de meilleurs secrets.
|
||||
// On court-circuite l'extraction et on passe directement au DEFAULT_APP_ID.
|
||||
if let Ok(Some(cached_version)) = config.get_qobuz_bundle_version() {
|
||||
match crate::api::Spoofer::fetch_current_bundle_version().await {
|
||||
Ok(current) if current == cached_version => {
|
||||
info!(
|
||||
"[Spoofer] Bundle inchangé ({}) — secret invalide pour une autre raison, skip extraction",
|
||||
current
|
||||
);
|
||||
return QobuzApi::new(DEFAULT_APP_ID);
|
||||
}
|
||||
Ok(new_version) => {
|
||||
info!("[Spoofer] Bundle rotaté : {} → {}, re-extraction", cached_version, new_version);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("[Spoofer] Impossible de vérifier la version du bundle : {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((app_id, secret)) = Self::fetch_spoofer_credentials(config).await? {
|
||||
// Use raw secret from Spoofer (no XOR)
|
||||
return QobuzApi::with_raw_secret(app_id, &secret);
|
||||
}
|
||||
|
||||
info!(
|
||||
"✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"
|
||||
);
|
||||
info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret");
|
||||
QobuzApi::new(DEFAULT_APP_ID)
|
||||
}
|
||||
|
||||
@@ -288,18 +306,15 @@ impl QobuzClient {
|
||||
timezone
|
||||
);
|
||||
|
||||
// Save both appid and the working secret
|
||||
if let Err(e) = config.set_qobuz_appid(&app_id)
|
||||
{
|
||||
// Save appid, secret, and bundle version
|
||||
if let Err(e) = config.set_qobuz_appid(&app_id) {
|
||||
debug!("Could not save appid: {}", e);
|
||||
}
|
||||
if let Err(e) =
|
||||
config.set_qobuz_spoofer_secret(secret)
|
||||
{
|
||||
debug!(
|
||||
"Could not save spoofer secret: {}",
|
||||
e
|
||||
);
|
||||
if let Err(e) = config.set_qobuz_spoofer_secret(secret) {
|
||||
debug!("Could not save spoofer secret: {}", e);
|
||||
}
|
||||
if let Err(e) = config.set_qobuz_bundle_version(spoofer.bundle_version()) {
|
||||
debug!("Could not save bundle version: {}", e);
|
||||
}
|
||||
|
||||
return Ok(Some((
|
||||
@@ -625,6 +640,44 @@ impl QobuzClient {
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
/// Récupère les détails d'un batch de tracks via track/getList.
|
||||
///
|
||||
/// Les tracks retournées sont mises en cache individuellement.
|
||||
/// Voir `QobuzApi::get_tracks_batch` pour le détail du comportement.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
// Séparer les IDs déjà en cache de ceux à récupérer
|
||||
let mut cached: std::collections::HashMap<String, Track> =
|
||||
std::collections::HashMap::new();
|
||||
let mut missing_ids: Vec<&str> = Vec::new();
|
||||
|
||||
for &id in track_ids {
|
||||
if let Some(track) = self.cache.get_track(id).await {
|
||||
cached.insert(id.to_string(), track);
|
||||
} else {
|
||||
missing_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_ids.is_empty() {
|
||||
let fetched = self
|
||||
.call_with_auth_repair("get_tracks_batch", || {
|
||||
self.api.get_tracks_batch(&missing_ids)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for track in fetched {
|
||||
self.cache.put_track(track.id.clone(), track.clone()).await;
|
||||
cached.insert(track.id.clone(), track);
|
||||
}
|
||||
}
|
||||
|
||||
// Restituer dans l'ordre d'entrée
|
||||
Ok(track_ids
|
||||
.iter()
|
||||
.filter_map(|id| cached.remove(*id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère l'URL de streaming d'une track
|
||||
pub async fn get_stream_url(&self, track_id: &str) -> Result<String> {
|
||||
// Vérifier le cache d'abord
|
||||
@@ -973,6 +1026,152 @@ impl QobuzClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ CMAF (streaming moderne) ============
|
||||
|
||||
/// Prépare le streaming CMAF pour un track : dérive les clés et fetche
|
||||
/// le segment d'initialisation. Retourne une `CmafStreamInfo` prête à l'emploi.
|
||||
///
|
||||
/// Pour télécharger la totalité du FLAC déchiffré d'un coup, utilisez
|
||||
/// plutôt `download_cmaf_full`. Pour streamer segment par segment, utilisez
|
||||
/// les données de `CmafStreamInfo` avec `cmaf::fetch_all_segments`.
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// * `QobuzError::NotFound` — track non disponible sur Qobuz
|
||||
/// * `QobuzError::Unauthorized` — session expirée (réparée automatiquement)
|
||||
/// * `QobuzError::Other` — erreur CMAF (clé invalide, segment init corrompu, etc.)
|
||||
pub async fn get_cmaf_stream_info(
|
||||
&self,
|
||||
track_id: &str,
|
||||
) -> Result<crate::models::CmafStreamInfo> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || {
|
||||
self.api.ensure_cmaf_session()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let setup = crate::cmaf::setup_streaming(
|
||||
url_template,
|
||||
&key_str,
|
||||
&infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::models::CmafStreamInfo {
|
||||
url_template: setup.url_template,
|
||||
n_segments: setup.n_segments,
|
||||
content_key: setup.content_key,
|
||||
flac_header: setup.flac_header,
|
||||
segment_table: setup.segment_table,
|
||||
format_id: setup.format_id,
|
||||
sampling_rate: setup.sampling_rate,
|
||||
bit_depth: setup.bit_depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ouvre un flux FLAC déchiffré en mode progressif pour un track CMAF.
|
||||
///
|
||||
/// Retourne un `(AsyncRead, taille_estimée)`. Le flux commence à produire
|
||||
/// du FLAC dès que le premier segment est déchiffré — le cache peut commencer
|
||||
/// à servir avant la fin du téléchargement (progressive caching préservé).
|
||||
///
|
||||
/// C'est la méthode à utiliser pour alimenter `pmocache::add_from_reader`.
|
||||
pub async fn open_cmaf_stream(
|
||||
&self,
|
||||
track_id: &str,
|
||||
) -> Result<(impl tokio::io::AsyncRead + Send + Unpin + 'static, u64)> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || self.api.ensure_cmaf_session())
|
||||
.await?;
|
||||
|
||||
crate::cmaf::open_flac_stream(
|
||||
url_template,
|
||||
key_str,
|
||||
infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Télécharge un track complet via CMAF et retourne les bytes FLAC déchiffrés.
|
||||
///
|
||||
/// Bloquant jusqu'à la fin du téléchargement. Pour les gros fichiers Hi-Res,
|
||||
/// préférer `open_cmaf_stream` qui préserve le progressive caching.
|
||||
pub async fn download_cmaf_full(&self, track_id: &str) -> Result<Vec<u8>> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || {
|
||||
self.api.ensure_cmaf_session()
|
||||
})
|
||||
.await?;
|
||||
|
||||
crate::cmaf::download_full(
|
||||
url_template,
|
||||
&key_str,
|
||||
&infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
154
pmoqobuz/src/cmaf/crypto.rs
Normal file
154
pmoqobuz/src/cmaf/crypto.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use aes::cipher::{BlockDecryptMut, KeyIvInit, StreamCipher};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hkdf::Hkdf;
|
||||
use md5::{Digest, Md5};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::error::CmafError;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
||||
|
||||
/// Seed publique extraite du bundle web Qobuz. Valeur IKM pour HKDF.
|
||||
pub const CMAF_SEED: &str = "abb21364945c0583309667d13ca3d93a";
|
||||
|
||||
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Dérive la clé de session 16 octets depuis le champ `infos` de session/start.
|
||||
///
|
||||
/// Format infos : `"salt_b64url.info_b64url"`
|
||||
/// `seed` est le CMAF_SEED hex-encodé utilisé comme IKM HKDF.
|
||||
pub fn derive_session_key(seed: &str, infos: &str) -> Result<[u8; 16], CmafError> {
|
||||
let parts: Vec<&str> = infos.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(CmafError::InvalidInfos(
|
||||
"session infos doit avoir au moins 2 parties séparées par des points".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let salt = URL_SAFE_NO_PAD.decode(parts[0])?;
|
||||
let info = URL_SAFE_NO_PAD.decode(parts[1])?;
|
||||
let ikm = hex_decode(seed);
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||
let mut okm = [0u8; 16];
|
||||
hk.expand(&info, &mut okm).map_err(|_| CmafError::HkdfExpand)?;
|
||||
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
/// Déroule la clé de contenu par track avec la clé de session.
|
||||
///
|
||||
/// Format key_str : `"qbz-1.wrapped_key_b64url.iv_b64url"`
|
||||
pub fn unwrap_content_key(session_key: &[u8; 16], key_str: &str) -> Result<[u8; 16], CmafError> {
|
||||
let parts: Vec<&str> = key_str.split('.').collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(CmafError::InvalidKey(
|
||||
"key string doit avoir au moins 3 parties séparées par des points".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let wrapped = URL_SAFE_NO_PAD.decode(parts[1])?;
|
||||
let iv = URL_SAFE_NO_PAD.decode(parts[2])?;
|
||||
|
||||
if iv.len() != 16 {
|
||||
return Err(CmafError::InvalidKey(format!(
|
||||
"IV de dérobage doit faire 16 octets, reçu {}",
|
||||
iv.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut buf = wrapped.clone();
|
||||
let decrypted =
|
||||
Aes128CbcDec::new(session_key.into(), iv.as_slice().into())
|
||||
.decrypt_padded_mut::<aes::cipher::block_padding::Pkcs7>(&mut buf)
|
||||
.map_err(|e| CmafError::AesDecrypt(format!("AES-CBC unwrap échoué: {e}")))?;
|
||||
|
||||
if decrypted.len() != 16 {
|
||||
return Err(CmafError::InvalidKey(format!(
|
||||
"clé déroullée doit faire 16 octets, obtenu {}",
|
||||
decrypted.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(decrypted);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Déchiffre une frame FLAC en place avec AES-128-CTR.
|
||||
///
|
||||
/// `iv_8` = IV 8 octets du segment UUID box, complété à zéro jusqu'à 16 octets.
|
||||
pub fn decrypt_frame(content_key: &[u8; 16], iv_8: &[u8; 8], data: &mut [u8]) {
|
||||
let mut nonce = [0u8; 16];
|
||||
nonce[..8].copy_from_slice(iv_8);
|
||||
Aes128Ctr::new(content_key.into(), &nonce.into()).apply_keystream(data);
|
||||
}
|
||||
|
||||
/// Calcule la signature MD5 pour les appels API CMAF de Qobuz.
|
||||
///
|
||||
/// Concatène method + paires clé-valeur triées + timestamp + seed,
|
||||
/// puis retourne le digest MD5 hexadécimal minuscule.
|
||||
pub fn compute_request_sig(
|
||||
method: &str,
|
||||
args: &std::collections::BTreeMap<&str, String>,
|
||||
timestamp: &str,
|
||||
seed: &str,
|
||||
) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(method.as_bytes());
|
||||
for (k, v) in args {
|
||||
hasher.update(k.as_bytes());
|
||||
hasher.update(v.as_bytes());
|
||||
}
|
||||
hasher.update(timestamp.as_bytes());
|
||||
hasher.update(seed.as_bytes());
|
||||
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compute_request_sig_deterministe() {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("profile", "qbz-1".to_string());
|
||||
let sig1 = compute_request_sig("sessionstart", &args, "1775500000", CMAF_SEED);
|
||||
let sig2 = compute_request_sig("sessionstart", &args, "1775500000", CMAF_SEED);
|
||||
assert_eq!(sig1.len(), 32);
|
||||
assert_eq!(sig1, sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_frame_aller_retour() {
|
||||
let key = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||
let iv = [1u8, 2, 3, 4, 5, 6, 7, 8];
|
||||
let original = b"Hello FLAC frame data here!".to_vec();
|
||||
let mut data = original.clone();
|
||||
decrypt_frame(&key, &iv, &mut data);
|
||||
assert_ne!(data, original);
|
||||
// AES-CTR est son propre inverse
|
||||
decrypt_frame(&key, &iv, &mut data);
|
||||
assert_eq!(data, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_infos_invalide() {
|
||||
let result = derive_session_key(CMAF_SEED, "pas_de_point");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_content_key_format_invalide() {
|
||||
let key = [0u8; 16];
|
||||
let result = unwrap_content_key(&key, "seulement.deux");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
17
pmoqobuz/src/cmaf/error.rs
Normal file
17
pmoqobuz/src/cmaf/error.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CmafError {
|
||||
#[error("Invalid infos format: {0}")]
|
||||
InvalidInfos(String),
|
||||
#[error("Invalid key format: {0}")]
|
||||
InvalidKey(String),
|
||||
#[error("Base64 decode error: {0}")]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
#[error("HKDF expand error")]
|
||||
HkdfExpand,
|
||||
#[error("AES decrypt error: {0}")]
|
||||
AesDecrypt(String),
|
||||
#[error("CMAF parse error: {0}")]
|
||||
ParseError(String),
|
||||
}
|
||||
395
pmoqobuz/src/cmaf/mod.rs
Normal file
395
pmoqobuz/src/cmaf/mod.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
//! Pipeline CMAF (Common Media Application Format) pour Qobuz.
|
||||
//!
|
||||
//! Qobuz utilise CMAF avec chiffrement AES-CTR par frame sur CDN Akamai.
|
||||
//! C'est le pipeline de l'app Android v9.7+ qui remplace l'endpoint legacy
|
||||
//! `/track/getFileUrl`.
|
||||
//!
|
||||
//! # Pipeline
|
||||
//!
|
||||
//! 1. `/session/start` → `{ session_id, infos, expires_at }`
|
||||
//! 2. `/file/url` → `{ url_template, key (enveloppé), n_segments, ... }`
|
||||
//! 3. Session key = `HKDF(CMAF_SEED, infos)`
|
||||
//! 4. Content key = AES-CBC-unwrap(session_key, key)
|
||||
//! 5. Segment init (s=0) → header FLAC + table des segments
|
||||
//! 6. Pour chaque s=1..n_segments : fetch → parse crypto boxes → déchiffrement AES-CTR
|
||||
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod parser;
|
||||
|
||||
pub use crypto::{compute_request_sig, decrypt_frame, derive_session_key, unwrap_content_key, CMAF_SEED};
|
||||
pub use error::CmafError;
|
||||
pub use parser::{
|
||||
parse_init_segment, parse_segment_crypto, FrameEntry, InitInfo, SegmentCrypto,
|
||||
SegmentTableEntry,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::retry::{classify_reqwest, classify_status, retry_transient, FetchError, DEFAULT_MAX_ATTEMPTS};
|
||||
|
||||
/// Concurrence max pour le fetch de segments CMAF.
|
||||
/// 3 segments en vol est le compromis optimal — le CDN Akamai rate-limite
|
||||
/// au-delà de ~5 requêtes parallèles par IP sur des fenêtres de 1s.
|
||||
pub const CMAF_PREFETCH_CONCURRENCY: usize = 3;
|
||||
|
||||
/// Callback de progression pour les fonctions de téléchargement.
|
||||
pub type CmafProgressCallback = Arc<dyn Fn(CmafProgressUpdate) + Send + Sync>;
|
||||
|
||||
/// Un tick de progression. `segments_completed` est cumulatif (1..=n).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CmafProgressUpdate {
|
||||
pub segments_completed: u32,
|
||||
pub n_segments: u32,
|
||||
pub bytes_this_segment: u64,
|
||||
}
|
||||
|
||||
/// Info réunies depuis le segment init, suffisantes pour démarrer le streaming.
|
||||
pub struct CmafStreamingInfo {
|
||||
pub url_template: String,
|
||||
pub n_segments: u8,
|
||||
pub content_key: [u8; 16],
|
||||
pub flac_header: Vec<u8>,
|
||||
pub segment_table: Vec<SegmentTableEntry>,
|
||||
pub format_id: u32,
|
||||
pub sampling_rate: Option<u32>,
|
||||
pub bit_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// Construit un client reqwest dédié aux fetches CDN Akamai.
|
||||
fn build_cdn_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| QobuzError::Http(e))
|
||||
}
|
||||
|
||||
/// Fetch une URL CDN en bytes avec retry sur les erreurs transitoires.
|
||||
/// Un 404/403 échoue immédiatement (terminal). 5xx et 429 → retry avec backoff.
|
||||
async fn fetch_bytes_with_retry(
|
||||
http: &reqwest::Client,
|
||||
url: &str,
|
||||
log_tag: &str,
|
||||
) -> std::result::Result<Vec<u8>, FetchError> {
|
||||
retry_transient(
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
log_tag,
|
||||
FetchError::is_transient,
|
||||
|_attempt| async move {
|
||||
let response = http
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_reqwest(&e, "fetch"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(classify_status(status, "fetch"));
|
||||
}
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| classify_reqwest(&e, "lecture"))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch les segments 1..=n_segments avec contrôle de concurrence.
|
||||
/// Déclenche le callback de progression une fois par segment complété.
|
||||
/// Les résultats sont retournés triés par index de segment.
|
||||
async fn fetch_all_segments(
|
||||
http: &reqwest::Client,
|
||||
url_template: &str,
|
||||
n_segments: u8,
|
||||
log_tag: &str,
|
||||
on_progress: Option<CmafProgressCallback>,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
let semaphore = Arc::new(Semaphore::new(CMAF_PREFETCH_CONCURRENCY));
|
||||
let completed_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let mut handles = Vec::with_capacity(n_segments as usize);
|
||||
|
||||
for seg_idx in 1u8..=n_segments {
|
||||
let sem = semaphore.clone();
|
||||
let http = http.clone();
|
||||
let seg_url = url_template.replace("$SEGMENT$", &seg_idx.to_string());
|
||||
let log_tag = log_tag.to_string();
|
||||
let progress = on_progress.clone();
|
||||
let counter = completed_count.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let permit = sem.acquire_owned().await
|
||||
.map_err(|e| format!("semaphore: {}", e))?;
|
||||
|
||||
let seg_data = fetch_bytes_with_retry(&http, &seg_url, &format!("{} seg {}", log_tag, seg_idx))
|
||||
.await
|
||||
.map_err(|e| format!("[{}] seg {} fetch: {}", log_tag, seg_idx, e))?;
|
||||
|
||||
let bytes_this_segment = seg_data.len() as u64;
|
||||
if let Some(cb) = progress {
|
||||
let done = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
cb(CmafProgressUpdate {
|
||||
segments_completed: done,
|
||||
n_segments: n_segments as u32,
|
||||
bytes_this_segment,
|
||||
});
|
||||
}
|
||||
|
||||
// Pause avant de libérer le slot pour respecter les limites CDN
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
drop(permit);
|
||||
|
||||
Ok::<(u8, Vec<u8>), String>((seg_idx, seg_data))
|
||||
}));
|
||||
}
|
||||
|
||||
let mut segments: Vec<(u8, Vec<u8>)> = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
let (idx, data) = handle
|
||||
.await
|
||||
.map_err(|e| QobuzError::Other(format!("[{}] panic task: {}", log_tag, e)))?
|
||||
.map_err(|e| QobuzError::Other(format!("[{}] téléchargement échoué: {}", log_tag, e)))?;
|
||||
segments.push((idx, data));
|
||||
}
|
||||
segments.sort_by_key(|(idx, _)| *idx);
|
||||
Ok(segments.into_iter().map(|(_, data)| data).collect())
|
||||
}
|
||||
|
||||
/// Déchiffre un segment CMAF et ajoute les frames FLAC dans `output`.
|
||||
///
|
||||
/// Optimisation : extend + decrypt in-place, zéro allocation par frame.
|
||||
fn decrypt_one_segment(
|
||||
seg_data: &[u8],
|
||||
content_key: &[u8; 16],
|
||||
output: &mut Vec<u8>,
|
||||
seg_idx: usize,
|
||||
) -> Result<()> {
|
||||
let crypto = parse_segment_crypto(seg_data)
|
||||
.map_err(|e| QobuzError::Other(format!("CMAF seg {} parse: {}", seg_idx, e)))?;
|
||||
|
||||
let mut data_pos = crypto.data_offset;
|
||||
for entry in &crypto.entries {
|
||||
let frame_end = data_pos + entry.size as usize;
|
||||
if frame_end > seg_data.len() {
|
||||
return Err(QobuzError::Other(format!("CMAF seg {} débordement frame", seg_idx)));
|
||||
}
|
||||
let start = output.len();
|
||||
output.extend_from_slice(&seg_data[data_pos..frame_end]);
|
||||
if entry.flags != 0 {
|
||||
decrypt_frame(content_key, &entry.iv, &mut output[start..]);
|
||||
}
|
||||
data_pos = frame_end;
|
||||
}
|
||||
if data_pos < crypto.mdat_end && crypto.mdat_end <= seg_data.len() {
|
||||
output.extend_from_slice(&seg_data[data_pos..crypto.mdat_end]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Déchiffre une séquence de segments CMAF chiffrés et écrit les frames FLAC dans `output`.
|
||||
pub fn decrypt_segments_into(
|
||||
segments: &[Vec<u8>],
|
||||
content_key: &[u8; 16],
|
||||
output: &mut Vec<u8>,
|
||||
) -> Result<()> {
|
||||
for (seg_idx, seg_data) in segments.iter().enumerate() {
|
||||
decrypt_one_segment(seg_data, content_key, output, seg_idx + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prépare le streaming CMAF : dérive les clés, fetche le segment init.
|
||||
/// Ne télécharge PAS les segments audio — l'appelant les streame en arrière-plan.
|
||||
pub async fn setup_streaming(
|
||||
url_template: String,
|
||||
key_str: &str,
|
||||
infos: &str,
|
||||
n_segments: u8,
|
||||
format_id: u32,
|
||||
sampling_rate: Option<u32>,
|
||||
bit_depth: Option<u32>,
|
||||
) -> Result<CmafStreamingInfo> {
|
||||
let session_key = derive_session_key(CMAF_SEED, infos)
|
||||
.map_err(|e| QobuzError::Other(format!("dérivation clé session: {}", e)))?;
|
||||
let content_key = unwrap_content_key(&session_key, key_str)
|
||||
.map_err(|e| QobuzError::Other(format!("dérobage clé contenu: {}", e)))?;
|
||||
|
||||
let http = build_cdn_client()?;
|
||||
let init_url = url_template.replace("$SEGMENT$", "0");
|
||||
|
||||
info!("[CMAF] Fetch segment init: {}", &init_url[..init_url.len().min(60)]);
|
||||
|
||||
let init_data = fetch_bytes_with_retry(&http, &init_url, "CMAF init")
|
||||
.await
|
||||
.map_err(|e| QobuzError::Other(format!("fetch segment init: {}", e)))?;
|
||||
|
||||
let init_info = parse_init_segment(&init_data)
|
||||
.map_err(|e| QobuzError::Other(format!("parse segment init: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"[CMAF] Init: header FLAC {}B, {} segments dans la table, n_segments API={}",
|
||||
init_info.flac_header.len(),
|
||||
init_info.segment_table.len(),
|
||||
n_segments,
|
||||
);
|
||||
if init_info.segment_table.len() != n_segments as usize {
|
||||
warn!(
|
||||
"[CMAF] ÉCART: table={} entrées mais API dit n_segments={}",
|
||||
init_info.segment_table.len(),
|
||||
n_segments,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(CmafStreamingInfo {
|
||||
url_template,
|
||||
n_segments,
|
||||
content_key,
|
||||
flac_header: init_info.flac_header,
|
||||
segment_table: init_info.segment_table,
|
||||
format_id,
|
||||
sampling_rate,
|
||||
bit_depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ouvre un flux FLAC déchiffré en mode progressif.
|
||||
///
|
||||
/// Retourne un `AsyncRead` qui produit les bytes FLAC au fur et à mesure que les
|
||||
/// segments CMAF sont téléchargés et déchiffrés. Le lecteur peut commencer à
|
||||
/// consommer le flux (et le cache à le servir) avant que tous les segments
|
||||
/// soient téléchargés — le progressive caching est préservé.
|
||||
///
|
||||
/// Le deuxième élément est la taille totale estimée en bytes, calculée depuis
|
||||
/// le header FLAC et la table des segments du segment init.
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// Retourne une erreur si la dérivation des clés ou le fetch du segment init
|
||||
/// échoue. Les erreurs de segments ultérieurs provoquent la fermeture du pipe
|
||||
/// (le lecteur verra un EOF prématuré).
|
||||
pub async fn open_flac_stream(
|
||||
url_template: String,
|
||||
key_str: String,
|
||||
infos: String,
|
||||
n_segments: u8,
|
||||
format_id: u32,
|
||||
sampling_rate: Option<u32>,
|
||||
bit_depth: Option<u32>,
|
||||
) -> Result<(impl AsyncRead + Send + Unpin + 'static, u64)> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let setup = setup_streaming(
|
||||
url_template, &key_str, &infos, n_segments,
|
||||
format_id, sampling_rate, bit_depth,
|
||||
).await?;
|
||||
|
||||
let estimated_size = (setup.flac_header.len()
|
||||
+ setup.segment_table.iter().map(|s| s.byte_len as usize).sum::<usize>()) as u64;
|
||||
|
||||
// Pipe 256 KB : assez grand pour absorber un segment FLAC Hi-Res typique
|
||||
// sans bloquer le producteur, assez petit pour ne pas sur-allouer.
|
||||
let (mut writer, reader) = tokio::io::duplex(256 * 1024);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = writer.write_all(&setup.flac_header).await {
|
||||
warn!("[CMAF-STREAM] écriture header FLAC: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let http = match build_cdn_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => { warn!("[CMAF-STREAM] client HTTP: {}", e); return; }
|
||||
};
|
||||
|
||||
// Lancer tous les fetches avec concurrence bornée par le sémaphore.
|
||||
// Les handles sont stockés dans l'ordre — on les consomme en ordre.
|
||||
let sem = Arc::new(Semaphore::new(CMAF_PREFETCH_CONCURRENCY));
|
||||
let mut handles = Vec::with_capacity(setup.n_segments as usize);
|
||||
|
||||
for seg_idx in 1u8..=setup.n_segments {
|
||||
let sem = sem.clone();
|
||||
let http = http.clone();
|
||||
let url = setup.url_template.replace("$SEGMENT$", &seg_idx.to_string());
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let permit = sem.acquire_owned().await
|
||||
.map_err(|e| format!("sémaphore: {}", e))?;
|
||||
let result = fetch_bytes_with_retry(&http, &url, &format!("CMAF-STREAM seg {}", seg_idx))
|
||||
.await
|
||||
.map_err(|e| format!("seg {} fetch: {}", seg_idx, e));
|
||||
// Cooldown CDN Akamai : retenir le slot 500ms avant de libérer,
|
||||
// identique à fetch_all_segments, pour éviter le rate-limiting.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
drop(permit);
|
||||
result
|
||||
}));
|
||||
}
|
||||
|
||||
// Consommer dans l'ordre : segment 1 d'abord, puis 2, etc.
|
||||
// Les segments prêts en avance attendent dans leur JoinHandle.
|
||||
let mut buf = Vec::new();
|
||||
for (i, handle) in handles.into_iter().enumerate() {
|
||||
let seg_idx = i + 1;
|
||||
let seg_data = match handle.await {
|
||||
Ok(Ok(data)) => data,
|
||||
Ok(Err(e)) => { warn!("[CMAF-STREAM] seg {}: {}", seg_idx, e); return; }
|
||||
Err(e) => { warn!("[CMAF-STREAM] seg {} panique: {}", seg_idx, e); return; }
|
||||
};
|
||||
|
||||
buf.clear();
|
||||
if let Err(e) = decrypt_one_segment(&seg_data, &setup.content_key, &mut buf, seg_idx) {
|
||||
warn!("[CMAF-STREAM] seg {}: déchiffrement: {}", seg_idx, e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writer.write_all(&buf).await {
|
||||
// Le lecteur a fermé le pipe (ex: playback stoppé) — arrêt silencieux.
|
||||
debug!("[CMAF-STREAM] seg {}: pipe fermé ({})", seg_idx, e);
|
||||
return;
|
||||
}
|
||||
debug!("[CMAF-STREAM] seg {}/{} → {} B", seg_idx, setup.n_segments, buf.len());
|
||||
}
|
||||
|
||||
info!("[CMAF-STREAM] complet : {} segments, {:.2} MB estimés",
|
||||
setup.n_segments,
|
||||
estimated_size as f64 / (1024.0 * 1024.0));
|
||||
// writer dropped ici → EOF propre sur le reader
|
||||
});
|
||||
|
||||
Ok((reader, estimated_size))
|
||||
}
|
||||
|
||||
/// Télécharge un track CMAF complet et retourne les bytes FLAC déchiffrés.
|
||||
pub async fn download_full(
|
||||
url_template: String,
|
||||
key_str: &str,
|
||||
infos: &str,
|
||||
n_segments: u8,
|
||||
format_id: u32,
|
||||
sampling_rate: Option<u32>,
|
||||
bit_depth: Option<u32>,
|
||||
on_progress: Option<CmafProgressCallback>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let setup = setup_streaming(url_template, key_str, infos, n_segments, format_id, sampling_rate, bit_depth).await?;
|
||||
let http = build_cdn_client()?;
|
||||
|
||||
let total_size: usize = setup.flac_header.len()
|
||||
+ setup.segment_table.iter().map(|s| s.byte_len as usize).sum::<usize>();
|
||||
|
||||
let segments = fetch_all_segments(&http, &setup.url_template, setup.n_segments, "CMAF-FULL", on_progress).await?;
|
||||
|
||||
let mut output = Vec::with_capacity(total_size);
|
||||
output.extend_from_slice(&setup.flac_header);
|
||||
decrypt_segments_into(&segments, &setup.content_key, &mut output)?;
|
||||
|
||||
debug!(
|
||||
"[CMAF-FULL] Complet: {:.2} MB FLAC, attendu {:.2} MB",
|
||||
output.len() as f64 / (1024.0 * 1024.0),
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
253
pmoqobuz/src/cmaf/parser.rs
Normal file
253
pmoqobuz/src/cmaf/parser.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use super::error::CmafError;
|
||||
|
||||
const QBZ_INIT_UUID: [u8; 16] = [
|
||||
0xc7, 0xc7, 0x5d, 0xf0, 0xfd, 0xd9, 0x51, 0xe9,
|
||||
0x8f, 0xc2, 0x29, 0x71, 0xe4, 0xac, 0xf8, 0xd2,
|
||||
];
|
||||
const QBZ_SEGMENT_UUID: [u8; 16] = [
|
||||
0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75,
|
||||
0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2,
|
||||
];
|
||||
const FLAC_MAGIC: &[u8; 4] = b"fLaC";
|
||||
|
||||
/// Taille en octets et nombre d'échantillons d'un segment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SegmentTableEntry {
|
||||
/// Taille des données FLAC déchiffrées de ce segment.
|
||||
pub byte_len: u32,
|
||||
/// Nombre d'échantillons audio dans ce segment.
|
||||
pub sample_count: u32,
|
||||
}
|
||||
|
||||
/// Header FLAC et table des segments extraits du segment d'initialisation.
|
||||
pub struct InitInfo {
|
||||
pub flac_header: Vec<u8>,
|
||||
/// Tailles par segment (indices 0..n_segments-1 correspondent aux segments 1..n_segments).
|
||||
pub segment_table: Vec<SegmentTableEntry>,
|
||||
}
|
||||
|
||||
/// Une entrée de frame dans le segment UUID box.
|
||||
pub struct FrameEntry {
|
||||
pub size: u32,
|
||||
pub flags: u16,
|
||||
pub iv: [u8; 8],
|
||||
}
|
||||
|
||||
/// Informations crypto parsées depuis le UUID box d'un segment audio.
|
||||
pub struct SegmentCrypto {
|
||||
/// Offset vers le début des données audio (payload mdat).
|
||||
pub data_offset: usize,
|
||||
/// Fin du contenu de la mdat box.
|
||||
pub mdat_end: usize,
|
||||
pub entries: Vec<FrameEntry>,
|
||||
}
|
||||
|
||||
/// Parcourt les boxes ISO BMFF et trouve le premier UUID box correspondant à `target_uuid`.
|
||||
/// Retourne `(payload_start, box_end)` où payload_start est après les 16 octets UUID.
|
||||
fn find_uuid_box(data: &[u8], target_uuid: &[u8; 16]) -> Option<(usize, usize)> {
|
||||
let mut pos = 0;
|
||||
while pos + 8 <= data.len() {
|
||||
let size = read_box_size(data, pos);
|
||||
if size < 8 || pos + size > data.len() {
|
||||
break;
|
||||
}
|
||||
if &data[pos + 4..pos + 8] == b"uuid" && pos + 24 <= data.len() {
|
||||
if &data[pos + 8..pos + 24] == target_uuid.as_ref() {
|
||||
return Some((pos + 24, pos + size));
|
||||
}
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse le segment d'initialisation (segment 0) pour extraire le header FLAC et la table des segments.
|
||||
pub fn parse_init_segment(data: &[u8]) -> Result<InitInfo, CmafError> {
|
||||
let (payload_start, box_end) = find_uuid_box(data, &QBZ_INIT_UUID)
|
||||
.ok_or_else(|| CmafError::ParseError("segment init: QBZ_INIT_UUID box non trouvé".into()))?;
|
||||
|
||||
let payload = &data[payload_start..box_end];
|
||||
parse_init_uuid_payload(payload)
|
||||
}
|
||||
|
||||
/// Parse un segment audio pour extraire les informations crypto par frame.
|
||||
pub fn parse_segment_crypto(data: &[u8]) -> Result<SegmentCrypto, CmafError> {
|
||||
let mut uuid_box_start: Option<usize> = None;
|
||||
let mut mdat_end = data.len();
|
||||
|
||||
let mut pos = 0;
|
||||
while pos + 8 <= data.len() {
|
||||
let size = read_box_size(data, pos);
|
||||
if size < 8 || pos + size > data.len() {
|
||||
break;
|
||||
}
|
||||
let box_type = &data[pos + 4..pos + 8];
|
||||
if box_type == b"uuid" && pos + 24 <= data.len() {
|
||||
if &data[pos + 8..pos + 24] == QBZ_SEGMENT_UUID.as_ref() {
|
||||
uuid_box_start = Some(pos);
|
||||
}
|
||||
} else if box_type == b"mdat" {
|
||||
mdat_end = pos + size;
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
|
||||
let box_start = uuid_box_start
|
||||
.ok_or_else(|| CmafError::ParseError("segment audio: QBZ_SEGMENT_UUID box non trouvé".into()))?;
|
||||
|
||||
parse_segment_uuid_payload(data, box_start, mdat_end)
|
||||
}
|
||||
|
||||
fn parse_init_uuid_payload(payload: &[u8]) -> Result<InitInfo, CmafError> {
|
||||
// Layout payload:
|
||||
// [4B padding/version]
|
||||
// [4B track_id]
|
||||
// [4B file_id]
|
||||
// [4B sample_rate]
|
||||
// [1B bits_per_sample]
|
||||
// [1B channels + 2B padding]
|
||||
// [6B total_samples_count]
|
||||
// [2B raw_data_len]
|
||||
// [raw_data_len bytes: contient le header FLAC]
|
||||
// [1B key_id_len]
|
||||
// [key_id_len bytes: key_id]
|
||||
// [2B segment_count]
|
||||
// Par segment: [4B byte_len][4B sample_count]
|
||||
|
||||
if payload.len() < 28 {
|
||||
return Err(CmafError::ParseError("payload init UUID trop court".into()));
|
||||
}
|
||||
|
||||
let mut a = 4; // version/padding
|
||||
a += 4; // track_id
|
||||
a += 4; // file_id
|
||||
a += 4; // sample_rate
|
||||
a += 1; // bits_per_sample
|
||||
a += 3; // channels + padding
|
||||
a += 6; // total_samples_count
|
||||
|
||||
if a + 2 > payload.len() {
|
||||
return Err(CmafError::ParseError("payload init UUID tronqué au raw_len".into()));
|
||||
}
|
||||
let raw_len = u16::from_be_bytes([payload[a], payload[a + 1]]) as usize;
|
||||
a += 2;
|
||||
|
||||
let raw_data = &payload[a..a + raw_len.min(payload.len() - a)];
|
||||
a += raw_len;
|
||||
|
||||
let flac_pos = raw_data
|
||||
.windows(4)
|
||||
.position(|w| w == FLAC_MAGIC)
|
||||
.ok_or_else(|| CmafError::ParseError("payload init UUID: magic fLaC non trouvé".into()))?;
|
||||
|
||||
// fLaC (4) + STREAMINFO block header (4) + STREAMINFO data (34) = 42 octets
|
||||
let header_len = 4 + 4 + 34;
|
||||
if flac_pos + header_len > raw_data.len() {
|
||||
return Err(CmafError::ParseError("payload init UUID: STREAMINFO tronqué".into()));
|
||||
}
|
||||
|
||||
let mut flac_header = raw_data[flac_pos..flac_pos + header_len].to_vec();
|
||||
// Marquer le dernier bloc de métadonnées
|
||||
flac_header[4] |= 0x80;
|
||||
|
||||
if a + 1 > payload.len() {
|
||||
return Ok(InitInfo { flac_header, segment_table: Vec::new() });
|
||||
}
|
||||
let key_id_len = payload[a] as usize;
|
||||
a += 1 + key_id_len;
|
||||
|
||||
let mut segment_table = Vec::new();
|
||||
if a + 2 <= payload.len() {
|
||||
let seg_count = u16::from_be_bytes([payload[a], payload[a + 1]]) as usize;
|
||||
a += 2;
|
||||
|
||||
for _ in 0..seg_count {
|
||||
if a + 8 > payload.len() {
|
||||
break;
|
||||
}
|
||||
let byte_len = u32::from_be_bytes([payload[a], payload[a + 1], payload[a + 2], payload[a + 3]]);
|
||||
a += 4;
|
||||
let sample_count = u32::from_be_bytes([payload[a], payload[a + 1], payload[a + 2], payload[a + 3]]);
|
||||
a += 4;
|
||||
segment_table.push(SegmentTableEntry { byte_len, sample_count });
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Init UUID: {} segments dans la table, header FLAC {} octets",
|
||||
segment_table.len(),
|
||||
flac_header.len()
|
||||
);
|
||||
|
||||
Ok(InitInfo { flac_header, segment_table })
|
||||
}
|
||||
|
||||
fn parse_segment_uuid_payload(
|
||||
data: &[u8],
|
||||
uuid_box_start: usize,
|
||||
mdat_end: usize,
|
||||
) -> Result<SegmentCrypto, CmafError> {
|
||||
// Layout après box header (8) + UUID (16) = offset 24 depuis uuid_box_start:
|
||||
// [4B version/padding]
|
||||
// [4B data_offset_raw] — offset depuis uuid_box_start vers les données audio
|
||||
// [1B iv_size]
|
||||
// [3B frame_count (24-bit BE)]
|
||||
// Par frame: [4B size][2B skip][2B flags][iv_size bytes IV]
|
||||
|
||||
let base = uuid_box_start + 24;
|
||||
if base + 12 > data.len() {
|
||||
return Err(CmafError::ParseError(
|
||||
"payload segment UUID trop court pour le header".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut a = base + 4; // skip 4-byte version/padding
|
||||
|
||||
let data_offset_raw = u32::from_be_bytes([data[a], data[a + 1], data[a + 2], data[a + 3]]);
|
||||
let data_offset = uuid_box_start + data_offset_raw as usize;
|
||||
a += 4;
|
||||
|
||||
let iv_size = data[a] as usize;
|
||||
a += 1;
|
||||
|
||||
let frame_count =
|
||||
((data[a] as usize) << 16) | ((data[a + 1] as usize) << 8) | (data[a + 2] as usize);
|
||||
a += 3;
|
||||
|
||||
let entry_size = 4 + 2 + 2 + iv_size;
|
||||
if a + frame_count * entry_size > data.len() {
|
||||
return Err(CmafError::ParseError(format!(
|
||||
"segment UUID: données insuffisantes pour {frame_count} entrées de {entry_size} octets"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(frame_count);
|
||||
for _ in 0..frame_count {
|
||||
let size = u32::from_be_bytes([data[a], data[a + 1], data[a + 2], data[a + 3]]);
|
||||
a += 4;
|
||||
a += 2; // 2 octets inconnus
|
||||
let flags = u16::from_be_bytes([data[a], data[a + 1]]);
|
||||
a += 2;
|
||||
|
||||
let mut iv = [0u8; 8];
|
||||
let copy_len = iv_size.min(8);
|
||||
iv[..copy_len].copy_from_slice(&data[a..a + copy_len]);
|
||||
a += iv_size;
|
||||
|
||||
entries.push(FrameEntry { size, flags, iv });
|
||||
}
|
||||
|
||||
Ok(SegmentCrypto { data_offset, mdat_end, entries })
|
||||
}
|
||||
|
||||
fn read_box_size(data: &[u8], pos: usize) -> usize {
|
||||
if pos + 8 > data.len() {
|
||||
return 0;
|
||||
}
|
||||
let s = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
|
||||
match s {
|
||||
0 => data.len() - pos,
|
||||
1..=7 => 0,
|
||||
s => s as usize,
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,13 @@ pub trait QobuzConfigExt {
|
||||
|
||||
/// Active ou désactive le rate limiting
|
||||
fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>;
|
||||
|
||||
/// Version du bundle Qobuz extrait en dernier (ex : `"8.1.0-b019"`).
|
||||
/// Permet de détecter une rotation de bundle sans télécharger les 7 MB.
|
||||
fn get_qobuz_bundle_version(&self) -> Result<Option<String>>;
|
||||
|
||||
/// Persiste la version du bundle après une extraction réussie.
|
||||
fn set_qobuz_bundle_version(&self, version: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
impl QobuzConfigExt for Config {
|
||||
@@ -483,4 +490,18 @@ impl QobuzConfigExt for Config {
|
||||
Value::Bool(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_qobuz_bundle_version(&self) -> Result<Option<String>> {
|
||||
match self.get_value(&["accounts", "qobuz", "bundle_version"]) {
|
||||
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_qobuz_bundle_version(&self, version: &str) -> Result<()> {
|
||||
self.set_value(
|
||||
&["accounts", "qobuz", "bundle_version"],
|
||||
Value::String(version.to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,18 @@ impl LazyProvider for QobuzLazyProvider {
|
||||
|
||||
async fn get_url(&self, lazy_pk: &str) -> Result<String> {
|
||||
let track_id = self.track_id_from_lazy(lazy_pk)?;
|
||||
|
||||
// L'endpoint CMAF local n'existe que si la feature `pmoserver` est active
|
||||
// (route /qobuz/tracks/:id/flac enregistrée). Sans cette feature, fallback
|
||||
// sur l'ancienne API Qobuz directe.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
{
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
return Ok(format!("{}/qobuz/tracks/{}/flac", base.trim_end_matches('/'), track_id));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
self.client
|
||||
.get_stream_url(track_id)
|
||||
.await
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod cache;
|
||||
pub mod cmaf;
|
||||
pub mod client;
|
||||
pub mod config_ext;
|
||||
pub mod didl;
|
||||
@@ -216,6 +217,7 @@ pub mod disk_cache;
|
||||
pub mod error;
|
||||
mod lazy_provider;
|
||||
pub mod models;
|
||||
pub mod retry;
|
||||
pub mod source;
|
||||
|
||||
// Extension pmoserver (feature-gated)
|
||||
|
||||
@@ -208,6 +208,31 @@ pub struct StreamInfo {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Informations pour le streaming CMAF d'un track.
|
||||
///
|
||||
/// Retourné par `QobuzClient::get_cmaf_stream_info`.
|
||||
/// L'appelant peut ensuite appeler `pmoqobuz::cmaf::download_full` pour
|
||||
/// obtenir les bytes FLAC déchiffrés, ou streamer les segments manuellement.
|
||||
#[derive(Debug)]
|
||||
pub struct CmafStreamInfo {
|
||||
/// Modèle d'URL des segments, avec le placeholder `$SEGMENT$`.
|
||||
pub url_template: String,
|
||||
/// Nombre de segments audio (hors segment init s=0).
|
||||
pub n_segments: u8,
|
||||
/// Clé AES-128 déchiffrée pour décoder les frames FLAC.
|
||||
pub content_key: [u8; 16],
|
||||
/// Header FLAC extrait du segment init (à placer en tête du flux décodé).
|
||||
pub flac_header: Vec<u8>,
|
||||
/// Table des segments avec taille et compte d'échantillons.
|
||||
pub segment_table: Vec<crate::cmaf::SegmentTableEntry>,
|
||||
/// Format ID Qobuz.
|
||||
pub format_id: u32,
|
||||
/// Fréquence d'échantillonnage (Hz).
|
||||
pub sampling_rate: Option<u32>,
|
||||
/// Profondeur de bits.
|
||||
pub bit_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// Format audio demandé pour le streaming
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
use crate::api_rest::{create_router, QobuzState};
|
||||
use crate::client::QobuzClient;
|
||||
use crate::config_ext::QobuzConfigExt;
|
||||
use crate::pmoserver_ext::QobuzServerExt;
|
||||
use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
|
||||
194
pmoqobuz/src/retry.rs
Normal file
194
pmoqobuz/src/retry.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Helper de retry avec backoff exponentiel pour les fetches réseau transitoires.
|
||||
//!
|
||||
//! Un blip réseau transitoire (5xx, timeout, connexion reset, 429) sur le
|
||||
//! `file/url` du prochain track ou un segment CMAF ne doit pas être fatal.
|
||||
//! Ce module retente les échecs *transitoires* avec backoff exponentiel
|
||||
//! et laisse les échecs *terminaux* (404 "disparu définitivement", erreurs auth)
|
||||
//! se propager immédiatement.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Nombre de tentatives : 1 initiale + 2 retrys.
|
||||
pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Erreur de fetch taguée selon son caractère retryable.
|
||||
#[derive(Debug)]
|
||||
pub enum FetchError {
|
||||
/// Vaut la peine de retenter : erreur réseau/timeout/connect/body, 5xx, ou 429.
|
||||
Transient(String),
|
||||
/// Ne vaut pas la peine de retenter : 4xx (sauf 429), ou échec définitif.
|
||||
Terminal(String),
|
||||
}
|
||||
|
||||
impl FetchError {
|
||||
pub fn is_transient(&self) -> bool {
|
||||
matches!(self, FetchError::Transient(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FetchError::Transient(s) | FetchError::Terminal(s) => write!(f, "{}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vrai pour les erreurs reqwest qui valent la peine d'être retentées.
|
||||
pub fn reqwest_is_transient(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
|
||||
}
|
||||
|
||||
/// Classifie une erreur reqwest en `FetchError`.
|
||||
/// Toutes les erreurs transport reqwest sont traitées comme transitoires.
|
||||
pub fn classify_reqwest(e: &reqwest::Error, context: &str) -> FetchError {
|
||||
FetchError::Transient(format!("{}: {}", context, e))
|
||||
}
|
||||
|
||||
/// Classifie un status HTTP non-succès en `FetchError`.
|
||||
/// 5xx et 429 → transitoire ; tout le reste (404, 403, ...) → terminal.
|
||||
pub fn classify_status(status: reqwest::StatusCode, context: &str) -> FetchError {
|
||||
let msg = format!("{}: HTTP {}", context, status);
|
||||
if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
FetchError::Transient(msg)
|
||||
} else {
|
||||
FetchError::Terminal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backoff exponentiel avec jitter pour la N-ième tentative (base 1) :
|
||||
/// ~250 ms, ~500 ms, ~1 s, plafonné à 2 s, plus jusqu'à +25% de jitter.
|
||||
/// Le jitter est dérivé de l'horloge pour éviter une dépendance à `rand`.
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let exp = attempt.saturating_sub(1).min(3);
|
||||
let base_ms = 250u64.saturating_mul(1u64 << exp).min(2000);
|
||||
let jitter_span = base_ms / 4;
|
||||
let jitter = if jitter_span > 0 {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
nanos % (jitter_span + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Duration::from_millis(base_ms + jitter)
|
||||
}
|
||||
|
||||
/// Exécute `op` (qui prend le numéro de tentative base 1) et retente
|
||||
/// tant qu'il retourne une erreur transitoire, avec backoff entre les tentatives.
|
||||
/// Les erreurs terminales et la dernière tentative retournent immédiatement.
|
||||
pub async fn retry_transient<F, Fut, T, E>(
|
||||
max_attempts: u32,
|
||||
log_tag: &str,
|
||||
is_transient: impl Fn(&E) -> bool,
|
||||
mut op: F,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
F: FnMut(u32) -> Fut,
|
||||
Fut: Future<Output = std::result::Result<T, E>>,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
let mut attempt = 1;
|
||||
loop {
|
||||
match op(attempt).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
if attempt >= max_attempts || !is_transient(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
let delay = backoff_delay(attempt);
|
||||
tracing::warn!(
|
||||
"[{}] erreur transitoire tentative {}/{}: {} — retry dans {}ms",
|
||||
log_tag,
|
||||
attempt,
|
||||
max_attempts,
|
||||
err,
|
||||
delay.as_millis()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reussit_au_premier_essai() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let c = calls.clone();
|
||||
let r: std::result::Result<u32, FetchError> =
|
||||
retry_transient(3, "test", FetchError::is_transient, |_| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(42)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(r.unwrap(), 42);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retente_transitoire_puis_reussit() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let c = calls.clone();
|
||||
let r: std::result::Result<u32, FetchError> =
|
||||
retry_transient(3, "test", FetchError::is_transient, |attempt| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::Relaxed);
|
||||
if attempt < 3 {
|
||||
Err(FetchError::Transient("503".into()))
|
||||
} else {
|
||||
Ok(7)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(r.unwrap(), 7);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_ne_retente_pas() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let c = calls.clone();
|
||||
let r: std::result::Result<u32, FetchError> =
|
||||
retry_transient(3, "test", FetchError::is_transient, |_| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::Relaxed);
|
||||
Err(FetchError::Terminal("404".into()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abandonne_apres_max_tentatives() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let c = calls.clone();
|
||||
let r: std::result::Result<u32, FetchError> =
|
||||
retry_transient(3, "test", FetchError::is_transient, |_| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::Relaxed);
|
||||
Err(FetchError::Transient("timeout".into()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
}
|
||||
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
@@ -1 +1 @@
|
||||
0.3.48
|
||||
0.3.51
|
||||
|
||||
Reference in New Issue
Block a user