- Augmente le batch ReadList de 64 à 256 (-75% appels SOAP) - Élimine les doubles appel à queue_snapshot() dans sync_queue()–50% appels SOAP en moins - Introduit lcs_flags_optimized() pour élaguer préfixe/suffixes communs (LCS O(n) vs quadratique) - Met en place un polling adaptatif : 500ms actif / 5s veille - Factorise parse_duration() et invalidation de caches via méthodes utilitaires
15 KiB
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier @Rules_optimal.md **
Contexte et symptôme
Le control point PMOMusic est lent lorsqu'un renderer OpenHome manipule des playlists
d'environ 1 000 titres. Les renderers Chromecast et UPnP pur ne sont pas affectés : ils
utilisent une InternalQueue entièrement locale, sans appels SOAP. Le problème est
spécifique à OpenHomeQueue (pmocontrol/src/queue/openhome.rs).
Le code a été généré par IA : il peut contenir des redondances, mais chaque comportement est intentionnel. L'objectif est d'optimiser sans rien supprimer.
Causes racines identifiées
P0 — Double appel à queue_snapshot() dans sync_queue()
Fichiers : pmocontrol/src/queue/openhome.rs
sync_queue() (ligne 1151) appelle queue_snapshot() pour obtenir l'état courant.
Puis elle délègue à l'une de ces deux sous-fonctions qui appellent à nouveau
queue_snapshot() :
replace_queue_with_pivot()(ligne 555) : 2e appelqueue_snapshot()+ 1 appeltrack_ids()séparé (alors quequeue_snapshot()appelle déjàtrack_ids()en interne)replace_queue_standard_lcs()(ligne 647) : 2e appelqueue_snapshot()
Seule replace_queue_preserve_current() n'a pas ce défaut (elle appelle uniquement
track_ids()).
Impact pour 1 000 titres :
Chaque queue_snapshot() exécute :
- 1 appel SOAP
IdArray(liste des IDs) - 16 appels SOAP
ReadList(lots de 64 items)
Soit 34 appels SOAP pour une seule opération sync_queue() au lieu de 17.
Le cache ReadList (TTL 500 ms) atténue partiellement mais ne supprime pas le problème
car la durée d'un sync_queue sur 1 000 titres peut dépasser 500 ms.
P1 — Algorithme LCS de complexité quadratique O(m × n)
Fichier : pmocontrol/src/queue/openhome.rs:851
La fonction lcs_flags() alloue une table DP de taille (m+1) × (n+1) :
let mut dp = vec![vec![0u32; n + 1]; m + 1];
Pour 1 000 titres en entrée : 1 000 × 1 000 = 1 000 000 entrées (≈ 4 MB), et
1 000 000 comparaisons. Elle est appelée jusqu'à 3 fois dans un seul sync_queue :
- 2 fois dans
replace_queue_with_pivot()(avant et après le pivot, lignes 579 et 582) - 1 fois dans
replace_queue_standard_lcs()(ligne 661)
Dans le cas courant (ajout de titres en fin de liste, ou liste déjà synchronisée), la quasi-totalité de la table DP est inutile : les préfixe et suffixe communs représentent souvent 90 % ou plus de la liste.
P2 — Taille de lot ReadList = 64
Fichier : pmocontrol/src/queue/openhome.rs:986
const MAX_BATCH: usize = 64;
Pour 1 000 titres : 1 000 ÷ 64 = 16 appels SOAP ReadList par queue_snapshot().
La latence réseau typique par appel SOAP (50–200 ms) implique 0,8 à 3,2 secondes
uniquement pour la lecture des métadonnées.
La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est conservatrice. Augmenter à 256 réduit à 4 appels (−75 %).
Le mécanisme de fallback one-by-one (lignes 1007–1019) assure la rétrocompatibilité avec les devices qui refuseraient un payload plus large.
P3 — Polling à 500 ms indépendant de l'activité
Fichier : pmocontrol/src/music_renderer/watcher.rs
Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille.
Avec plusieurs renderers actifs, les appels de polling et les opérations sync_queue
se chevauchent sur le même device réseau, créant de la contention.
P4 — Redondances de code (nettoyage conservatif)
a. Invalidation des caches dupliquée (openhome.rs)
La séquence d'invalidation apparaît en 3 endroits distincts (lignes 1134–1136, 454–456, 634–635) :
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
// parfois aussi :
self.current_track_id_cache.lock().unwrap().invalidate();
b. Protection durée stream dupliquée (openhome.rs et interne.rs)
La logique de protection de durée pour les flux continus (radio) est implémentée :
- Dans
cache_metadata()deOpenHomeQueue(openhome.rs:250–351) - Dans
protect_stream_durations()deInternalQueue(interne.rs:92–143) - Dans
merge_metadata_protecting_streams()deInternalQueue(interne.rs:148–215)
c. parse_duration() défini 3 fois
La conversion HH:MM:SS → secondes apparaît dans openhome.rs, interne.rs,
et dans time_utils::parse_hhmmss_u32() (déjà publique).
Ce qui fonctionne déjà correctement
Pagination du Browse : La boucle de pagination est correctement implémentée dans
control_point.rs:1610–1653 avec browse_children() + offset incrémental.
Fallback ReadList one-by-one : Si un batch échoue, le retry unitaire (lignes 1007–1019) assure la robustesse sur les devices stricts.
Protection multi-control-point : delete_id_if_exists() gère proprement le cas où
un autre control point a déjà supprimé un titre.
Stratégie double-LCS avec pivot : La logique de replace_queue_with_pivot() est
correcte et importante pour ne pas interrompre la lecture en cours.
Cache métadonnées stream : La protection de durée décroissante pour les flux radio est un comportement essentiel à préserver scrupuleusement.
Plan d'exécution
Crate concernée : pmocontrol
Étape 1 — Augmenter le batch ReadList à 256
Fichier : pmocontrol/src/queue/openhome.rs:986
// Avant
const MAX_BATCH: usize = 64;
// Après
const MAX_BATCH: usize = 256;
Le fallback one-by-one (lignes 1007–1019) reste intact. Si un renderer refuse un payload de 256 IDs, il retombe automatiquement sur le mode unitaire.
Étape 2 — Éliminer le double appel à queue_snapshot()
Fichier : pmocontrol/src/queue/openhome.rs
Le snapshot calculé dans sync_queue() contient déjà les items et leurs IDs
backend (backend_id: usize). Il n'est pas nécessaire de le recalculer dans les
sous-fonctions.
2a. Passer le snapshot à replace_queue_with_pivot()
Signature actuelle (ligne 548) :
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
) -> Result<(), ControlPointError>
Nouvelle signature :
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids())
) -> Result<(), ControlPointError>
À l'intérieur de replace_queue_with_pivot(), supprimer :
// Supprimer ces deux lignes (ligne 555–556)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
Et utiliser directement les paramètres snapshot et current_track_ids.
Appel depuis sync_queue() (ligne 1221) :
// Avant
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
// Après — passer le snapshot et les IDs déjà disponibles
let current_ids_for_pivot: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, ¤t_ids_for_pivot)?;
Note importante : dans sync_queue(), le snapshot est pris APRÈS
ensure_playlist_source_selected() (ligne 1115) et APRÈS la résolution du playing_info.
Cet ordre est correct et doit être conservé.
2b. Passer le snapshot à replace_queue_standard_lcs()
Signature actuelle (ligne 641) :
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
_current_index: Option<usize>,
) -> Result<(), ControlPointError>
Nouvelle signature :
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté
) -> Result<(), ControlPointError>
À l'intérieur, supprimer :
// Supprimer ces deux lignes (lignes 647–648)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
Appel depuis sync_queue() (ligne 1253) :
// Avant
self.replace_queue_standard_lcs(items, Some(0))?;
// Après
let current_ids_for_lcs: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?;
Cas particulier à préserver (ligne 1237–1246) : le guard sur snapshot.items.is_empty()
dans sync_queue() est exécuté avant l'appel à replace_queue_standard_lcs, donc
le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé.
Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs
Fichier : pmocontrol/src/queue/openhome.rs
La fonction lcs_flags() (ligne 851) reste inchangée. L'optimisation s'applique
aux appels dans replace_queue_with_pivot() et replace_queue_standard_lcs().
Principe
Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue :
/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs
/// avant d'appeler l'algorithme DP O(m×n).
///
/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n),
/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements.
fn lcs_flags_optimized(
current: &[PlaybackItem],
desired: &[PlaybackItem],
) -> (Vec<bool>, Vec<bool>) {
// Préfixe commun
let leading = current
.iter()
.zip(desired.iter())
.take_while(|(c, d)| items_match(c, d))
.count();
// Suffixe commun (sur les portions restantes uniquement)
let c_tail = ¤t[leading..];
let d_tail = &desired[leading..];
let trailing = c_tail
.iter()
.rev()
.zip(d_tail.iter().rev())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_mid = &c_tail[..c_tail.len() - trailing];
let d_mid = &d_tail[..d_tail.len() - trailing];
// Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout)
if c_mid.is_empty() && d_mid.is_empty() {
return (vec![true; current.len()], vec![true; desired.len()]);
}
// LCS DP sur le delta central uniquement
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
// Reconstituer les vecteurs complets
let mut keep_current = vec![true; leading];
keep_current.extend(keep_c_mid);
keep_current.extend(vec![true; trailing]);
let mut keep_desired = vec![true; leading];
keep_desired.extend(keep_d_mid);
keep_desired.extend(vec![true; trailing]);
(keep_current, keep_desired)
}
Remplacer les 3 appels à lcs_flags() (lignes 579, 582, 661) par lcs_flags_optimized().
La fonction lcs_flags() originale est conservée (utilisée en interne par
lcs_flags_optimized()).
Étape 4 — Polling adaptatif selon l'activité
Fichier : pmocontrol/src/music_renderer/watcher.rs
Ajouter un flag partagé is_active dans MusicRenderer (ou WatchedState) pour
signaler si le renderer est en activité récente.
Le renderer met is_active = true lors de chaque opération (play, sync, seek, stop).
Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité.
// Dans la boucle du watcher :
let interval = if is_active.load(Ordering::Relaxed) {
Duration::from_millis(500)
} else {
Duration::from_millis(5_000)
};
thread::sleep(interval);
Fonctionnalités à préserver :
- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable
- Sleep timer countdown : reste actif au polling suivant
- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe, pas par le polling — non affectée
Étape 5 — Consolider les redondances (nettoyage conservatif)
À réaliser uniquement après validation fonctionnelle des étapes 1–4.
5a. Méthode invalidate_all_caches() sur OpenHomeQueue
fn invalidate_all_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
}
fn invalidate_track_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
}
Remplacer les séquences d'invalidation en 3 endroits (lignes 1134–1136, 454–456, 634–635). Garder les appels sélectifs là où seulement 2 caches sont invalidés.
5b. Factoriser parse_duration()
Supprimer les définitions locales de parse_duration dans openhome.rs et interne.rs.
Utiliser crate::music_renderer::time_utils::parse_hhmmss_u32() (déjà publique).
La sémantique est identique : conversion HH:MM:SS → u64 secondes.
5c. Factoriser la protection durée stream
Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux
continu pour le même titre/artiste ») dans une fonction privée dans openhome.rs,
et y référencer depuis interne.rs via le module queue.
Règle absolue : ne pas modifier la sémantique de détection de stream continu
(is_continuous_stream_url()) ni la logique de comparaison titre/artiste. Uniquement
factoriser le code existant.
Ordre d'exécution
- Étape 1 — Batch ReadList 256 (changement trivial, gain immédiat −75 % appels)
- Étape 2 — Élimination double
queue_snapshot()(−50 % appels SOAP totaux) - Étape 3 — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n))
- Étape 4 — Polling adaptatif (réduction contention réseau en veille)
- Étape 5 — Consolidation redondances (nettoyage, après validation)
Périmètre : ce qui ne change pas
- La logique à 3 cas de
sync_queue()(avec pivot, préserver courant, LCS standard) - La protection durée décroissante pour les flux radio (cache stream)
- Le mécanisme
delete_id_if_exists()pour la robustesse multi-control-point - Le fallback
ReadListone-by-one en cas d'erreur batch - La pagination Browse dans
control_point.rs(déjà correcte) - Le comportement des queues
InternalQueue(Chromecast, UPnP) — non affectées - Les TTL des caches existants (1 s, 500 ms, 250 ms)
- Tous les logs de diagnostic (
tracing::warn!,debug!) — à conserver
Tests recommandés
Demander à l'humain de compiler et tester :
cargo build -p pmocontrol
Puis tester avec un renderer OpenHome physique :
- Playlist de 1 000 titres : mesurer le temps de
sync_queueavant/après - Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions
- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue
- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste
- Renderer Chromecast : vérifier l'absence de régression (queue interne)