Files
pmomusic/Blackboard/Todo/enorme_playlist.md
Eric Coissac 22b3a67417 🚀 Optimise OpenHome playlist sync performance (-75% SOAP calls)
- 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
2026-04-06 17:12:46 +02:00

427 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
** 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 (50200 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 10071019) 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 11341136,
454456, 634635) :
```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:250351`)
- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92143`)
- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148215`)
**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:16101653` avec `browse_children()` + offset incrémental.
**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 10071019)
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 10071019) 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<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
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 :
```rust
// Supprimer ces deux lignes (ligne 555556)
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<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, &current_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<PlaybackItem>,
_current_index: Option<usize>,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté
) -> Result<(), ControlPointError>
```
À l'intérieur, supprimer :
```rust
// Supprimer ces deux lignes (lignes 647648)
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<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_standard_lcs(items, &snapshot, &current_ids_for_lcs)?;
```
**Cas particulier à préserver** (ligne 12371246) : 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<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 = &current[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 14.**
#### 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 11341136, 454456, 634635).
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)