push-xvuyuzpqnxzs #91
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Audit PMO Control - Optimisation Playlist OpenHome
|
||||||
|
|
||||||
|
## Résumé Exécutif
|
||||||
|
|
||||||
|
L'utilisateur rapporte des lenteurs significatives lors de la manipulation de playlists de ~1000 titres avec les renderers OpenHome. Les renderers Chromecast et UPnP (avec queue interne) ne sont pas affectés.
|
||||||
|
|
||||||
|
## État des Optimisations Deja Implémentées
|
||||||
|
|
||||||
|
Le precedent plan dans `Blackboard/Todo/enorme_playlist.md` a deja été partiellement implémenté:
|
||||||
|
|
||||||
|
| optimisation | Statut | Emplacement |
|
||||||
|
|-------------|-------|------------|
|
||||||
|
| MAX_BATCH = 256 pour ReadList | ✅ FAIT | `openhome.rs:1015` |
|
||||||
|
| Élimination double queue_snapshot() | ✅ FAIT | `replace_queue_with_pivot()` et `replace_queue_standard_lcs()` |
|
||||||
|
| LCS préfixe/suffixe (lcs_flags_optimized) | ✅ FAIT | `openhome.rs:869` |
|
||||||
|
| Polling adaptatif (is_active) | ✅ FAIT | `musicrenderer.rs:325-374` |
|
||||||
|
| Consolidation invalidation caches | ✅ FAIT | `openhome.rs:222-235` |
|
||||||
|
|
||||||
|
## Contraintes Protocolaires Découvertes
|
||||||
|
|
||||||
|
L'action `Insert` **ne supporte PAS l'insertion par lot** - chaque appel prend un seul Uri/Metadata.
|
||||||
|
|
||||||
|
## Nouvelles Optimisations (Contre-propositions Utilisateur)
|
||||||
|
|
||||||
|
### OPT-1: Fast Path pour 99% des cas de sync_queue
|
||||||
|
|
||||||
|
**Observation**: 99% des changements de playlist sont:
|
||||||
|
- Insertion de nouvelles tracks en **fin de queue**
|
||||||
|
- Délétion de tracks en **début de queue**
|
||||||
|
- Rarement des changements nécessitant un vrai alignement LCS
|
||||||
|
|
||||||
|
**Solution**: Ajouter une détection de pattern avant d'appeler LCS:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn smart_sync(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
let current_ids = self.track_ids()?;
|
||||||
|
|
||||||
|
// Cas 1: Append only (insertion en fin)
|
||||||
|
if items.starts_with(¤t_ids) {
|
||||||
|
// Fast path: juste ajouter les nouveaux items
|
||||||
|
return self.append_only(items.skip(current_ids.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 2: Delete from beginning
|
||||||
|
if current_ids.starts_with(&items) {
|
||||||
|
// Fast path: supprimer de la fin
|
||||||
|
return self.delete_from_beginning(current_ids.len() - items.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 3: Full LCS only for complex reorderings
|
||||||
|
return self.replace_queue_standard_lcs(items);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: 99% des sync_queue passent de O(N²) à O(N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OPT-2: Queue FIFO pour Opérations OpenHome (Thread Background)
|
||||||
|
|
||||||
|
**Concept**: Une file d'attente FIFO des opérations SOAP exécutée dans un thread dédié.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct OpenHomeOpQueue {
|
||||||
|
queue: Arc<Mutex<Vec<OpenHomeOp>>>,
|
||||||
|
worker_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum OpenHomeOp {
|
||||||
|
Insert { uri: String, metadata: String, after_id: u32 },
|
||||||
|
Delete { track_id: u32 },
|
||||||
|
DeleteAll,
|
||||||
|
SeekId { id: u32 },
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
SetVolume { volume: u16 },
|
||||||
|
// Meta operations
|
||||||
|
UpdateMetadata { track_id: u32, metadata: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenHomeOpQueue {
|
||||||
|
/// Push operation to the FIFO queue
|
||||||
|
pub fn push(&self, op: OpenHomeOp) {
|
||||||
|
self.queue.lock().unwrap().push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push with priority (volume, play, stop - need fast response)
|
||||||
|
pub fn push_first(&self, op: OpenHomeOp) {
|
||||||
|
self.queue.lock().unwrap().push_front(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all pending operations (client can flush)
|
||||||
|
pub fn clear(&self) {
|
||||||
|
self.queue.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Worker thread consumes operations
|
||||||
|
fn worker_loop(&self) {
|
||||||
|
loop {
|
||||||
|
let op = self.queue.lock().unwrap().pop_front();
|
||||||
|
match op {
|
||||||
|
Some(op) => self.execute(op),
|
||||||
|
None => thread::sleep(Duration::from_millis(10)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- UI non-bloquante (les operations sont lancées et exec en background)
|
||||||
|
- Batching naturel (plusieurs operations sont executes en sequence)
|
||||||
|
- Priorité via `push_first()` pour play/stop/volume
|
||||||
|
- `clear()` permet d'annuler les operations en attente (ex: playlist changée)
|
||||||
|
|
||||||
|
**Implémentation suggérée**:
|
||||||
|
1. Créer `src/queue/openhome_op_queue.rs` avec la structure
|
||||||
|
2. Intégrer dans `OpenHomeQueue` ou `OpenHomeRenderer`
|
||||||
|
3. Thread de worker lancé au démarrage du control point
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OPT-3: Métadonnées en Tâche de Fond
|
||||||
|
|
||||||
|
**Observation**: L'appli utilise-t-elle vraiment les métadonnées de la queue OpenHome, ou un cache local?
|
||||||
|
|
||||||
|
Si le control-point maintient son propre cache (plus probable):
|
||||||
|
- Les mises à jour de métadonnées peuvent être traitées en background
|
||||||
|
- Pas besoin de sync immédiate des métadonnées
|
||||||
|
|
||||||
|
**Solution**: Queue séparée pour les operations de métadonnées:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Haute priorité (opérations critiques)
|
||||||
|
let high_priority_queue: OpenHomeOpQueue;
|
||||||
|
|
||||||
|
// Basse priorité (métadonnées)
|
||||||
|
let metadata_queue: OpenHomeOpQueue;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implémentation**:
|
||||||
|
1. Séparer les operations critiques (play/stop/seek/volume) de metadata
|
||||||
|
2. Metadata update traités en background avec délais
|
||||||
|
3. Le cache local du control-point est mis à jour indépendamment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OPT-4: Connection Pooling HTTP
|
||||||
|
|
||||||
|
Chaque appel SOAP crée une nouvelle connexion. Avec 1000 insertions:
|
||||||
|
- Overhead TCP: ~10-50ms par appel
|
||||||
|
- Total: 10-50 secondes overhead réseau
|
||||||
|
|
||||||
|
**Solution**: Agent HTTP static avec connection reuse:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// soap_client.rs
|
||||||
|
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
|
||||||
|
Agent::config_builder()
|
||||||
|
.timeout_global(Some(Duration::from_secs(30)))
|
||||||
|
.build()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan d'Implémentation Proposé
|
||||||
|
|
||||||
|
### Phase 1: Fast Path LCS (Priorité Haute)
|
||||||
|
|
||||||
|
1. Ajouter `detect_sync_pattern()` dans `openhome.rs`
|
||||||
|
2. Implémenter `append_only()` et `delete_from_beginning()`
|
||||||
|
3. Tester avec playlists réelles
|
||||||
|
|
||||||
|
### Phase 2: Queue FIFO Opérations (Priorité Haute)
|
||||||
|
|
||||||
|
1. Créer `src/queue/openhome_op_queue.rs`
|
||||||
|
2. Implémenter `push()`, `push_first()`, `clear()`
|
||||||
|
3. Thread worker avec loop de consommation
|
||||||
|
4. Intégrer dans `OpenHomeRenderer`
|
||||||
|
|
||||||
|
### Phase 3: Séparation Métadonnées (Priorité Moyenne)
|
||||||
|
|
||||||
|
1. Créer queue séparée pour metadata
|
||||||
|
2. Implémenter batch processing
|
||||||
|
|
||||||
|
### Phase 4: Connection Pooling (Priorité Basse)
|
||||||
|
|
||||||
|
1. Modifier `soap_client.rs` pour agent static
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions pour Clarification
|
||||||
|
|
||||||
|
1. **Cache Métadonnées**: Le control-point utilise-t-il vraiment les métadonnées de la queue OpenHome, ou maintient-il son propre cache qui est alimenté indépendamment?
|
||||||
|
|
||||||
|
2. **Priorité des Opérations**: Pour `push_first()`, quelles opérations nécessitent une réponse rapide?
|
||||||
|
- Volume (immédiat)
|
||||||
|
- Play/Pause/Stop (immédiat)
|
||||||
|
- Seek (rapide)
|
||||||
|
- Insert (peut être différé)
|
||||||
|
|
||||||
|
3. **Comportement en cas de conflit**: Si le client fait `clear()` et que le worker est en train d'exécuter une opération:
|
||||||
|
- Annuler l'opération en cours? ( risky - peut laisser le renderer dans un état inconsistent)
|
||||||
|
- Laisser finir l'opération en cours? (plus sur)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests Recommandés
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler
|
||||||
|
cargo build -p pmocontrol
|
||||||
|
|
||||||
|
# Benchmark LCS fast paths
|
||||||
|
# - Cas: append 100 tracks to 900 = O(N)
|
||||||
|
# - Cas: delete 100 from 900 = O(N)
|
||||||
|
# - Cas: reorder = O(N²) avec LCS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan mis à jour avec contre-propositions utilisateur*
|
||||||
|
*Date: 2026-04-08*
|
||||||
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Optimisation Playlist OpenHome — Étape 2
|
||||||
|
|
||||||
|
**Contexte**: Suite de `enorme_playlist.md`. Les optimisations de base (MAX_BATCH=256, LCS prefixe/suffixe, polling adaptatif, caches TTL) sont faites. Les lenteurs persistent sur les grandes playlists (~1000 titres). Les renderers Chromecast et UPnP sont moins affectés mais bénéficieront également de certaines optimisations.
|
||||||
|
|
||||||
|
**Contrainte architecturale fondamentale**: La queue OpenHome est la **source de vérité unique**. Un miroir local persistent a été tenté et abandonné — impossible à maintenir en sync quand d'autres control points (BubbleUPnP, Linn, etc.) modifient la queue. Toute optimisation doit respecter cette contrainte.
|
||||||
|
|
||||||
|
**Note sur le LCS**: `lcs_flags_optimized` (openhome.rs:869) gère déjà le cas dominant (préfixe/suffixe communs). Pour un append de 100 tracks à 900 existants, le LCS est O(1) — ce n'est pas le goulot. Le coût réel est les appels SOAP : connexions TCP × (RTT + handshake) et les `ReadList` pour reconstruire le snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse des Goulots Réels
|
||||||
|
|
||||||
|
Pour un `sync_queue` "append 100 tracks à 900 existants" aujourd'hui :
|
||||||
|
|
||||||
|
| Étape | Appels SOAP | Coût estimé |
|
||||||
|
|-------|------------|-------------|
|
||||||
|
| `id_array()` | 1 | ~5ms |
|
||||||
|
| `read_list()` — 4 batches × 256 | 4 | ~20ms |
|
||||||
|
| 100 × `insert()` | 100 | ~100 × (RTT + **TCP handshake**) |
|
||||||
|
| **Total TCP handshakes** | 105 | **105 × 10-50ms = 1-5 secondes** |
|
||||||
|
|
||||||
|
Les deux leviers : (1) éliminer les handshakes TCP, (2) éliminer les `ReadList` quand inutiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan d'Implémentation
|
||||||
|
|
||||||
|
### Phase 1 — Connection Pooling (1-2h, PRIORITÉ MAXIMALE)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/soap_client.rs`
|
||||||
|
|
||||||
|
**Problème**: lignes 65-72 créent un nouvel `ureq::Agent` à chaque appel SOAP = nouvelle connexion TCP à chaque fois.
|
||||||
|
|
||||||
|
**Solution**: Agent statique partagé via `OnceLock`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static SOAP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_soap_agent() -> &'static ureq::Agent {
|
||||||
|
SOAP_AGENT.get_or_init(|| {
|
||||||
|
ureq::Agent::config_builder()
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.timeout_global(Some(Duration::from_secs(30)))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `invoke_upnp_action_with_timeout()`, remplacer la construction de l'agent par :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Cas normal : réutiliser l'agent partagé (keep-alive, connection pooling)
|
||||||
|
// Cas custom timeout : agent dédié (rare — timeout global suffit en pratique)
|
||||||
|
let agent_owned;
|
||||||
|
let agent: &ureq::Agent = if timeout.is_some() {
|
||||||
|
agent_owned = ureq::Agent::config_builder()
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.timeout_global(timeout)
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
&agent_owned
|
||||||
|
} else {
|
||||||
|
get_soap_agent()
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérifier** que `ureq::Agent` maintient bien un pool de connexions HTTP/1.1 keep-alive entre les appels (comportement documenté de ureq v3 — l'agent est conçu pour être réutilisé).
|
||||||
|
|
||||||
|
**Impact**: Élimine les TCP handshakes répétés pour tous les renderers (OpenHome, UPnP, Chromecast). Pour 100 inserts : 99 handshakes économisés × 10-50ms = **1-5 secondes récupérées**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Fast Path Session (2-3h, PRIORITÉ HAUTE)
|
||||||
|
|
||||||
|
**Concept**: Sans miroir persistant (abandonné), on peut quand même éviter les `ReadList` dans le cas dominant en maintenant un état **éphémère de session** — valable uniquement entre deux `sync_queue` consécutifs, et invalidé dès qu'on détecte une incohérence.
|
||||||
|
|
||||||
|
**Principe**: Après chaque `sync_queue`, mémoriser :
|
||||||
|
- la liste d'IDs résultante (déjà dans `track_ids_cache` TTL 1s)
|
||||||
|
- le `after_id` du dernier insert (pour pouvoir appender sans `id_array`)
|
||||||
|
|
||||||
|
Au prochain `sync_queue`, tenter de détecter le pattern sans `ReadList` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||||
|
// Récupérer les IDs actuels (cache ou 1 appel id_array)
|
||||||
|
let current_ids = self.track_ids()?;
|
||||||
|
let current_len = current_ids.len();
|
||||||
|
let new_len = new_items.len();
|
||||||
|
|
||||||
|
// Fast path 1: append only
|
||||||
|
// Condition: new_items a plus d'items, et les current_len premiers de new_items
|
||||||
|
// ont les mêmes didl_id que les items actuels (vérifiable depuis id_array + metadata_cache local)
|
||||||
|
if new_len > current_len {
|
||||||
|
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||||
|
if prefix_matches {
|
||||||
|
return FastPathResult::AppendOnly { items: &new_items[current_len..] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path 2: delete from end
|
||||||
|
if new_len < current_len {
|
||||||
|
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||||
|
if prefix_matches {
|
||||||
|
let to_delete = ¤t_ids[new_len..];
|
||||||
|
return FastPathResult::DeleteFromEnd { ids: to_delete };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas général: fallback ReadList + LCS
|
||||||
|
FastPathResult::NeedFullSync
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`check_prefix_matches`** : compare `current_ids[i]` avec `new_items[i]` en utilisant le `metadata_cache` local (déjà en mémoire) pour résoudre les URIs/didl_ids des IDs connus. Si un ID n'est pas en cache → fast path impossible → fallback.
|
||||||
|
|
||||||
|
**Clé**: Cette vérification utilise uniquement le `metadata_cache` local (HashMap en mémoire, nano-secondes) et `id_array()` (déjà caché TTL 1s). Zéro appel `ReadList` dans le cas heureux. Si la vérification échoue (incohérence détectée, cache manquant) → fallback propre vers `ReadList` + LCS, source de vérité OpenHome préservée.
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/queue/openhome.rs`, ajouter `try_fast_path()` et l'intégrer en début de `sync_queue()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Queue FIFO Async (8-12h, PRIORITÉ MOYENNE)
|
||||||
|
|
||||||
|
**Prérequis**: Phases 1 et 2 complétées.
|
||||||
|
|
||||||
|
**Concept**: Exécuter les opérations SOAP dans un thread dédié pour rendre `sync_queue()` non-bloquant du point de vue de l'appelant.
|
||||||
|
|
||||||
|
**Contrainte technique**: Les `insert()` sont chaînés — chaque appel retourne un `new_id` utilisé comme `after_id` du suivant. Le worker doit maintenir cet état interne.
|
||||||
|
|
||||||
|
**Fichier à créer**: `pmocontrol/src/queue/openhome_op_queue.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum OpenHomeOp {
|
||||||
|
/// Insert séquentiel — after_id géré en interne (last_inserted_id)
|
||||||
|
InsertAtEnd { uri: String, metadata: String, didl_id: String },
|
||||||
|
/// Insert après un ID connu (ex: après le pivot)
|
||||||
|
InsertAfter { after_id: u32, uri: String, metadata: String, didl_id: String },
|
||||||
|
DeleteId { track_id: u32 },
|
||||||
|
DeleteAll,
|
||||||
|
SeekId { id: u32 },
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
SetVolume { volume: u16 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OpenHomeOpQueue {
|
||||||
|
sender: mpsc::Sender<OpenHomeOp>,
|
||||||
|
last_error: Arc<Mutex<Option<ControlPointError>>>,
|
||||||
|
completion: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenHomeOpQueue {
|
||||||
|
pub fn push(&self, op: OpenHomeOp) { ... }
|
||||||
|
/// Opérations critiques passent devant (play/stop/volume)
|
||||||
|
pub fn push_priority(&self, op: OpenHomeOp) { ... }
|
||||||
|
/// Vider la file d'attente (ex: nouvelle playlist demandée avant fin de sync)
|
||||||
|
pub fn clear_pending(&self) { ... }
|
||||||
|
/// Attendre que toutes les opérations soient exécutées
|
||||||
|
pub fn wait_completion(&self) { ... }
|
||||||
|
/// Récupérer la dernière erreur (non-bloquant)
|
||||||
|
pub fn take_error(&self) -> Option<ControlPointError> { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comportement en cas d'erreur**: vider la file d'attente, signaler l'erreur via `last_error`, invalider les caches OpenHome (forcer re-sync depuis source de vérité au prochain appel).
|
||||||
|
|
||||||
|
**Comportement en cas de `clear_pending()` pendant exécution**: laisser l'opération en cours se terminer (plus sûr — évite de laisser le renderer dans un état inconsistant), vider le reste.
|
||||||
|
|
||||||
|
**Intégration dans `OpenHomeQueue`**: remplacer les appels directs `playlist_client.insert()` / `playlist_client.delete_id()` par des `op_queue.push()`. Les opérations qui ont besoin d'une réponse synchrone (ex: `current_track()`, `queue_snapshot()`) continuent d'appeler directement le `playlist_client` — mais doivent d'abord attendre la complétion de la file (`wait_completion()`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — Throttle des replace_item (2-3h, PRIORITÉ BASSE)
|
||||||
|
|
||||||
|
**Contexte**: `replace_item()` (openhome.rs:1315) fait `delete_id` + `insert` pour mettre à jour une piste. Sur un stream radio qui change de morceau, la durée est mise à jour fréquemment → paires SOAP inutiles car le `metadata_cache` local est déjà la source pour l'UI.
|
||||||
|
|
||||||
|
**Solution**: Ne pas envoyer le `delete_id` + `insert` OpenHome si une mise à jour pour ce `track_id` a déjà été envoyée dans les N dernières secondes. Le `metadata_cache` local est mis à jour immédiatement (pour l'UI), et l'opération OpenHome est différée ou ignorée.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
let track_id = self.track_ids()?[index];
|
||||||
|
|
||||||
|
// Toujours mettre à jour le cache local immédiatement (pour l'UI)
|
||||||
|
self.cache_metadata(track_id, item.metadata.clone());
|
||||||
|
|
||||||
|
// Throttle: si replace OpenHome récent pour ce track, sauter l'opération SOAP
|
||||||
|
if self.is_recent_replace(track_id, Duration::from_secs(5)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.mark_replace_time(track_id);
|
||||||
|
|
||||||
|
// Opération SOAP (delete + insert)
|
||||||
|
// ... code existant ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `last_replace_times: Mutex<HashMap<u32, SystemTime>>` dans `OpenHomeQueue`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'Implémentation
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 → mesurer gain TCP → Phase 2 → mesurer gain ReadList → Phase 3 → Phase 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Tester chaque phase avec des playlists réelles (~1000 tracks) avant de poursuivre. Ne pas combiner les phases pour pouvoir isoler les régressions.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Phase 1 — vérifier connexions TCP réutilisées
|
||||||
|
# tcpdump -i lo port 60000 -c 200 (ou port du renderer)
|
||||||
|
# Avant: SYN à chaque appel SOAP
|
||||||
|
# Après: SYN unique, flux keep-alive
|
||||||
|
|
||||||
|
# Phase 2 — vérifier fast path activé
|
||||||
|
# RUST_LOG=debug cargo run ... 2>&1 | grep "fast path"
|
||||||
|
# Cas append 100 → "fast path: AppendOnly, 100 inserts"
|
||||||
|
# Cas delete 100 → "fast path: DeleteFromEnd, 100 deletes"
|
||||||
|
# Cas reorder → "fast path: NeedFullSync, falling back to ReadList+LCS"
|
||||||
|
|
||||||
|
# Phase 3 — vérifier non-blocage
|
||||||
|
# sync_queue() doit retourner en <1ms (les 100 inserts continuent en background)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Contrainte architecturale intégrée: miroir local abandonné (désynchronisation avec autres control points). Toutes les phases respectent OpenHome comme source de vérité unique.*
|
||||||
|
|
||||||
|
*Date: 2026-04-08*
|
||||||
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||||
|
|
||||||
|
# Async Queue Refresh — Étape 3
|
||||||
|
|
||||||
|
**Contexte**: `refresh_attached_queue_for()` dans `control_point.rs` est appelé de 3 endroits
|
||||||
|
et bloque son thread pendant toute la synchronisation (browse media server + 100+ opérations
|
||||||
|
SOAP/mémoire). L'objectif est de factoriser le mécanisme async au niveau de la couche queue
|
||||||
|
(`MusicQueue`), qui est déjà l'abstraction agnostique du backend. `control_point.rs` ne doit
|
||||||
|
plus connaître les threads ni les tokens d'annulation.
|
||||||
|
|
||||||
|
**Principe architectural**: la couche queue sait *comment* syncer (mémoire ou SOAP) et donc
|
||||||
|
aussi *comment* annuler et *quand* signaler que la lecture peut démarrer. `control_point.rs`
|
||||||
|
sait seulement *quoi* syncer (browse + conversion PlaybackItem). Les deux responsabilités
|
||||||
|
restent séparées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue d'ensemble des changements
|
||||||
|
|
||||||
|
```
|
||||||
|
AVANT
|
||||||
|
control_point.rs
|
||||||
|
refresh_attached_queue_for()
|
||||||
|
→ browse()
|
||||||
|
→ sync_queue(items) ← bloquant 1-5s
|
||||||
|
|
||||||
|
APRÈS
|
||||||
|
control_point.rs
|
||||||
|
do_queue_refresh_work() ← interne, fait le browse + conversion
|
||||||
|
MusicQueue (couche queue)
|
||||||
|
schedule_sync(items, callbacks) ← non-bloquant, retourne immédiatement
|
||||||
|
→ thread "queue-sync-{renderer_id}"
|
||||||
|
→ QueueBackend::sync_queue(items, cancel_token, on_ready)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers à modifier / créer
|
||||||
|
|
||||||
|
| Fichier | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `pmocontrol/src/errors.rs` | Ajouter variante `SyncCancelled` |
|
||||||
|
| `pmocontrol/src/queue/backend.rs` | Modifier signature `sync_queue()` |
|
||||||
|
| `pmocontrol/src/queue/interne.rs` | Adapter signature `sync_queue()` |
|
||||||
|
| `pmocontrol/src/queue/openhome.rs` | Adapter + points de vérification cancel + on_ready |
|
||||||
|
| `pmocontrol/src/queue/music_queue.rs` | Ajouter champs async + méthode `schedule_sync()` |
|
||||||
|
| `pmocontrol/src/queue/mod.rs` | Exporter `SyncScheduleOutcome` |
|
||||||
|
| `pmocontrol/src/model.rs` | Ajouter événements `QueueReadyToPlay`, `QueueSyncCancelled` |
|
||||||
|
| `pmocontrol/src/sse.rs` | Sérialiser les deux nouveaux événements |
|
||||||
|
| `pmocontrol/src/control_point.rs` | Remplacer les 3 call sites bloquants |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 1 — Nouvelle variante d'erreur (`errors.rs`)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/errors.rs`
|
||||||
|
|
||||||
|
Ajouter après la ligne 51 (`ControlPoint`) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||||
|
SyncCancelled,
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette variante est retournée par `sync_queue()` quand le `cancel_token` passe à `true`.
|
||||||
|
Elle est **non-fatale** — le coordinator la traite comme un comportement normal, pas une
|
||||||
|
erreur à logger en `warn!`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Modifier la signature de `sync_queue()` dans le trait (`backend.rs`)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/queue/backend.rs`, ligne 110
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AVANT
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
// APRÈS
|
||||||
|
use std::sync::{Arc, atomic::AtomicBool};
|
||||||
|
|
||||||
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sémantique des paramètres** :
|
||||||
|
- `cancel_token` : si `true` au moment d'une opération, retourner `Err(SyncCancelled)` immédiatement
|
||||||
|
- `on_ready` : callback one-shot appelé quand la lecture peut démarrer (voir logique ci-dessous)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Adapter `InternalQueue::sync_queue()` (`interne.rs`)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/queue/interne.rs`
|
||||||
|
|
||||||
|
Trouver la méthode `sync_queue()` et adapter la signature. Le corps reste identique,
|
||||||
|
avec deux ajouts :
|
||||||
|
|
||||||
|
**1. Vérification du pivot (early start)** : si une piste est en cours de lecture
|
||||||
|
(un `current_index` est défini dans le snapshot courant), appeler `on_ready` immédiatement
|
||||||
|
avant toute opération — la piste courante sera préservée.
|
||||||
|
|
||||||
|
**2. Si pas de pivot** (queue vide ou aucune piste en cours) : appeler `on_ready` après
|
||||||
|
avoir inséré le premier item.
|
||||||
|
|
||||||
|
**3. Vérification cancel** : après chaque item inséré/supprimé (en pratique `InternalQueue`
|
||||||
|
est rapide mais le principe doit être cohérent) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
|
// Early start si pivot présent
|
||||||
|
let has_current = self.current_index()?.is_some();
|
||||||
|
if has_current {
|
||||||
|
if let Some(f) = on_ready.take() { f(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... logique existante de sync_queue() ...
|
||||||
|
// Dans la boucle d'insertions, après le 1er insert :
|
||||||
|
if on_ready.is_some() {
|
||||||
|
if let Some(f) = on_ready.take() { f(); }
|
||||||
|
}
|
||||||
|
// Après chaque opération :
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Adapter `OpenHomeQueue::sync_queue()` (`openhome.rs`)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/queue/openhome.rs`
|
||||||
|
|
||||||
|
### 4.1 Signature (ligne ~1277)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Early start — logique pivot
|
||||||
|
|
||||||
|
Au début de `sync_queue()`, **avant** toute opération SOAP, détecter si un pivot est présent :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Après la récupération du snapshot (ligne ~1323), avant les branches if/else :
|
||||||
|
let has_pivot = playing_info.is_some();
|
||||||
|
if has_pivot {
|
||||||
|
// Le pivot sera préservé — on peut démarrer la lecture immédiatement
|
||||||
|
if let Some(f) = on_ready.take() { f(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si pas de pivot (nouvelle playlist via `delete_all` + inserts depuis 0) : appeler `on_ready`
|
||||||
|
après le **1er insert réussi** dans `replace_queue()` et dans `replace_queue_standard_lcs()`.
|
||||||
|
|
||||||
|
### 4.3 Points de vérification cancel
|
||||||
|
|
||||||
|
Ajouter `if cancel_token.load(SeqCst) { return Err(SyncCancelled); }` aux endroits suivants :
|
||||||
|
|
||||||
|
- Dans `delete_marked_items()` (ligne ~458) : après chaque `delete_id_if_exists()`
|
||||||
|
- Dans `rebuild_playlist_section()` (ligne ~494) : après chaque `insert()`
|
||||||
|
- Dans `replace_queue_preserve_current()` (ligne ~419) : après chaque `delete_id_if_exists()` et `insert()`
|
||||||
|
- Dans `replace_queue_standard_lcs()` (ligne ~693-745) : après chaque delete et insert
|
||||||
|
- Dans `replace_queue()` (ligne ~1118) : après chaque insert dans la boucle
|
||||||
|
- Dans le fast path `AppendOnly` (ligne ~1318) : après chaque insert
|
||||||
|
- Dans le fast path `DeleteFromEnd` (ligne ~1336) : après chaque delete
|
||||||
|
|
||||||
|
### 4.4 Propagation du cancel_token aux helpers
|
||||||
|
|
||||||
|
Les méthodes helper privées qui font des boucles doivent recevoir le token :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn delete_marked_items(
|
||||||
|
&mut self,
|
||||||
|
old_ids: &[u32],
|
||||||
|
keep_flags: &[bool],
|
||||||
|
position_label: &str,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
) -> Result<(), ControlPointError>
|
||||||
|
|
||||||
|
fn rebuild_playlist_section(
|
||||||
|
&mut self,
|
||||||
|
// ... params existants ...
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<u32, ControlPointError>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Adapter `MusicQueue` (dispatch enum) (`music_queue.rs`)
|
||||||
|
|
||||||
|
**Fichier**: `pmocontrol/src/queue/music_queue.rs`
|
||||||
|
|
||||||
|
### 5.1 Adapter le dispatch `sync_queue()` (ligne ~98)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||||
|
MusicQueue::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Ajouter l'état async dans `MusicQueue`
|
||||||
|
|
||||||
|
`MusicQueue` passe de simple enum de dispatch à une struct qui **encapsule** l'enum backend
|
||||||
|
et l'état de synchronisation async :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AVANT
|
||||||
|
pub enum MusicQueue {
|
||||||
|
Internal(InternalQueue),
|
||||||
|
OpenHome(OpenHomeQueue),
|
||||||
|
}
|
||||||
|
|
||||||
|
// APRÈS
|
||||||
|
pub struct MusicQueue {
|
||||||
|
backend: MusicQueueBackend,
|
||||||
|
// État async de synchronisation
|
||||||
|
sync_in_progress: Arc<AtomicBool>,
|
||||||
|
sync_pending: Arc<AtomicBool>,
|
||||||
|
sync_cancel_token: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'enum devient privée
|
||||||
|
enum MusicQueueBackend {
|
||||||
|
Internal(InternalQueue),
|
||||||
|
OpenHome(OpenHomeQueue),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: le changement de `enum` en `struct` implique de mettre à jour toutes les
|
||||||
|
utilisations de `MusicQueue::Internal(...)` et `MusicQueue::OpenHome(...)` dans le reste
|
||||||
|
du code (essentiellement `music_queue.rs` lui-même et `mod.rs`). Les callers externes
|
||||||
|
utilisent `MusicQueue` via `QueueBackend` et `QueueFromRendererInfo` — ils ne sont pas
|
||||||
|
impactés si l'API publique est préservée.
|
||||||
|
|
||||||
|
### 5.3 Enum résultat et méthode `schedule_sync()`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SyncScheduleOutcome {
|
||||||
|
/// Thread spawné, sync en cours.
|
||||||
|
Scheduled,
|
||||||
|
/// Sync déjà en cours — annulée et nouvelle sync programmée en pending.
|
||||||
|
AlreadyRunning,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl MusicQueue {
|
||||||
|
/// Lance une synchronisation asynchrone de la queue.
|
||||||
|
///
|
||||||
|
/// - Si aucune sync n'est en cours : spawne un thread, retourne `Scheduled`.
|
||||||
|
/// - Si une sync est en cours : l'annule, note une sync pending, retourne `AlreadyRunning`.
|
||||||
|
/// Le thread en cours finira l'opération courante, détectera le cancel, puis
|
||||||
|
/// relancera la sync avec les nouveaux items via `pending_items_fn`.
|
||||||
|
///
|
||||||
|
/// `pending_items_fn` : closure appelée dans le worker pour re-fetcher les items
|
||||||
|
/// en cas de pending. Elle doit être Send + 'static car elle s'exécute dans un thread.
|
||||||
|
///
|
||||||
|
/// `on_ready` : appelé dès que la lecture peut démarrer (pivot préservé ou 1er insert).
|
||||||
|
///
|
||||||
|
/// `renderer_id` : utilisé uniquement pour nommer le thread de travail.
|
||||||
|
pub fn schedule_sync(
|
||||||
|
&self, // &self car l'état async est derrière Arc<AtomicBool>
|
||||||
|
renderer_id: &str,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||||
|
) -> SyncScheduleOutcome
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problème de `&mut self` vs `&self`** : `sync_queue()` dans le trait prend `&mut self`
|
||||||
|
car les backends mutent leur état. Mais `schedule_sync()` veut spawner un thread qui détient
|
||||||
|
le backend. Solution : le backend est déjà derrière `Arc<Mutex<MusicQueue>>` dans
|
||||||
|
`MusicRenderer` (ligne 124 de musicrenderer.rs — c'est le `queue` field). Le thread worker
|
||||||
|
clone cet `Arc` et acquiert le lock pour appeler `sync_queue()`.
|
||||||
|
|
||||||
|
**Signature révisée** :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Doit être appelé avec un Arc<Mutex<Self>> pour permettre le spawn du thread worker.
|
||||||
|
pub fn schedule_sync(
|
||||||
|
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||||
|
renderer_id: &str,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||||
|
) -> SyncScheduleOutcome {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
|
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||||
|
let q = queue_arc.lock().unwrap();
|
||||||
|
(
|
||||||
|
Arc::clone(&q.sync_in_progress),
|
||||||
|
Arc::clone(&q.sync_pending),
|
||||||
|
Arc::clone(&q.sync_cancel_token),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if sync_in_progress.swap(true, SeqCst) {
|
||||||
|
// Sync en cours : annuler et noter pending
|
||||||
|
sync_cancel_token.store(true, SeqCst);
|
||||||
|
sync_pending.store(true, SeqCst);
|
||||||
|
return SyncScheduleOutcome::AlreadyRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de sync en cours : initialiser et spawner
|
||||||
|
sync_cancel_token.store(false, SeqCst);
|
||||||
|
sync_pending.store(false, SeqCst);
|
||||||
|
|
||||||
|
let queue_arc = Arc::clone(queue_arc);
|
||||||
|
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||||
|
|
||||||
|
thread::Builder::new()
|
||||||
|
.name(thread_name)
|
||||||
|
.spawn(move || {
|
||||||
|
// Guard: libère in_progress à la sortie même en cas de panic
|
||||||
|
struct Guard(Arc<AtomicBool>);
|
||||||
|
impl Drop for Guard {
|
||||||
|
fn drop(&mut self) { self.0.store(false, SeqCst); }
|
||||||
|
}
|
||||||
|
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||||
|
|
||||||
|
let mut current_items = items;
|
||||||
|
let mut current_on_ready = Some(on_ready);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sync_pending.store(false, SeqCst);
|
||||||
|
sync_cancel_token.store(false, SeqCst);
|
||||||
|
|
||||||
|
let result = {
|
||||||
|
let mut q = queue_arc.lock().unwrap();
|
||||||
|
q.backend.sync_queue(
|
||||||
|
current_items,
|
||||||
|
&sync_cancel_token,
|
||||||
|
current_on_ready.take().flatten(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(ControlPointError::SyncCancelled) => {
|
||||||
|
// Annulé normalement — vérifier si pending
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("queue-sync error: {}", e);
|
||||||
|
}
|
||||||
|
Ok(()) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sync_pending.load(SeqCst) {
|
||||||
|
break; // Pas de nouvelle sync en attente → terminer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouvelle sync demandée pendant l'exécution → re-fetcher et relancer
|
||||||
|
match pending_items_fn() {
|
||||||
|
Ok(new_items) => {
|
||||||
|
current_items = new_items;
|
||||||
|
current_on_ready = Some(None); // pas de on_ready pour les re-syncs
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("queue-sync pending re-fetch error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// _guard libère sync_in_progress = false
|
||||||
|
})
|
||||||
|
.expect("Failed to spawn queue-sync thread");
|
||||||
|
|
||||||
|
SyncScheduleOutcome::Scheduled
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 6 — Nouveaux événements SSE
|
||||||
|
|
||||||
|
### 6.1 `model.rs`
|
||||||
|
|
||||||
|
Localiser l'enum `RendererEvent` et ajouter :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Émis dès que la queue peut être lue (pivot préservé ou 1er track inséré).
|
||||||
|
QueueReadyToPlay {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
|
/// Émis quand une sync est annulée car une nouvelle a été demandée.
|
||||||
|
QueueSyncCancelled {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `sse.rs`
|
||||||
|
|
||||||
|
Localiser le match de sérialisation des `RendererEvent` et ajouter les deux variantes.
|
||||||
|
Suivre le pattern existant de `QueueRefreshing` (ligne ~152) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
RendererEvent::QueueReadyToPlay { id } => {
|
||||||
|
// sérialiser avec type = "queue_ready_to_play"
|
||||||
|
}
|
||||||
|
RendererEvent::QueueSyncCancelled { id } => {
|
||||||
|
// sérialiser avec type = "queue_sync_cancelled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 7 — Modifier `control_point.rs`
|
||||||
|
|
||||||
|
### 7.1 Extraire la logique de browse
|
||||||
|
|
||||||
|
Renommer `refresh_attached_queue_for()` en deux fonctions :
|
||||||
|
|
||||||
|
**`fetch_queue_items_for()`** (nouvelle, interne) : fait le browse + conversion, retourne
|
||||||
|
`Vec<PlaybackItem>`. C'est la `pending_items_fn` passée à `schedule_sync()`.
|
||||||
|
|
||||||
|
**`schedule_queue_refresh_for()`** (remplace l'ancienne) : appelle `fetch_queue_items_for()`,
|
||||||
|
puis `MusicQueue::schedule_sync()`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fetch_queue_items_for(
|
||||||
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
|
renderer_id: &DeviceId,
|
||||||
|
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
|
// Browse media server + conversion PlaybackItem
|
||||||
|
// (logique actuellement dans refresh_attached_queue_for() lignes ~1593-1670)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_queue_refresh_for(
|
||||||
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
|
renderer_id: &DeviceId,
|
||||||
|
event_bus: &RendererEventBus,
|
||||||
|
auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
|
||||||
|
) -> SyncScheduleOutcome {
|
||||||
|
let items = match fetch_queue_items_for(registry, renderer_id) {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(e) => { warn!(...); return SyncScheduleOutcome::Scheduled; /* ou erreur */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
// on_ready : déclenche auto_play si demandé + émet QueueReadyToPlay SSE
|
||||||
|
let rid = renderer_id.clone();
|
||||||
|
let bus = event_bus.clone();
|
||||||
|
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||||
|
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||||
|
if let Some(cb) = auto_play_cb {
|
||||||
|
if let Err(e) = cb(&rid) {
|
||||||
|
warn!("auto-play callback failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// pending_items_fn : re-fetcher depuis le media server si pending
|
||||||
|
let registry2 = Arc::clone(registry);
|
||||||
|
let rid2 = renderer_id.clone();
|
||||||
|
let pending_fn = Box::new(move || fetch_queue_items_for(®istry2, &rid2));
|
||||||
|
|
||||||
|
// Récupérer l'Arc<Mutex<MusicQueue>> du renderer depuis le registry
|
||||||
|
let queue_arc = {
|
||||||
|
let reg = registry.read().unwrap();
|
||||||
|
reg.get_renderer_queue_arc(renderer_id)? // méthode à ajouter dans DeviceRegistry
|
||||||
|
};
|
||||||
|
|
||||||
|
// Émettre QueueRefreshing avant de lancer
|
||||||
|
event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone() });
|
||||||
|
|
||||||
|
let outcome = MusicQueue::schedule_sync(&queue_arc, &renderer_id.0, items, pending_fn, on_ready);
|
||||||
|
|
||||||
|
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||||
|
event_bus.broadcast(RendererEvent::QueueSyncCancelled { id: renderer_id.clone() });
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Call site 1 — thread "cp-media-server-event-worker" (l.270)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AVANT
|
||||||
|
let _ = refresh_attached_queue_for(®istry, &renderer_id, &event_bus, None);
|
||||||
|
// APRÈS
|
||||||
|
schedule_queue_refresh_for(®istry, &renderer_id, &event_bus, None);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Call site 2 — thread "cp-playlist-periodic-refresh" (l.340)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AVANT
|
||||||
|
let _ = refresh_attached_queue_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||||
|
// APRÈS
|
||||||
|
schedule_queue_refresh_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Call site 3 — `attach_queue_to_playlist_async()` (l.1264)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn attach_queue_to_playlist_async(
|
||||||
|
&self, renderer_id: &DeviceId, server_id: &DeviceId, container_id: &str, auto_play: bool,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
// 1. Enregistrer la liaison (synchrone, ~1ms)
|
||||||
|
self.registry.write().unwrap()
|
||||||
|
.set_playlist_binding(renderer_id, server_id, container_id);
|
||||||
|
|
||||||
|
// 2. Construire le callback auto-play si demandé
|
||||||
|
let cp = self.clone();
|
||||||
|
let rid = renderer_id.clone();
|
||||||
|
let auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||||
|
if auto_play {
|
||||||
|
Some(Box::new(move |id| cp.play_current_from_queue(id)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Lancer le refresh async — retourne immédiatement
|
||||||
|
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, auto_play_cb);
|
||||||
|
|
||||||
|
Ok(()) // La webapp sera notifiée via SSE (QueueRefreshing → QueueReadyToPlay → QueueUpdated)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 Fin de sync — émettre `QueueUpdated`
|
||||||
|
|
||||||
|
L'événement `QueueUpdated` (avec `queue_length`) est actuellement émis à la ligne ~1718
|
||||||
|
dans `refresh_attached_queue_for()`. Il doit être émis à la fin du worker thread dans
|
||||||
|
`MusicQueue::schedule_sync()`, après le `sync_queue()` réussi.
|
||||||
|
|
||||||
|
Passer un `on_complete` callback à `schedule_sync()` (en plus de `on_ready`) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn schedule_sync(
|
||||||
|
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||||
|
renderer_id: &str,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||||
|
on_complete: Box<dyn Fn(usize) + Send + 'static>, // NOUVEAU — reçoit queue_length
|
||||||
|
) -> SyncScheduleOutcome
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le worker, après `Ok(())` du `sync_queue()` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Ok(()) => {
|
||||||
|
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||||
|
on_complete(queue_len);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `schedule_queue_refresh_for()` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let bus3 = event_bus.clone();
|
||||||
|
let rid3 = renderer_id.clone();
|
||||||
|
let on_complete = Box::new(move |queue_len: usize| {
|
||||||
|
bus3.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
id: rid3.clone(),
|
||||||
|
queue_length: queue_len,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 8 — Accès à `Arc<Mutex<MusicQueue>>` depuis le registry
|
||||||
|
|
||||||
|
`schedule_queue_refresh_for()` a besoin d'accéder à l'`Arc<Mutex<MusicQueue>>` du renderer.
|
||||||
|
Localiser dans `DeviceRegistry` comment les renderers et leurs queues sont stockés, et ajouter
|
||||||
|
une méthode :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn get_renderer_queue_arc(
|
||||||
|
&self,
|
||||||
|
renderer_id: &DeviceId,
|
||||||
|
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Ou équivalent selon la structure réelle du registry.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'implémentation
|
||||||
|
|
||||||
|
```
|
||||||
|
Étape 1 (errors.rs) ← 5 min
|
||||||
|
Étape 2 (backend.rs) ← 10 min, casse la compilation → à faire avant les autres
|
||||||
|
Étape 3 (interne.rs) ← 30 min
|
||||||
|
Étape 4 (openhome.rs) ← 1-2h (nombreux points de vérification cancel)
|
||||||
|
Étape 5 (music_queue.rs) ← 2-3h (changement struct + schedule_sync)
|
||||||
|
Étape 6 (model.rs + sse.rs) ← 30 min
|
||||||
|
Étape 7 (control_point.rs) ← 1h
|
||||||
|
Étape 8 (registry) ← 30 min selon la structure
|
||||||
|
```
|
||||||
|
|
||||||
|
Après l'étape 2, `cargo build` cassera jusqu'à l'étape 5 incluse — c'est attendu.
|
||||||
|
Faire les étapes 3, 4, 5 dans la même session sans interrompre.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pmocontrol
|
||||||
|
|
||||||
|
# Vérifier les scénarios :
|
||||||
|
# 1. Attach playlist → QueueRefreshing SSE immédiat, QueueReadyToPlay après 1er insert,
|
||||||
|
# QueueUpdated après fin complète
|
||||||
|
# 2. Attach 2e playlist pendant sync en cours → QueueSyncCancelled + nouveau refresh repart
|
||||||
|
# 3. Piste en cours de lecture pendant sync → QueueReadyToPlay immédiat (pivot préservé)
|
||||||
|
# 4. Refresh périodique (60s) ne bloque plus son thread
|
||||||
|
|
||||||
|
RUST_LOG=debug cargo run 2>&1 | grep -E "queue.sync|SyncCancelled|Scheduled|AlreadyRunning|on_ready|on_complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Date: 2026-04-09*
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.39"
|
version = "0.3.41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.40"
|
version = "0.3.41"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
6
pmoapp/webapp/package-lock.json
generated
6
pmoapp/webapp/package-lock.json
generated
@@ -1609,9 +1609,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const searchInput = ref('');
|
|||||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
|
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
|
||||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
if (!currentServer.value || !searchInput.value.trim()) return;
|
||||||
await searchServer(currentServer.value.id, searchInput.value.trim());
|
await searchServer(currentServer.value.id, searchInput.value.trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export function useMediaServers() {
|
|||||||
|
|
||||||
// Recherche dans un serveur
|
// Recherche dans un serveur
|
||||||
async function searchServer(serverId: string, query: string) {
|
async function searchServer(serverId: string, query: string) {
|
||||||
|
console.log(`[useMediaServers] searchServer called: serverId=${serverId}, query=${query}`);
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
searchResults.value = null
|
searchResults.value = null
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
@@ -226,8 +227,10 @@ export function useMediaServers() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
searchQuery.value = query
|
searchQuery.value = query
|
||||||
|
console.log(`[useMediaServers] Calling API searchServer for server ${serverId}`);
|
||||||
|
|
||||||
const data = await api.searchServer(serverId, query)
|
const data = await api.searchServer(serverId, query)
|
||||||
|
console.log(`[useMediaServers] Search returned ${data.entries.length} entries, total=${data.total_count}`);
|
||||||
searchResults.value = {
|
searchResults.value = {
|
||||||
container_id: 'search',
|
container_id: 'search',
|
||||||
entries: data.entries,
|
entries: data.entries,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::openapi::{
|
|||||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||||
RendererStateView,
|
RendererStateView,
|
||||||
};
|
};
|
||||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot, SyncScheduleOutcome};
|
||||||
use crate::registry::DeviceRegistry;
|
use crate::registry::DeviceRegistry;
|
||||||
|
|
||||||
/// Control point minimal :
|
/// Control point minimal :
|
||||||
@@ -267,19 +267,12 @@ impl ControlPoint {
|
|||||||
"Triggering queue refresh for bound playlist"
|
"Triggering queue refresh for bound playlist"
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(err) = refresh_attached_queue_for(
|
let _ = schedule_queue_refresh_for(
|
||||||
®istry_for_media_worker,
|
®istry_for_media_worker,
|
||||||
&renderer_id,
|
&renderer_id,
|
||||||
&event_bus_for_media_worker,
|
&event_bus_for_media_worker,
|
||||||
None,
|
None,
|
||||||
) {
|
);
|
||||||
warn!(
|
|
||||||
renderer = renderer_id.0.as_str(),
|
|
||||||
server = server_id.0.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Failed to refresh queue from playlist container"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MediaServerEvent::Online { server_id, info } => {
|
MediaServerEvent::Online { server_id, info } => {
|
||||||
@@ -337,18 +330,12 @@ impl ControlPoint {
|
|||||||
"Periodic refresh triggered for bound playlist"
|
"Periodic refresh triggered for bound playlist"
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(err) = refresh_attached_queue_for(
|
let _ = schedule_queue_refresh_for(
|
||||||
®istry_for_periodic,
|
®istry_for_periodic,
|
||||||
&renderer_id,
|
&renderer_id,
|
||||||
&event_bus_for_periodic,
|
&event_bus_for_periodic,
|
||||||
None,
|
None,
|
||||||
) {
|
);
|
||||||
warn!(
|
|
||||||
renderer = renderer_id.0.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Periodic refresh failed for bound playlist"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -1182,19 +1169,25 @@ impl ControlPoint {
|
|||||||
binding.auto_play_on_refresh = auto_play;
|
binding.auto_play_on_refresh = auto_play;
|
||||||
renderer.set_playlist_binding(Some(binding));
|
renderer.set_playlist_binding(Some(binding));
|
||||||
|
|
||||||
let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid);
|
let callback: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
|
||||||
if auto_play {
|
if auto_play {
|
||||||
Some(&mut auto_start_cb)
|
let reg = Arc::clone(&self.registry);
|
||||||
|
Some(Box::new(move |rid: &DeviceId| {
|
||||||
|
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(||
|
||||||
|
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||||
|
)?;
|
||||||
|
renderer.play_current_from_queue()
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
return refresh_attached_queue_for(
|
let _ = schedule_queue_refresh_for(
|
||||||
&self.registry,
|
&self.registry,
|
||||||
renderer_id,
|
renderer_id,
|
||||||
&self.event_bus,
|
&self.event_bus,
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
|
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
|
||||||
@@ -1247,21 +1240,25 @@ impl ControlPoint {
|
|||||||
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
|
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
|
||||||
|
|
||||||
// For initial attach with auto_play, force playback start (don't check if idle)
|
// For initial attach with auto_play, force playback start (don't check if idle)
|
||||||
let mut auto_start_cb = |rid: &DeviceId| {
|
let callback: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||||
debug!(
|
|
||||||
renderer = rid.0.as_str(),
|
|
||||||
"Attach callback: forcing playback start (not checking if idle)"
|
|
||||||
);
|
|
||||||
self.play_current_from_queue(rid)
|
|
||||||
};
|
|
||||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
|
||||||
if auto_play {
|
if auto_play {
|
||||||
Some(&mut auto_start_cb)
|
let reg = Arc::clone(&self.registry);
|
||||||
|
Some(Box::new(move |rid: &DeviceId| {
|
||||||
|
debug!(
|
||||||
|
renderer = rid.0.as_str(),
|
||||||
|
"Attach callback: forcing playback start (not checking if idle)"
|
||||||
|
);
|
||||||
|
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(||
|
||||||
|
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||||
|
)?;
|
||||||
|
renderer.play_current_from_queue()
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
|
let _ = schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detach a renderer's queue from its associated playlist container.
|
/// Detach a renderer's queue from its associated playlist container.
|
||||||
@@ -1760,6 +1757,199 @@ fn refresh_attached_queue_for(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fetch_queue_items_for(
|
||||||
|
music_server: &Arc<crate::media_server::MusicServer>,
|
||||||
|
container_id: &str,
|
||||||
|
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
|
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
||||||
|
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
||||||
|
const BROWSE_PAGE_SIZE: u32 = 64;
|
||||||
|
|
||||||
|
let entries = {
|
||||||
|
let mut all_entries = Vec::new();
|
||||||
|
let mut offset = 0u32;
|
||||||
|
loop {
|
||||||
|
let mut attempt = 1;
|
||||||
|
let page = loop {
|
||||||
|
match music_server.browse_children(container_id, offset, BROWSE_PAGE_SIZE) {
|
||||||
|
Ok(e) => break e,
|
||||||
|
Err(err) => {
|
||||||
|
if attempt >= MAX_BROWSE_ATTEMPTS {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(
|
||||||
|
BROWSE_RETRY_DELAY_MS * attempt as u64,
|
||||||
|
));
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let fetched = page.len() as u32;
|
||||||
|
all_entries.extend(page);
|
||||||
|
if fetched < BROWSE_PAGE_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += fetched;
|
||||||
|
}
|
||||||
|
all_entries
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_items: Vec<PlaybackItem> = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(new_items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_queue_refresh_for(
|
||||||
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
|
renderer_id: &DeviceId,
|
||||||
|
event_bus: &RendererEventBus,
|
||||||
|
after_refresh: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
|
||||||
|
) -> SyncScheduleOutcome {
|
||||||
|
// Step 1: Get renderer from registry
|
||||||
|
let renderer = {
|
||||||
|
let reg = registry.read().unwrap();
|
||||||
|
reg.get_renderer(renderer_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer = match renderer {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"schedule_queue_refresh_for: renderer not found"
|
||||||
|
);
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's a binding
|
||||||
|
let (server_id, container_id) = {
|
||||||
|
let binding = match renderer.get_playlist_binding() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"schedule_queue_refresh_for: no binding present"
|
||||||
|
);
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(binding.server_id.clone(), binding.container_id.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Get server from registry
|
||||||
|
let music_server = {
|
||||||
|
let reg = registry.read().unwrap();
|
||||||
|
reg.get_server(&server_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let music_server = match music_server {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
server = server_id.0.as_str(),
|
||||||
|
"schedule_queue_refresh_for: server not found in registry"
|
||||||
|
);
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !music_server.is_online() {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
server = server_id.0.as_str(),
|
||||||
|
"schedule_queue_refresh_for: server offline, skipping refresh"
|
||||||
|
);
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit QueueRefreshing
|
||||||
|
event_bus.broadcast(RendererEvent::QueueRefreshing {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the queue Arc
|
||||||
|
let queue_arc = {
|
||||||
|
let reg = registry.read().unwrap();
|
||||||
|
match reg.get_renderer_queue_arc(renderer_id) {
|
||||||
|
Some(q) => q,
|
||||||
|
None => {
|
||||||
|
warn!("schedule_queue_refresh_for: could not get queue arc");
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the pending_items_fn for re-fetching
|
||||||
|
let registry_clone = Arc::clone(registry);
|
||||||
|
let server_id_clone = server_id.clone();
|
||||||
|
let container_id_clone = container_id.clone();
|
||||||
|
let pending_fn = Box::new(move || {
|
||||||
|
let music_server = {
|
||||||
|
let reg = registry_clone.read().unwrap();
|
||||||
|
reg.get_server(&server_id_clone)
|
||||||
|
};
|
||||||
|
match music_server {
|
||||||
|
Some(s) => fetch_queue_items_for(&s, &container_id_clone),
|
||||||
|
None => Err(ControlPointError::MediaServerError("Server not found".to_string())),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build on_ready callback
|
||||||
|
let rid = renderer_id.clone();
|
||||||
|
let bus = event_bus.clone();
|
||||||
|
let after_cb = after_refresh;
|
||||||
|
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||||
|
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||||
|
if let Some(cb) = after_cb {
|
||||||
|
if let Err(e) = cb(&rid) {
|
||||||
|
warn!("auto-play callback failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build on_complete callback
|
||||||
|
let rid2 = renderer_id.clone();
|
||||||
|
let bus2 = event_bus.clone();
|
||||||
|
let on_complete = Box::new(move |queue_len: usize| {
|
||||||
|
bus2.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
id: rid2.clone(),
|
||||||
|
queue_length: queue_len,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// First fetch
|
||||||
|
let items = match fetch_queue_items_for(&music_server, &container_id) {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("schedule_queue_refresh_for: failed to fetch items: {}", e);
|
||||||
|
return SyncScheduleOutcome::Scheduled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call schedule_sync
|
||||||
|
let outcome = crate::queue::MusicQueue::schedule_sync(
|
||||||
|
&queue_arc,
|
||||||
|
&renderer_id.0,
|
||||||
|
items,
|
||||||
|
pending_fn,
|
||||||
|
on_ready,
|
||||||
|
on_complete,
|
||||||
|
);
|
||||||
|
|
||||||
|
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||||
|
event_bus.broadcast(RendererEvent::QueueSyncCancelled {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
|
fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
|
||||||
let metadata = item.metadata.as_ref();
|
let metadata = item.metadata.as_ref();
|
||||||
let title = metadata
|
let title = metadata
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ pub enum ControlPointError {
|
|||||||
SnapshotError(String),
|
SnapshotError(String),
|
||||||
#[error("Error on ControlPoint: {0}")]
|
#[error("Error on ControlPoint: {0}")]
|
||||||
ControlPoint(String),
|
ControlPoint(String),
|
||||||
|
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||||
|
SyncCancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlPointError {
|
impl ControlPointError {
|
||||||
|
|||||||
@@ -437,6 +437,12 @@ pub enum RendererEvent {
|
|||||||
QueueRefreshing {
|
QueueRefreshing {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
},
|
},
|
||||||
|
QueueReadyToPlay {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
|
QueueSyncCancelled {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
binding: Option<PlaylistBinding>,
|
binding: Option<PlaylistBinding>,
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
|
||||||
use crate::arylic_client::{
|
use crate::arylic_client::{
|
||||||
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
|
send_command_no_response, send_command_optional, send_command_required, ARYLIC_TCP_PORT,
|
||||||
send_command_required,
|
DEFAULT_TIMEOUT_SECS,
|
||||||
};
|
};
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::linkplay_client::extract_linkplay_host;
|
use crate::linkplay_client::extract_linkplay_host;
|
||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
TransportControl, VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::queue::MusicQueue;
|
use crate::queue::MusicQueue;
|
||||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
use crate::DeviceIdentity;
|
||||||
|
|
||||||
/// Raw response from Arylic MCU+PINFGET command
|
/// Raw response from Arylic MCU+PINFGET command
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -409,8 +409,16 @@ impl QueueBackend for ArylicTcpRenderer {
|
|||||||
.replace_queue(items, current_index)
|
.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
self.queue.lock().unwrap().sync_queue(items)
|
&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> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! are wrapped in sync calls using smol::block_on for compatibility with
|
//! are wrapped in sync calls using smol::block_on for compatibility with
|
||||||
//! the existing sync trait interfaces.
|
//! the existing sync trait interfaces.
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::{Arc, Mutex, Once};
|
use std::sync::{Arc, Mutex, Once};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
@@ -906,8 +907,16 @@ impl QueueBackend for ChromecastRenderer {
|
|||||||
.replace_queue(items, current_index)
|
.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
self.queue.lock().unwrap().sync_queue(items)
|
&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> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::linkplay_client::{
|
use crate::linkplay_client::{
|
||||||
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
|
build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode, LinkPlayStatus,
|
||||||
};
|
};
|
||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
TransportControl, VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::queue::MusicQueue;
|
use crate::queue::MusicQueue;
|
||||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
use crate::DeviceIdentity;
|
||||||
|
|
||||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
@@ -287,8 +287,16 @@ impl QueueBackend for LinkPlayRenderer {
|
|||||||
.replace_queue(items, current_index)
|
.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
self.queue.lock().unwrap().sync_queue(items)
|
&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> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
|||||||
@@ -976,6 +976,12 @@ impl MusicRenderer {
|
|||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current queue item without advancing.
|
/// Get the current queue item without advancing.
|
||||||
/// Returns the item and count of remaining items after current.
|
/// Returns the item and count of remaining items after current.
|
||||||
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
@@ -1372,7 +1378,16 @@ impl MusicRenderer {
|
|||||||
/// - If the current track is NOT in the new items, it's preserved as the first item
|
/// - If the current track is NOT in the new items, it's preserved as the first item
|
||||||
/// - If there's no current track, the queue is simply replaced
|
/// - If there's no current track, the queue is simply replaced
|
||||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
// Mark renderer as active for adaptive polling
|
self.sync_queue_with_callback(items, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronize the queue with new items with optional cancel token and on_ready callback.
|
||||||
|
pub fn sync_queue_with_callback(
|
||||||
|
&self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: Option<&Arc<AtomicBool>>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
{
|
{
|
||||||
let mut watched = self
|
let mut watched = self
|
||||||
.watched_state
|
.watched_state
|
||||||
@@ -1382,7 +1397,9 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut backend = self.lock_backend_for("sync_queue");
|
let mut backend = self.lock_backend_for("sync_queue");
|
||||||
backend.sync_queue(items)?;
|
let dummy_token = Arc::new(AtomicBool::new(false));
|
||||||
|
let token = cancel_token.unwrap_or(&dummy_token);
|
||||||
|
backend.sync_queue(items, token, on_ready)?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
self.emit_queue_updated();
|
self.emit_queue_updated();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2283,14 +2300,21 @@ impl QueueBackend for MusicRendererBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicRendererBackend::Upnp(r) => r.sync_queue(items),
|
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||||
MusicRendererBackend::OpenHome(r) => r.sync_queue(items),
|
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||||
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items),
|
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||||
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items),
|
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items),
|
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
|
||||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.sync_queue(items),
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
||||||
|
upnp.sync_queue(items, cancel_token, on_ready)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
@@ -182,9 +182,8 @@ impl OpenHomeRenderer {
|
|||||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
/// 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.
|
/// 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> {
|
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock().unwrap();
|
||||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
if let Some(oh_queue) = queue.as_openhome() {
|
||||||
oh_queue.track_ids()
|
oh_queue.track_ids()
|
||||||
} else {
|
} else {
|
||||||
Err(ControlPointError::QueueError(
|
Err(ControlPointError::QueueError(
|
||||||
@@ -209,9 +208,8 @@ impl OpenHomeRenderer {
|
|||||||
let insert_after = match after_id {
|
let insert_after = match after_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock().unwrap();
|
||||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
if let Some(oh_queue) = queue.as_openhome() {
|
||||||
oh_queue
|
oh_queue
|
||||||
.track_ids()?
|
.track_ids()?
|
||||||
.last()
|
.last()
|
||||||
@@ -413,7 +411,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
|||||||
|
|
||||||
// Get track ID from queue (uses cached data)
|
// 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().unwrap();
|
||||||
if let MusicQueue::OpenHome(oh_queue) = &*queue_guard_for_id {
|
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
|
||||||
match oh_queue.current_track() {
|
match oh_queue.current_track() {
|
||||||
Ok(id_opt) => track_id = id_opt,
|
Ok(id_opt) => track_id = id_opt,
|
||||||
Err(err) => debug!(
|
Err(err) => debug!(
|
||||||
@@ -696,11 +694,16 @@ impl QueueBackend for OpenHomeRenderer {
|
|||||||
.replace_queue(items, current_index)
|
.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
self.queue
|
self.queue
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||||
.sync_queue(items)
|
.sync_queue(items, cancel_token, on_ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::PlaybackState;
|
use crate::model::PlaybackState;
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
TransportControl, VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
|
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
use crate::upnp_clients::{
|
use crate::upnp_clients::{
|
||||||
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||||
@@ -318,8 +318,16 @@ impl QueueBackend for UpnpRenderer {
|
|||||||
.replace_queue(items, current_index)
|
.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
self.queue.lock().unwrap().sync_queue(items)
|
&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> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
//! - This identity is used by the sync helpers to preserve the current
|
//! - This identity is used by the sync helpers to preserve the current
|
||||||
//! track across queue rebuilds when the MediaServer content changes.
|
//! track across queue rebuilds when the MediaServer content changes.
|
||||||
|
|
||||||
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
|
||||||
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
|
|
||||||
/// High-level enqueue mode.
|
/// High-level enqueue mode.
|
||||||
///
|
///
|
||||||
@@ -107,7 +108,12 @@ pub trait QueueBackend {
|
|||||||
/// corresponds to the old current index track.
|
/// corresponds to the old current index track.
|
||||||
/// If the old current index track is absent from the new queue,
|
/// If the old current index track is absent from the new queue,
|
||||||
/// it is kept as the first item and the new items are appended after it.
|
/// it is kept as the first item and the new items are appended after it.
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Returns the item at `index`, if it exists.
|
/// Returns the item at `index`, if it exists.
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::{
|
|||||||
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||||
DeviceId, DeviceIdentity, RendererInfo,
|
DeviceId, DeviceIdentity, RendererInfo,
|
||||||
};
|
};
|
||||||
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
|
|
||||||
/// Internal/local queue implementation.
|
/// Internal/local queue implementation.
|
||||||
///
|
///
|
||||||
@@ -269,25 +270,43 @@ impl QueueBackend for InternalQueue {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
use tracing::debug;
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
if items.is_empty() {
|
if cancel_token.load(SeqCst) {
|
||||||
return self.replace_queue(Vec::new(), None);
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_current = self.current_index().ok().flatten().is_some();
|
||||||
|
if has_current {
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
let _ = self.replace_queue(Vec::new(), None);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protéger les durées des streams contre la diminution
|
|
||||||
let updated_items = self.protect_stream_durations(items);
|
let updated_items = self.protect_stream_durations(items);
|
||||||
|
|
||||||
// Récupérer l'item actuel
|
|
||||||
let current = self.current_index.and_then(|idx| {
|
let current = self.current_index.and_then(|idx| {
|
||||||
self.items
|
self.items
|
||||||
.get(idx)
|
.get(idx)
|
||||||
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
|
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((_current_idx, current_uri, current_didl_id)) = current {
|
if let Some((_current_idx, current_uri, current_didl_id)) = current {
|
||||||
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
|
|
||||||
let new_idx = updated_items
|
let new_idx = updated_items
|
||||||
.iter()
|
.iter()
|
||||||
.position(|item| item.uri == current_uri)
|
.position(|item| item.uri == current_uri)
|
||||||
@@ -298,34 +317,25 @@ impl QueueBackend for InternalQueue {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some(new_idx) = new_idx {
|
if let Some(new_idx) = new_idx {
|
||||||
// Item trouvé dans la nouvelle liste
|
self.replace_queue(updated_items, Some(new_idx))?;
|
||||||
debug!(
|
|
||||||
renderer = self.renderer_id.0.as_str(),
|
|
||||||
current_uri = current_uri.as_str(),
|
|
||||||
new_idx,
|
|
||||||
"sync_queue: current item found in new playlist"
|
|
||||||
);
|
|
||||||
self.replace_queue(updated_items, Some(new_idx))
|
|
||||||
} else {
|
} else {
|
||||||
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
|
|
||||||
// Loguer pour diagnostic
|
|
||||||
debug!(
|
|
||||||
renderer = self.renderer_id.0.as_str(),
|
|
||||||
current_uri = current_uri.as_str(),
|
|
||||||
current_didl_id = current_didl_id.as_str(),
|
|
||||||
new_items_count = updated_items.len(),
|
|
||||||
"sync_queue: current item NOT found in new playlist, preserving as first item"
|
|
||||||
);
|
|
||||||
let current_item = self.items[self.current_index.unwrap()].clone();
|
let current_item = self.items[self.current_index.unwrap()].clone();
|
||||||
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
|
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
|
||||||
new_items.push(current_item);
|
new_items.push(current_item);
|
||||||
new_items.extend(updated_items);
|
new_items.extend(updated_items);
|
||||||
self.replace_queue(new_items, Some(0))
|
self.replace_queue(new_items, Some(0))?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Pas d'item actuel
|
self.replace_queue(updated_items, None)?;
|
||||||
self.replace_queue(updated_items, None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !has_current {
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enqueue_items(
|
fn enqueue_items(
|
||||||
@@ -502,6 +512,6 @@ impl QueueFromRendererInfo for InternalQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn to_backend(self) -> MusicQueue {
|
fn to_backend(self) -> MusicQueue {
|
||||||
MusicQueue::Internal(self)
|
MusicQueue::from_internal(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,23 @@ mod snapshot;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
pub use backend::{EnqueueMode, QueueBackend};
|
pub use backend::{EnqueueMode, QueueBackend};
|
||||||
pub use music_queue::MusicQueue;
|
pub use music_queue::{MusicQueue, SyncScheduleOutcome};
|
||||||
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||||
|
|
||||||
// Internal queue implementations - not part of the public API
|
// Internal queue implementations - not part of the public API
|
||||||
pub(crate) use interne::InternalQueue;
|
pub(crate) use interne::InternalQueue;
|
||||||
pub(crate) use openhome::OpenHomeQueue;
|
pub(crate) use openhome::OpenHomeQueue;
|
||||||
|
|
||||||
use crate::{RendererInfo, errors::ControlPointError};
|
|
||||||
use crate::music_renderer::time_utils::parse_time_flexible;
|
use crate::music_renderer::time_utils::parse_time_flexible;
|
||||||
|
use crate::{errors::ControlPointError, RendererInfo};
|
||||||
|
|
||||||
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
/// 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.
|
/// 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(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||||
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
|
match (
|
||||||
|
parse_time_flexible(old_dur).ok(),
|
||||||
|
parse_time_flexible(new_dur).ok(),
|
||||||
|
) {
|
||||||
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
|
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,10 @@ 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).
|
/// 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(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||||
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
|
match (
|
||||||
|
parse_time_flexible(old_dur).ok(),
|
||||||
|
parse_time_flexible(new_dur).ok(),
|
||||||
|
) {
|
||||||
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
|
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,84 +3,297 @@ use crate::queue::{
|
|||||||
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
||||||
};
|
};
|
||||||
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering::SeqCst},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SyncScheduleOutcome {
|
||||||
|
Scheduled,
|
||||||
|
AlreadyRunning,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum MusicQueue {
|
enum MusicQueueBackend {
|
||||||
Internal(InternalQueue),
|
Internal(InternalQueue),
|
||||||
OpenHome(OpenHomeQueue),
|
OpenHome(OpenHomeQueue),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MusicQueue {
|
||||||
|
backend: MusicQueueBackend,
|
||||||
|
sync_in_progress: Arc<AtomicBool>,
|
||||||
|
sync_pending: Arc<AtomicBool>,
|
||||||
|
sync_cancel_token: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
impl MusicQueue {
|
impl MusicQueue {
|
||||||
/// Creates a queue appropriate for the given renderer.
|
/// Creates a queue appropriate for the given renderer.
|
||||||
/// This is the factory method used by QueueFromRendererInfo trait.
|
/// This is the factory method used by QueueFromRendererInfo trait.
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||||
if info.capabilities().has_oh_playlist() {
|
let backend = if info.capabilities().has_oh_playlist() {
|
||||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
|
MusicQueueBackend::OpenHome(OpenHomeQueue::from_renderer_info(info)?)
|
||||||
info,
|
|
||||||
)?))
|
|
||||||
} else {
|
} else {
|
||||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
|
MusicQueueBackend::Internal(InternalQueue::from_renderer_info(info)?)
|
||||||
info,
|
};
|
||||||
)?))
|
|
||||||
|
Ok(MusicQueue {
|
||||||
|
backend,
|
||||||
|
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a MusicQueue from an InternalQueue backend.
|
||||||
|
pub fn from_internal(queue: InternalQueue) -> MusicQueue {
|
||||||
|
MusicQueue {
|
||||||
|
backend: MusicQueueBackend::Internal(queue),
|
||||||
|
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a MusicQueue from an OpenHomeQueue backend.
|
||||||
|
pub fn from_openhome(queue: OpenHomeQueue) -> MusicQueue {
|
||||||
|
MusicQueue {
|
||||||
|
backend: MusicQueueBackend::OpenHome(queue),
|
||||||
|
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this is an OpenHome backend.
|
||||||
|
pub fn is_openhome(&self) -> bool {
|
||||||
|
matches!(self.backend, MusicQueueBackend::OpenHome(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the OpenHome queue if this is an OpenHome backend.
|
||||||
|
pub fn as_openhome(&self) -> Option<&OpenHomeQueue> {
|
||||||
|
match &self.backend {
|
||||||
|
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||||
|
MusicQueueBackend::Internal(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the OpenHome queue if this is an OpenHome backend.
|
||||||
|
pub fn as_openhome_mut(&mut self) -> Option<&mut OpenHomeQueue> {
|
||||||
|
match &mut self.backend {
|
||||||
|
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||||
|
MusicQueueBackend::Internal(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
match &self.backend {
|
||||||
|
MusicQueueBackend::Internal(q) => q.len(),
|
||||||
|
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn schedule_sync(
|
||||||
|
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||||
|
renderer_id: &str,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
pending_items_fn: Box<
|
||||||
|
dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static,
|
||||||
|
>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||||
|
on_complete: Box<dyn Fn(usize) + Send + 'static>,
|
||||||
|
) -> SyncScheduleOutcome {
|
||||||
|
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||||
|
let q = queue_arc.lock().unwrap();
|
||||||
|
(
|
||||||
|
Arc::clone(&q.sync_in_progress),
|
||||||
|
Arc::clone(&q.sync_pending),
|
||||||
|
Arc::clone(&q.sync_cancel_token),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if sync_in_progress.swap(true, SeqCst) {
|
||||||
|
sync_cancel_token.store(true, SeqCst);
|
||||||
|
sync_pending.store(true, SeqCst);
|
||||||
|
return SyncScheduleOutcome::AlreadyRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_cancel_token.store(false, SeqCst);
|
||||||
|
sync_pending.store(false, SeqCst);
|
||||||
|
|
||||||
|
let queue_arc = Arc::clone(queue_arc);
|
||||||
|
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||||
|
|
||||||
|
thread::Builder::new()
|
||||||
|
.name(thread_name)
|
||||||
|
.spawn(move || {
|
||||||
|
struct Guard(Arc<AtomicBool>);
|
||||||
|
impl Drop for Guard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(false, SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
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_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();
|
||||||
|
}
|
||||||
|
} else if real_on_ready.is_some() {
|
||||||
|
tracing::debug!(
|
||||||
|
thread = %std::thread::current().name().unwrap_or("?"),
|
||||||
|
"queue-sync: on_ready not triggered (cancelled or skipped)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
|
||||||
|
})
|
||||||
|
.expect("Failed to spawn queue-sync thread");
|
||||||
|
|
||||||
|
SyncScheduleOutcome::Scheduled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueueBackend for MusicQueue {
|
impl QueueBackend for MusicQueue {
|
||||||
// Primitives
|
// Primitives
|
||||||
fn len(&self) -> Result<usize, ControlPointError> {
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.len(),
|
MusicQueueBackend::Internal(q) => q.len(),
|
||||||
MusicQueue::OpenHome(q) => q.len(),
|
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.track_ids(),
|
MusicQueueBackend::Internal(q) => q.track_ids(),
|
||||||
MusicQueue::OpenHome(q) => q.track_ids(),
|
MusicQueueBackend::OpenHome(q) => q.track_ids(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.id_to_position(id),
|
MusicQueueBackend::Internal(q) => q.id_to_position(id),
|
||||||
MusicQueue::OpenHome(q) => q.id_to_position(id),
|
MusicQueueBackend::OpenHome(q) => q.id_to_position(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.position_to_id(id),
|
MusicQueueBackend::Internal(q) => q.position_to_id(id),
|
||||||
MusicQueue::OpenHome(q) => q.position_to_id(id),
|
MusicQueueBackend::OpenHome(q) => q.position_to_id(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.current_track(),
|
MusicQueueBackend::Internal(q) => q.current_track(),
|
||||||
MusicQueue::OpenHome(q) => q.current_track(),
|
MusicQueueBackend::OpenHome(q) => q.current_track(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.current_index(),
|
MusicQueueBackend::Internal(q) => q.current_index(),
|
||||||
MusicQueue::OpenHome(q) => q.current_index(),
|
MusicQueueBackend::OpenHome(q) => q.current_index(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
MusicQueueBackend::Internal(q) => q.queue_snapshot(),
|
||||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
MusicQueueBackend::OpenHome(q) => q.queue_snapshot(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.set_index(index),
|
MusicQueueBackend::Internal(q) => q.set_index(index),
|
||||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
MusicQueueBackend::OpenHome(q) => q.set_index(index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,30 +302,35 @@ impl QueueBackend for MusicQueue {
|
|||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
MusicQueueBackend::Internal(q) => q.replace_queue(items, current_index),
|
||||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
MusicQueueBackend::OpenHome(q) => q.replace_queue(items, current_index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
match self {
|
&mut self,
|
||||||
MusicQueue::Internal(q) => q.sync_queue(items),
|
items: Vec<PlaybackItem>,
|
||||||
MusicQueue::OpenHome(q) => q.sync_queue(items),
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
match &mut self.backend {
|
||||||
|
MusicQueueBackend::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||||
|
MusicQueueBackend::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.get_item(index),
|
MusicQueueBackend::Internal(q) => q.get_item(index),
|
||||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
MusicQueueBackend::OpenHome(q) => q.get_item(index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
MusicQueueBackend::Internal(q) => q.replace_item(index, item),
|
||||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
MusicQueueBackend::OpenHome(q) => q.replace_item(index, item),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,59 +339,59 @@ impl QueueBackend for MusicQueue {
|
|||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
mode: EnqueueMode,
|
mode: EnqueueMode,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
|
MusicQueueBackend::Internal(q) => q.enqueue_items(items, mode),
|
||||||
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
|
MusicQueueBackend::OpenHome(q) => q.enqueue_items(items, mode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized helpers
|
// Optimized helpers
|
||||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.clear_queue(),
|
MusicQueueBackend::Internal(q) => q.clear_queue(),
|
||||||
MusicQueue::OpenHome(q) => q.clear_queue(),
|
MusicQueueBackend::OpenHome(q) => q.clear_queue(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.is_empty(),
|
MusicQueueBackend::Internal(q) => q.is_empty(),
|
||||||
MusicQueue::OpenHome(q) => q.is_empty(),
|
MusicQueueBackend::OpenHome(q) => q.is_empty(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.upcoming_len(),
|
MusicQueueBackend::Internal(q) => q.upcoming_len(),
|
||||||
MusicQueue::OpenHome(q) => q.upcoming_len(),
|
MusicQueueBackend::OpenHome(q) => q.upcoming_len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
match self {
|
match &self.backend {
|
||||||
MusicQueue::Internal(q) => q.upcoming_items(),
|
MusicQueueBackend::Internal(q) => q.upcoming_items(),
|
||||||
MusicQueue::OpenHome(q) => q.upcoming_items(),
|
MusicQueueBackend::OpenHome(q) => q.upcoming_items(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.peek_current(),
|
MusicQueueBackend::Internal(q) => q.peek_current(),
|
||||||
MusicQueue::OpenHome(q) => q.peek_current(),
|
MusicQueueBackend::OpenHome(q) => q.peek_current(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.dequeue_next(),
|
MusicQueueBackend::Internal(q) => q.dequeue_next(),
|
||||||
MusicQueue::OpenHome(q) => q.dequeue_next(),
|
MusicQueueBackend::OpenHome(q) => q.dequeue_next(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match &mut self.backend {
|
||||||
MusicQueue::Internal(q) => q.append_or_init_index(items),
|
MusicQueueBackend::Internal(q) => q.append_or_init_index(items),
|
||||||
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
|
MusicQueueBackend::OpenHome(q) => q.append_or_init_index(items),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use std::usize;
|
use std::usize;
|
||||||
|
|
||||||
@@ -170,6 +170,8 @@ pub struct OpenHomeQueue {
|
|||||||
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
||||||
/// ne permet pas de les modifier directement.
|
/// ne permet pas de les modifier directement.
|
||||||
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
||||||
|
/// Cache URI by track ID for fast path matching
|
||||||
|
uri_by_id: Mutex<HashMap<u32, String>>,
|
||||||
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
||||||
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
||||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||||
@@ -178,6 +180,16 @@ pub struct OpenHomeQueue {
|
|||||||
read_list_cache: Arc<Mutex<ReadListCache>>,
|
read_list_cache: Arc<Mutex<ReadListCache>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fast path result for queue sync optimization
|
||||||
|
enum FastPathResult {
|
||||||
|
/// New items are an append to the current queue
|
||||||
|
AppendOnly { new_items: Vec<PlaybackItem> },
|
||||||
|
/// Items were deleted from the end
|
||||||
|
DeleteFromEnd { delete_ids: Vec<u32> },
|
||||||
|
/// No fast path possible - need full LCS sync
|
||||||
|
NeedFullSync,
|
||||||
|
}
|
||||||
|
|
||||||
impl OpenHomeQueue {
|
impl OpenHomeQueue {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
renderer_id: DeviceId,
|
renderer_id: DeviceId,
|
||||||
@@ -191,6 +203,7 @@ impl OpenHomeQueue {
|
|||||||
info_client,
|
info_client,
|
||||||
product_client,
|
product_client,
|
||||||
metadata_cache: Mutex::new(HashMap::new()),
|
metadata_cache: Mutex::new(HashMap::new()),
|
||||||
|
uri_by_id: Mutex::new(HashMap::new()),
|
||||||
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
||||||
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
||||||
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
|
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
|
||||||
@@ -229,6 +242,117 @@ impl OpenHomeQueue {
|
|||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||||
|
self.uri_by_id.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
|
||||||
|
///
|
||||||
|
/// This is a fast path optimization that avoids the expensive ReadList calls when:
|
||||||
|
/// 1. New items are an append to existing queue (only inserts needed)
|
||||||
|
/// 2. Items were deleted from the end (only deletes needed)
|
||||||
|
///
|
||||||
|
/// Returns `FastPathResult::NeedFullSync` if the pattern doesn't match or
|
||||||
|
/// if cache data is insufficient to verify.
|
||||||
|
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||||
|
let current_ids = match self.track_ids() {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
error = %e,
|
||||||
|
"try_fast_path: failed to get current IDs, falling back to full sync"
|
||||||
|
);
|
||||||
|
return FastPathResult::NeedFullSync;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_len = current_ids.len();
|
||||||
|
let new_len = new_items.len();
|
||||||
|
|
||||||
|
if new_len > current_len {
|
||||||
|
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||||
|
if prefix_matches {
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
current_len, new_len, "try_fast_path: detected append-only pattern"
|
||||||
|
);
|
||||||
|
return FastPathResult::AppendOnly {
|
||||||
|
new_items: new_items[current_len..].to_vec(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if new_len < current_len {
|
||||||
|
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||||
|
if prefix_matches {
|
||||||
|
let delete_ids: Vec<u32> = current_ids[new_len..].to_vec();
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
current_len,
|
||||||
|
new_len,
|
||||||
|
delete_count = delete_ids.len(),
|
||||||
|
"try_fast_path: detected delete-from-end pattern"
|
||||||
|
);
|
||||||
|
return FastPathResult::DeleteFromEnd { delete_ids };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
current_len, new_len, "try_fast_path: no fast path detected, falling back to full sync"
|
||||||
|
);
|
||||||
|
FastPathResult::NeedFullSync
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the prefix of the new items matches the current queue items.
|
||||||
|
/// Uses local metadata cache or falls back to URI comparison.
|
||||||
|
fn check_prefix_matches(&self, current_ids: &[u32], new_items: &[PlaybackItem]) -> bool {
|
||||||
|
if current_ids.len() != new_items.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata_cache = match self.metadata_cache.lock() {
|
||||||
|
Ok(cache) => cache,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri_cache = match self.uri_by_id.lock() {
|
||||||
|
Ok(cache) => cache,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (idx, new_item) in new_items.iter().enumerate() {
|
||||||
|
let current_id = current_ids[idx];
|
||||||
|
|
||||||
|
// First check if we have cached metadata for this track_id that matches
|
||||||
|
if let Some(cached_metadata) = metadata_cache.get(¤t_id) {
|
||||||
|
if let Some(cached) = cached_metadata {
|
||||||
|
// Compare using title + artist as identity (for streams)
|
||||||
|
if let Some(ref new_metadata) = new_item.metadata {
|
||||||
|
let same_title = cached.title.as_ref() == new_metadata.title.as_ref();
|
||||||
|
let same_artist = cached.artist.as_ref() == new_metadata.artist.as_ref();
|
||||||
|
if same_title && same_artist {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No metadata match - fall back to URI comparison using uri_by_id cache
|
||||||
|
if new_item.uri.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare against cached URI for this track_id
|
||||||
|
if let Some(cached_uri) = uri_cache.get(¤t_id) {
|
||||||
|
if cached_uri == &new_item.uri {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found - fall back to full sync
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
||||||
@@ -250,7 +374,7 @@ impl OpenHomeQueue {
|
|||||||
metadata: Option<crate::model::TrackMetadata>,
|
metadata: Option<crate::model::TrackMetadata>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
let track_id = self.position_to_id(index)?;
|
let track_id = self.position_to_id(index)?;
|
||||||
self.cache_metadata(track_id, metadata);
|
self.cache_metadata(track_id, metadata, "");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +384,19 @@ impl OpenHomeQueue {
|
|||||||
/// chanson donc toute durée est acceptée.
|
/// chanson donc toute durée est acceptée.
|
||||||
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
|
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
|
||||||
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
||||||
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
|
fn cache_metadata(
|
||||||
|
&self,
|
||||||
|
track_id: u32,
|
||||||
|
new_metadata: Option<crate::model::TrackMetadata>,
|
||||||
|
uri: &str,
|
||||||
|
) {
|
||||||
let mut cache = self.metadata_cache.lock().unwrap();
|
let mut cache = self.metadata_cache.lock().unwrap();
|
||||||
|
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||||
|
|
||||||
|
// Update URI cache
|
||||||
|
if !uri.is_empty() {
|
||||||
|
uri_cache.insert(track_id, uri.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier s'il y a déjà des métadonnées en cache
|
// Vérifier s'il y a déjà des métadonnées en cache
|
||||||
if let Some(cached_meta) = cache.get(&track_id) {
|
if let Some(cached_meta) = cache.get(&track_id) {
|
||||||
@@ -366,7 +501,7 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
||||||
// Mettre en cache pour éviter les oscillations sur les flux radio
|
// Mettre en cache pour éviter les oscillations sur les flux radio
|
||||||
self.cache_metadata(entry.id, fresh.clone());
|
self.cache_metadata(entry.id, fresh.clone(), entry.uri());
|
||||||
fresh
|
fresh
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -397,7 +532,7 @@ impl OpenHomeQueue {
|
|||||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
// Enregistrer les métadonnées dans le cache
|
||||||
self.cache_metadata(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||||
|
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
@@ -409,32 +544,44 @@ impl OpenHomeQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
new_items: Vec<PlaybackItem>,
|
new_items: Vec<PlaybackItem>,
|
||||||
playing_id: usize,
|
playing_id: usize,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
// Get current track IDs from OpenHome
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
let current_track_ids = self.track_ids()?;
|
let current_track_ids = self.track_ids()?;
|
||||||
|
|
||||||
// Delete everything except the currently playing item
|
|
||||||
// Using delete_id_if_exists() to handle cases where another control point
|
|
||||||
// may have already modified the playlist
|
|
||||||
for &track_id in current_track_ids.iter().rev() {
|
for &track_id in current_track_ids.iter().rev() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
if track_id as usize != playing_id {
|
if track_id as usize != playing_id {
|
||||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new items after the currently playing track
|
|
||||||
let mut previous_id = playing_id as u32;
|
let mut previous_id = playing_id as u32;
|
||||||
|
let mut first_insert_done = false;
|
||||||
for item in new_items {
|
for item in new_items {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self
|
let new_id = self
|
||||||
.playlist_client
|
.playlist_client
|
||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||||
self.cache_metadata(new_id, item.metadata);
|
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
|
|
||||||
|
if !first_insert_done && on_ready.is_some() {
|
||||||
|
first_insert_done = true;
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -442,7 +589,6 @@ impl OpenHomeQueue {
|
|||||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
|
||||||
self.invalidate_track_caches();
|
self.invalidate_track_caches();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -454,8 +600,13 @@ impl OpenHomeQueue {
|
|||||||
old_ids: &[u32],
|
old_ids: &[u32],
|
||||||
keep_flags: &[bool],
|
keep_flags: &[bool],
|
||||||
position_label: &str,
|
position_label: &str,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
if !keep_flags[idx] {
|
if !keep_flags[idx] {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -480,8 +631,11 @@ impl OpenHomeQueue {
|
|||||||
keep_old_flags: &[bool],
|
keep_old_flags: &[bool],
|
||||||
mut previous_id: u32,
|
mut previous_id: u32,
|
||||||
position_label: &str,
|
position_label: &str,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||||
) -> Result<u32, ControlPointError> {
|
) -> Result<u32, ControlPointError> {
|
||||||
// Collect IDs of kept items (in order)
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
let remaining_ids: Vec<u32> = old_ids
|
let remaining_ids: Vec<u32> = old_ids
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -489,16 +643,18 @@ impl OpenHomeQueue {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut remaining_idx = 0;
|
let mut remaining_idx = 0;
|
||||||
|
let mut first_insert_done = false;
|
||||||
|
|
||||||
// Rebuild section
|
|
||||||
for (idx, item) in new_items.iter().enumerate() {
|
for (idx, item) in new_items.iter().enumerate() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
if keep_new_flags[idx] {
|
if keep_new_flags[idx] {
|
||||||
let existing_id = remaining_ids[remaining_idx];
|
let existing_id = remaining_ids[remaining_idx];
|
||||||
remaining_idx += 1;
|
remaining_idx += 1;
|
||||||
previous_id = existing_id;
|
previous_id = existing_id;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées de l'item existant conservé
|
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
|
||||||
self.cache_metadata(existing_id, item.metadata.clone());
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -513,8 +669,7 @@ impl OpenHomeQueue {
|
|||||||
.playlist_client
|
.playlist_client
|
||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées du nouvel item
|
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
|
||||||
self.cache_metadata(new_id, item.metadata.clone());
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -526,6 +681,13 @@ impl OpenHomeQueue {
|
|||||||
new_id
|
new_id
|
||||||
);
|
);
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
|
|
||||||
|
if !first_insert_done && on_ready.is_some() {
|
||||||
|
first_insert_done = true;
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +704,8 @@ impl OpenHomeQueue {
|
|||||||
pivot_id: usize,
|
pivot_id: usize,
|
||||||
snapshot: &QueueSnapshot,
|
snapshot: &QueueSnapshot,
|
||||||
current_track_ids: &[u32],
|
current_track_ids: &[u32],
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
// Find the pivot index in our current state
|
// Find the pivot index in our current state
|
||||||
let pivot_idx = current_track_ids
|
let pivot_idx = current_track_ids
|
||||||
@@ -570,10 +734,15 @@ impl OpenHomeQueue {
|
|||||||
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
|
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
|
||||||
|
|
||||||
// Delete items marked for deletion in AFTER part (reverse order)
|
// Delete items marked for deletion in AFTER part (reverse order)
|
||||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
|
||||||
|
|
||||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||||
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
self.delete_marked_items(
|
||||||
|
&old_ids_before,
|
||||||
|
&keep_old_before,
|
||||||
|
"BEFORE pivot",
|
||||||
|
cancel_token,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||||
// Rebuild BEFORE part (we don't need the returned previous_id)
|
// Rebuild BEFORE part (we don't need the returned previous_id)
|
||||||
@@ -584,13 +753,19 @@ impl OpenHomeQueue {
|
|||||||
&keep_old_before,
|
&keep_old_before,
|
||||||
OPENHOME_PLAYLIST_HEAD_ID,
|
OPENHOME_PLAYLIST_HEAD_ID,
|
||||||
"BEFORE pivot",
|
"BEFORE pivot",
|
||||||
|
cancel_token,
|
||||||
|
on_ready,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// PIVOT keeps its ID and position - it's the anchor point
|
// PIVOT keeps its ID and position - it's the anchor point
|
||||||
let previous_id = pivot_id as u32;
|
let previous_id = pivot_id as u32;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées du pivot
|
// Mettre à jour les métadonnées du pivot
|
||||||
self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone());
|
self.cache_metadata(
|
||||||
|
previous_id,
|
||||||
|
new_items[pivot_idx_new].metadata.clone(),
|
||||||
|
&new_items[pivot_idx_new].uri,
|
||||||
|
);
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -609,6 +784,8 @@ impl OpenHomeQueue {
|
|||||||
&keep_old_after,
|
&keep_old_after,
|
||||||
previous_id,
|
previous_id,
|
||||||
"AFTER pivot",
|
"AFTER pivot",
|
||||||
|
cancel_token,
|
||||||
|
on_ready,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -630,7 +807,11 @@ impl OpenHomeQueue {
|
|||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
snapshot: &QueueSnapshot,
|
snapshot: &QueueSnapshot,
|
||||||
current_track_ids: &[u32],
|
current_track_ids: &[u32],
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
current_count = snapshot.items.len(),
|
current_count = snapshot.items.len(),
|
||||||
@@ -656,32 +837,22 @@ impl OpenHomeQueue {
|
|||||||
"LCS computed: minimizing OpenHome playlist operations"
|
"LCS computed: minimizing OpenHome playlist operations"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get current playing track ID BEFORE any modifications
|
|
||||||
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
||||||
|
|
||||||
// Check if the currently playing track is in the new playlist
|
|
||||||
// If so, we should NOT use delete_all() - we must preserve it
|
|
||||||
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
|
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.position(|item| item.backend_id as u32 == current_id)
|
.position(|item| item.backend_id as u32 == current_id)
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we're replacing everything (keep=0), use delete_all() BUT only if
|
|
||||||
// there's no currently playing track, OR if the current track is not in the new playlist.
|
|
||||||
// If current track IS in new playlist, we must preserve it using insert/delete operations.
|
|
||||||
if items_to_keep == 0 && items_to_delete > 0 {
|
if items_to_keep == 0 && items_to_delete > 0 {
|
||||||
if current_track_in_new_playlist.is_some() {
|
if current_track_in_new_playlist.is_some() {
|
||||||
// Current track is in new playlist - use insert/delete instead of delete_all
|
|
||||||
// to preserve playback
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
current_track_in_playlist = true,
|
current_track_in_playlist = true,
|
||||||
"Preserving currently playing track - using insert/delete instead of delete_all"
|
"Preserving currently playing track - using insert/delete instead of delete_all"
|
||||||
);
|
);
|
||||||
// Fall through to selective deletion below
|
|
||||||
} else {
|
} else {
|
||||||
// No current track or not in new playlist - safe to use delete_all
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
||||||
@@ -690,19 +861,18 @@ impl OpenHomeQueue {
|
|||||||
self.metadata_cache.lock().unwrap().clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Selective deletion when keeping some items
|
|
||||||
for idx in (0..current_track_ids.len()).rev() {
|
for idx in (0..current_track_ids.len()).rev() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
if !keep_current[idx] {
|
if !keep_current[idx] {
|
||||||
let track_id = current_track_ids[idx];
|
let track_id = current_track_ids[idx];
|
||||||
// Use delete_id_if_exists() to handle cases where another control point
|
|
||||||
// may have already modified the playlist
|
|
||||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild by inserting new items
|
|
||||||
let remaining_ids: Vec<u32> = current_track_ids
|
let remaining_ids: Vec<u32> = current_track_ids
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -717,8 +887,12 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
let mut remaining_idx = 0usize;
|
let mut remaining_idx = 0usize;
|
||||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||||
|
let mut first_insert_done = false;
|
||||||
|
|
||||||
for (idx, item) in items.into_iter().enumerate() {
|
for (idx, item) in items.into_iter().enumerate() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
if keep_desired[idx] {
|
if keep_desired[idx] {
|
||||||
if remaining_idx >= remaining_ids.len() {
|
if remaining_idx >= remaining_ids.len() {
|
||||||
return Err(ControlPointError::OpenHomeError(format!(
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
@@ -729,19 +903,23 @@ impl OpenHomeQueue {
|
|||||||
remaining_idx += 1;
|
remaining_idx += 1;
|
||||||
previous_id = existing_id;
|
previous_id = existing_id;
|
||||||
|
|
||||||
// Mettre à jour les métadonnées de l'item existant conservé
|
self.cache_metadata(existing_id, item.metadata, &item.uri);
|
||||||
// La fonction cache_metadata gère la protection contre la diminution de durée
|
|
||||||
self.cache_metadata(existing_id, item.metadata);
|
|
||||||
} else {
|
} else {
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self
|
let new_id = self
|
||||||
.playlist_client
|
.playlist_client
|
||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées du nouvel item
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||||
self.cache_metadata(new_id, item.metadata);
|
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
|
|
||||||
|
if !first_insert_done && on_ready.is_some() {
|
||||||
|
first_insert_done = true;
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +929,6 @@ impl OpenHomeQueue {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
|
||||||
self.invalidate_track_caches();
|
self.invalidate_track_caches();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1124,7 +1301,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
.insert(previous_id, &item.uri, &metadata)?;
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
// Enregistrer les métadonnées dans le cache
|
// Enregistrer les métadonnées dans le cache
|
||||||
self.cache_metadata(new_id, item.metadata);
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||||
|
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
}
|
}
|
||||||
@@ -1135,10 +1312,20 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
cancel_token: &Arc<AtomicBool>,
|
||||||
|
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
use std::sync::atomic::Ordering::SeqCst;
|
||||||
|
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
|
|
||||||
// DIAGNOSTIC: Log current track state before any modifications
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
let pre_current_track = self.playlist_client.id().ok();
|
let pre_current_track = self.playlist_client.id().ok();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -1154,10 +1341,8 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.lock().unwrap().clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
|
||||||
self.invalidate_all_caches();
|
self.invalidate_all_caches();
|
||||||
|
|
||||||
// DIAGNOSTIC: Log state after delete_all
|
|
||||||
let post_current_track = self.playlist_client.id().ok();
|
let post_current_track = self.playlist_client.id().ok();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -1167,9 +1352,58 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronize local state with the actual OpenHome playlist before computing
|
match self.try_fast_path(&items) {
|
||||||
// differences. Without this, any drift between our cache and the renderer
|
FastPathResult::AppendOnly { new_items } => {
|
||||||
// (e.g., manual edits from another control point) would keep the stale items.
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
new_items_count = new_items.len(),
|
||||||
|
"sync_queue: fast path - append-only detected"
|
||||||
|
);
|
||||||
|
let current_ids = self.track_ids()?;
|
||||||
|
let mut after_id = current_ids
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
|
||||||
|
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||||
|
for item in &new_items {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
let metadata = build_metadata_xml(item);
|
||||||
|
let uri = item.uri.as_str();
|
||||||
|
let metadata_xml = metadata.as_str();
|
||||||
|
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
|
||||||
|
if !item.uri.is_empty() {
|
||||||
|
uri_cache.insert(after_id, item.uri.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(uri_cache);
|
||||||
|
self.invalidate_track_caches();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
FastPathResult::DeleteFromEnd { delete_ids } => {
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
delete_count = delete_ids.len(),
|
||||||
|
"sync_queue: fast path - delete-from-end detected"
|
||||||
|
);
|
||||||
|
for id in delete_ids.iter().rev() {
|
||||||
|
if cancel_token.load(SeqCst) {
|
||||||
|
return Err(ControlPointError::SyncCancelled);
|
||||||
|
}
|
||||||
|
self.playlist_client.delete_id(*id)?;
|
||||||
|
}
|
||||||
|
self.invalidate_all_caches();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
FastPathResult::NeedFullSync => {
|
||||||
|
debug!(
|
||||||
|
renderer = self.renderer_id.0.as_str(),
|
||||||
|
"sync_queue: fast path not applicable, proceeding with full sync"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -1180,9 +1414,6 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
"sync_queue: snapshot vs new items comparison"
|
"sync_queue: snapshot vs new items comparison"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: current_index may point to an index that doesn't exist in items
|
|
||||||
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
|
|
||||||
// IDs but ReadList returns empty TrackList). We must bounds-check here.
|
|
||||||
let playing_info = snapshot.current_index.and_then(|idx| {
|
let playing_info = snapshot.current_index.and_then(|idx| {
|
||||||
if idx < snapshot.items.len() {
|
if idx < snapshot.items.len() {
|
||||||
Some((
|
Some((
|
||||||
@@ -1209,8 +1440,14 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
"OpenHome playlist state"
|
"OpenHome playlist state"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let has_pivot = playing_info.is_some();
|
||||||
|
if has_pivot {
|
||||||
|
if let Some(f) = on_ready.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
|
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
|
||||||
// Find if the currently playing item is in the new playlist (by URI first, then by didl_id)
|
|
||||||
let new_playing_idx = items
|
let new_playing_idx = items
|
||||||
.iter()
|
.iter()
|
||||||
.position(|item| item.uri == playing_uri)
|
.position(|item| item.uri == playing_uri)
|
||||||
@@ -1230,8 +1467,6 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(pivot_idx) = new_playing_idx {
|
if let Some(pivot_idx) = new_playing_idx {
|
||||||
// CASE 2: Currently playing item IS in the new playlist
|
|
||||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
playing_idx,
|
playing_idx,
|
||||||
@@ -1248,22 +1483,24 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
playing_id,
|
playing_id,
|
||||||
&snapshot,
|
&snapshot,
|
||||||
¤t_ids_for_pivot,
|
¤t_ids_for_pivot,
|
||||||
|
cancel_token,
|
||||||
|
&mut on_ready,
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
// CASE 1: Currently playing item NOT in the new playlist
|
|
||||||
// Keep it as first item and append the new playlist after it
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
playing_idx,
|
playing_idx,
|
||||||
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
||||||
);
|
);
|
||||||
|
|
||||||
self.replace_queue_preserve_current(items, playing_id)?;
|
self.replace_queue_preserve_current(
|
||||||
|
items,
|
||||||
|
playing_id,
|
||||||
|
cancel_token,
|
||||||
|
&mut on_ready,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No currently playing item or can't determine it - use standard LCS
|
|
||||||
// BUT first check if this is because the OpenHome device returned empty playlist
|
|
||||||
// This could cause the queue to be cleared incorrectly
|
|
||||||
if snapshot.items.is_empty() && !items.is_empty() {
|
if snapshot.items.is_empty() && !items.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -1271,8 +1508,6 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
new_items = items.len(),
|
new_items = items.len(),
|
||||||
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
|
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
|
||||||
);
|
);
|
||||||
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
|
|
||||||
// Instead, just add the new items without deleting existing ones
|
|
||||||
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
|
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1282,10 +1517,15 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
let current_ids_for_lcs: Vec<u32> =
|
let current_ids_for_lcs: Vec<u32> =
|
||||||
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
||||||
self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?;
|
self.replace_queue_standard_lcs(
|
||||||
|
items,
|
||||||
|
&snapshot,
|
||||||
|
¤t_ids_for_lcs,
|
||||||
|
cancel_token,
|
||||||
|
&mut on_ready,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DIAGNOSTIC: Log state after sync completes
|
|
||||||
let post_current_track = self.playlist_client.id().ok();
|
let post_current_track = self.playlist_client.id().ok();
|
||||||
let post_ids = self.track_ids();
|
let post_ids = self.track_ids();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1344,7 +1584,8 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||||
self.cache_metadata(new_id, item.metadata);
|
self.uri_by_id.lock().unwrap().remove(&track_id);
|
||||||
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||||
|
|
||||||
if ci == Some(index) {
|
if ci == Some(index) {
|
||||||
self.playlist_client.seek_id(new_id)?;
|
self.playlist_client.seek_id(new_id)?;
|
||||||
@@ -1503,6 +1744,6 @@ impl QueueFromRendererInfo for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn to_backend(self) -> MusicQueue {
|
fn to_backend(self) -> MusicQueue {
|
||||||
MusicQueue::OpenHome(self)
|
MusicQueue::from_openhome(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{DeviceId, model::TrackMetadata};
|
use crate::{model::TrackMetadata, DeviceId};
|
||||||
|
|
||||||
/// Canonical representation of a track in a renderer queue.
|
/// Canonical representation of a track in a renderer queue.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ impl DeviceRegistry {
|
|||||||
self.devices.get(id)?.as_music_renderer().ok()
|
self.devices.get(id)?.as_music_renderer().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_renderer_queue_arc(
|
||||||
|
&self,
|
||||||
|
id: &DeviceId,
|
||||||
|
) -> Option<std::sync::Arc<std::sync::Mutex<crate::queue::MusicQueue>>> {
|
||||||
|
let renderer = self.get_renderer(id)?;
|
||||||
|
Some(renderer.queue())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
||||||
self.devices.get(id)?.as_music_server().ok()
|
self.devices.get(id)?.as_music_server().ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
|
|
||||||
|
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_soap_agent() -> Arc<Agent> {
|
||||||
|
SOAP_AGENT
|
||||||
|
.get_or_init(|| {
|
||||||
|
Arc::new(
|
||||||
|
Agent::config_builder()
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.timeout_global(Some(Duration::from_secs(30)))
|
||||||
|
.build()
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a SOAP call:
|
/// Result of a SOAP call:
|
||||||
/// - HTTP status code
|
/// - HTTP status code
|
||||||
/// - raw XML body (always)
|
/// - raw XML body (always)
|
||||||
@@ -62,21 +80,20 @@ pub fn invoke_upnp_action_with_timeout(
|
|||||||
|
|
||||||
trace!(body = body_xml.as_str(), "SOAP request body");
|
trace!(body = body_xml.as_str(), "SOAP request body");
|
||||||
|
|
||||||
let mut builder = Agent::config_builder();
|
let request = if let Some(duration) = timeout {
|
||||||
builder = builder.http_status_as_error(false);
|
let config = Agent::config_builder()
|
||||||
if let Some(duration) = timeout {
|
.http_status_as_error(false)
|
||||||
builder = builder.timeout_global(Some(duration));
|
.timeout_global(Some(duration))
|
||||||
}
|
.build();
|
||||||
|
let agent: Agent = config.into();
|
||||||
|
agent.post(control_url)
|
||||||
|
} else {
|
||||||
|
get_soap_agent().post(control_url)
|
||||||
|
};
|
||||||
|
|
||||||
let config = builder.build();
|
|
||||||
let agent: Agent = config.into();
|
|
||||||
|
|
||||||
// 3. SOAPAction header
|
|
||||||
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
||||||
|
|
||||||
// 4. HTTP POST
|
let mut response = request
|
||||||
let mut response = agent
|
|
||||||
.post(control_url)
|
|
||||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||||
.header("SOAPAction", &soap_action_header)
|
.header("SOAPAction", &soap_action_header)
|
||||||
.send(body_xml)
|
.send(body_xml)
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ pub enum RendererEventPayload {
|
|||||||
renderer_id: String,
|
renderer_id: String,
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
},
|
},
|
||||||
|
QueueReadyToPlay {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
QueueSyncCancelled {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
renderer_id: String,
|
renderer_id: String,
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
@@ -293,6 +301,14 @@ async fn renderer_event_to_payload(
|
|||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
|
RendererEvent::QueueReadyToPlay { id } => RendererEventPayload::QueueReadyToPlay {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
RendererEvent::QueueSyncCancelled { id } => RendererEventPayload::QueueSyncCancelled {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.40
|
0.3.41
|
||||||
|
|||||||
Reference in New Issue
Block a user