** 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) ** ## 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 appel `queue_snapshot()` + 1 appel `track_ids()` séparé (alors que `queue_snapshot()` appelle déjà `track_ids()` en interne) - `replace_queue_standard_lcs()` (ligne 647) : 2e appel `queue_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)` : ```rust 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` ```rust 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) : ```rust 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()` de `OpenHomeQueue` (`openhome.rs:250–351`) - Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92–143`) - Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`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` ```rust // 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) : ```rust fn replace_queue_with_pivot( &mut self, new_items: Vec, pivot_idx_new: usize, pivot_id: usize, ) -> Result<(), ControlPointError> ``` Nouvelle signature : ```rust fn replace_queue_with_pivot( &mut self, new_items: Vec, 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 : ```rust // 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) : ```rust // 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 = 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) : ```rust fn replace_queue_standard_lcs( &mut self, items: Vec, _current_index: Option, ) -> Result<(), ControlPointError> ``` Nouvelle signature : ```rust fn replace_queue_standard_lcs( &mut self, items: Vec, snapshot: &QueueSnapshot, // ← ajouté current_track_ids: &[u32], // ← ajouté ) -> Result<(), ControlPointError> ``` À l'intérieur, supprimer : ```rust // 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) : ```rust // Avant self.replace_queue_standard_lcs(items, Some(0))?; // Après let current_ids_for_lcs: Vec = 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 : ```rust /// 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, Vec) { // 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é. ```rust // 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` ```rust 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 1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat −75 % appels) 2. **Étape 2** — Élimination double `queue_snapshot()` (−50 % appels SOAP totaux) 3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n)) 4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille) 5. **É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 `ReadList` one-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_queue` avant/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)