🚀 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
This commit is contained in:
19
Blackboard/Report/enorme_playlist.md
Normal file
19
Blackboard/Report/enorme_playlist.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Rapport : Optimisation performance OpenHome playlist
|
||||
|
||||
## Résumé
|
||||
Les optimizations implementées réduisent significativement le temps de synchronisation des playlists OpenHome de ~1000 titres. Les principales améliorations : passage du batch ReadList de 64 à 256 (−75% appels SOAP), elimination des doubles appels queue_snapshot() (−50% appels SOAP), et introduction du polling adaptatif avec intervalle long en veille (5s vs 500ms).
|
||||
|
||||
## Fichiers modifies
|
||||
|
||||
1. `pmocontrol/src/queue/openhome.rs`
|
||||
- Batch ReadList augmente de 64 a 256
|
||||
- Signature de replace_queue_with_pivot et replace_queue_standard_lcs modifiee pour accepter snapshot et current_track_ids
|
||||
- Appel a sync_queue mis a jour pour passer les donnees deja disponibles
|
||||
-Nouvelle fonction lcs_flags_optimized avec elimination pre/suffixe communs
|
||||
|
||||
2. `pmocontrol/src/music_renderer/watcher.rs`
|
||||
- Ajout du champ is_active dans WatchedState pour le polling adaptatif
|
||||
|
||||
3. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Boucle watcher avec intervalle adaptatif (500ms actif, 5000ms veille)
|
||||
- Marqueurs is_active=true dans play(), stop(), seek_rel_time(), sync_queue()
|
||||
426
Blackboard/Todo/enorme_playlist.md
Normal file
426
Blackboard/Todo/enorme_playlist.md
Normal file
@@ -0,0 +1,426 @@
|
||||
** 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<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 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<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) :
|
||||
```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 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<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 :
|
||||
|
||||
```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 = ¤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)
|
||||
Reference in New Issue
Block a user