Merge pull request 'push-uplrprqrknlm' (#90) from push-uplrprqrknlm into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m33s
Reviewed-on: #90
This commit was merged in pull request #90.
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)
|
||||
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,338 @@
|
||||
** 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 frontend Vue.js du control point PMOMusic (`pmoapp/webapp`) présente des problèmes de
|
||||
performance significatifs avec des playlists de ~1000 titres. La plupart des lenteurs sont
|
||||
côté UI, indépendamment des optimisations déjà réalisées côté Rust/backend.
|
||||
|
||||
Les trois manifestations observées :
|
||||
- **Freeze au scroll** dans la file d'attente (QueueViewer) dès ~200 items
|
||||
- **Blocage UI temporaire** à l'ouverture d'une playlist dans PlayListManager
|
||||
- **Refetches JSON répétés** déclenchés par les événements SSE `queue_updated`
|
||||
|
||||
## Causes racines identifiées
|
||||
|
||||
### P0 — Pas de virtualisation dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:85-93`
|
||||
|
||||
```vue
|
||||
<!-- Tous les items rendus en DOM simultanément -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
...
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Pour 1 000 titres : 1 000 nœuds DOM permanents, chacun contenant une image, un composable
|
||||
réactif (`useCoverImage`), et des computed properties. Le scroll devient impossible.
|
||||
|
||||
**Ironie** : `vue-virtual-scroller@^2.0.0-beta.8` est présent dans `package.json` mais
|
||||
**n'est utilisé nulle part** dans la codebase — manifestement prévu puis abandonné.
|
||||
|
||||
### P1 — Pas de virtualisation dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue:562-724`
|
||||
|
||||
```vue
|
||||
<div class="track-grid" v-if="sortedTracks.length > 0">
|
||||
<article v-for="track in sortedTracks" :key="..." class="track-card">
|
||||
<!-- image + title + artist + album + durée + bitrate + samplerate -->
|
||||
</article>
|
||||
</div>
|
||||
```
|
||||
|
||||
Même problème : grid CSS avec 1 000 articles et leurs images (`loading="lazy"`).
|
||||
|
||||
Aggravé par la computed `sortedTracks` (ligne 883-890) :
|
||||
```typescript
|
||||
const sortedTracks = computed(() => {
|
||||
return [...detail.tracks].sort( // copie complète du tableau
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
});
|
||||
```
|
||||
Et `lazyTracksCount` (ligne 892-897) qui filtre les 1 000 items à chaque re-render.
|
||||
|
||||
### P2 — `queue_updated` force un refetch JSON complet
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
```typescript
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
```
|
||||
|
||||
`fetchRendererSnapshot()` appelle `api.getRendererFullSnapshot(rendererId)` qui retourne
|
||||
le snapshot complet incluant **tous les items de la queue avec leurs métadonnées**.
|
||||
|
||||
Pour 1 000 titres, le payload JSON peut atteindre plusieurs centaines de Ko. Si l'utilisateur
|
||||
charge une playlist de 1 000 titres depuis un serveur qui émet les items par batch (ex. 64
|
||||
par 64 côté Rust), l'événement `queue_updated` est émis plusieurs fois de suite, déclenchant
|
||||
autant de refetches consécutifs du même JSON complet.
|
||||
|
||||
**Note** : `loadingIds.has(rendererId)` (ligne 354) déduplique les requêtes simultanées,
|
||||
mais pas les requêtes consécutives rapprochées.
|
||||
|
||||
### P3 — Infinite scroll accumule toutes les pages en mémoire (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:200-204`
|
||||
|
||||
```typescript
|
||||
// Accumuler les nouvelles entrées — jamais purgées
|
||||
state.entries.push(...data.entries)
|
||||
```
|
||||
|
||||
En parcourant un serveur contenant 1 000 titres, toutes les pages de 50 items
|
||||
s'accumulent dans `browseCache` sans jamais être libérées. Résultat : après un scroll
|
||||
complet, 1 000 entrées sont en mémoire ET en DOM simultanément.
|
||||
|
||||
### P4 — `scrollIntoView` sur 1 000 nœuds DOM non virtualisés
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:27-48`
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(".queue-item.current");
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
`querySelector` sur un conteneur de 1 000 nœuds + animation CSS `smooth` provoque un
|
||||
layout thrashing. Ce watcher est aussi déclenché au montage (`immediate: true`), ce qui
|
||||
peut provoquer un re-layout au chargement initial de la page.
|
||||
|
||||
## Ce qui fonctionne déjà correctement
|
||||
|
||||
- **Déduplication des snapshots simultanés** : `loadingIds.has(rendererId)` évite les
|
||||
requêtes parallèles pour le même renderer — à préserver.
|
||||
- **`loading="lazy"` sur les images** dans PlayListManager — efficace une fois que le DOM
|
||||
est virtualisé.
|
||||
- **Cache du browse** avec invalidation par conteneur SSE — architecture correcte.
|
||||
- **`useCoverImage`** avec retry exponentiel et cleanup — à conserver tel quel dans
|
||||
`QueueItem.vue`.
|
||||
- **Connexion SSE unique** partagée entre tous les composants — bonne architecture.
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Répertoire concerné : `pmoapp/webapp`
|
||||
|
||||
---
|
||||
|
||||
### Étape 1 — Virtualiser la file d'attente dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue`
|
||||
|
||||
`vue-virtual-scroller` est déjà installé. Remplacer le `v-for` nu par `<RecycleScroller>` :
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
// ... imports existants inchangés
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Remplacer le div.queue-list + v-for par : -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
>
|
||||
<QueueItem
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
```
|
||||
|
||||
`item-size="64"` correspond à la hauteur CSS actuelle de `.queue-item` (padding +
|
||||
cover 48px + gap). À ajuster si le CSS change.
|
||||
|
||||
**Adapter `scrollIntoView`** : `RecycleScroller` expose une méthode `scrollToItem(index)`.
|
||||
Remplacer le `querySelector` + `scrollIntoView` par :
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick();
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : `QueueItem.vue` reste inchangé — `RecycleScroller` recycle
|
||||
les nœuds DOM au lieu de les créer tous, mais les props passées à chaque item sont
|
||||
identiques.
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 — Débouncer les refetches `queue_updated`
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
Le problème : `queue_updated` arrive N fois de suite pendant le chargement d'une grande
|
||||
playlist, déclenchant N refetches.
|
||||
|
||||
Ajouter un debounce par renderer sur l'appel à `fetchRendererSnapshot` :
|
||||
|
||||
```typescript
|
||||
// Map des timers de debounce par renderer (à déclarer en module scope)
|
||||
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Dans le case "queue_updated" :
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
break;
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : Si un seul `queue_updated` arrive (cas normal), le refetch
|
||||
est simplement retardé de 300 ms — imperceptible. Si N arrivent en rafale (chargement
|
||||
d'une grande playlist), un seul refetch est déclenché à la fin.
|
||||
|
||||
**Contrainte** : Ne pas dépasser 500 ms de debounce — l'indicateur `queueRefreshing` dans
|
||||
l'UI doit se désactiver rapidement après la fin du chargement.
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 — Virtualiser la grille dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue`
|
||||
|
||||
La grille CSS ne peut pas être virtualisée directement avec `RecycleScroller` (liste 1D).
|
||||
Remplacer la grid par une liste virtualisée, ou introduire une pagination côté client :
|
||||
|
||||
```typescript
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Avec des boutons de navigation Précédent / Suivant et un indicateur de page.
|
||||
|
||||
**Optimiser `sortedTracks`** : mémoriser le résultat par `playlist.id` pour éviter
|
||||
la copie+tri à chaque re-render non lié à la playlist :
|
||||
|
||||
```typescript
|
||||
const sortedTracksCache = new Map<string, TrackEntry[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
const cached = sortedTracksCache.get(detail.id);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
sortedTracksCache.set(detail.id, sorted);
|
||||
return sorted;
|
||||
});
|
||||
```
|
||||
|
||||
**Simplifier `lazyTracksCount`** : le dériver de `sortedTracks` pour ne pas parcourir
|
||||
le tableau original en parallèle :
|
||||
|
||||
```typescript
|
||||
const lazyTracksCount = computed(() =>
|
||||
sortedTracks.value.filter(isLazyTrack).length
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 — Limiter l'accumulation dans le browse infini (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:183-212`
|
||||
|
||||
Implémenter une fenêtre glissante dans `browseCache` : conserver seulement les 200
|
||||
derniers items en mémoire :
|
||||
|
||||
```typescript
|
||||
const BROWSE_WINDOW_SIZE = 200;
|
||||
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
// ... code existant jusqu'à la récupération de data ...
|
||||
|
||||
// Remplacer : state.entries.push(...data.entries)
|
||||
// Par :
|
||||
const combined = [...state.entries, ...data.entries];
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE);
|
||||
state.total_count = data.total_count;
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length;
|
||||
state.hasMore = state.currentOffset < state.total_count;
|
||||
browseCache.value.set(key, { ...state });
|
||||
}
|
||||
```
|
||||
|
||||
**Invariant à préserver** : `state.currentOffset` et `state.hasMore` doivent continuer
|
||||
de refléter la position réelle dans la liste serveur, indépendamment de ce qui est
|
||||
affiché — leur logique ne change pas.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution
|
||||
|
||||
1. **Étape 1** — Virtualisation `QueueViewer` (impact le plus visible, composant le plus simple)
|
||||
2. **Étape 2** — Debounce `queue_updated` (élimine les refetches en cascade, changement minimal)
|
||||
3. **Étape 3** — Optimisation `PlayListManager` (plus complexe, composant de 2039 lignes)
|
||||
4. **Étape 4** — Fenêtre glissante `MediaBrowser` (amélioration mémoire, moins critique)
|
||||
|
||||
## Périmètre : ce qui ne change pas
|
||||
|
||||
- `QueueItem.vue` : aucune modification (recycling géré par le parent)
|
||||
- `useCoverImage.ts` : aucune modification (lazy loading + retry déjà corrects)
|
||||
- `useSSE.ts` : aucune modification (connexion unique, bonne architecture)
|
||||
- `loadingIds` dans `fetchRendererSnapshot` : déduplication conservée
|
||||
- `api.getRendererFullSnapshot` : le payload reste complet, pas de pagination API
|
||||
- Tous les événements SSE autres que `queue_updated` : aucune modification
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
Demander à l'humain de :
|
||||
|
||||
```bash
|
||||
cd pmoapp/webapp
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Puis tester manuellement :
|
||||
- Ouvrir la file d'attente d'un renderer OpenHome avec 1 000 titres : scroll fluide ?
|
||||
- Vérifier que la piste courante est visible au changement de piste (`scrollToItem`)
|
||||
- Charger une playlist de 1 000 titres via PlayListManager : absence de blocage ?
|
||||
- Observer les requêtes réseau dans DevTools lors du chargement d'une grande playlist :
|
||||
un seul `GET /renderers/{id}/full` doit être émis après la fin du chargement
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.39"
|
||||
version = "0.3.40"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
20
Report/frontend_enorme_playlist.md
Normal file
20
Report/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Rapport : Optimisation performances frontend playlists ~1000 titres
|
||||
|
||||
## Résumé
|
||||
Implémentation de quatre optimisations pour traiter les lenteurs UI observées avec de grandes playlists : virtualisation de la file d'attente avec RecycleScroller, debounce des refetches queue_updated, pagination + cache dans PlayListManager, et fenêtre glissante dans MediaBrowser. Ces changements éliminent les freezes au scroll, bloquages UI et refetches JSON répétés.
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
|
||||
- Remplacement du v-for natif par RecycleScroller de vue-virtual-scroller
|
||||
- Migration de querySelector+scrollIntoView vers scrollToItem() exposé par RecycleScroller
|
||||
|
||||
2. `pmoapp/webapp/src/composables/useRenderers.ts`
|
||||
- Ajout d'un debounce de 300ms sur les refetches queue_updated pour éviter les refetches en cascade
|
||||
|
||||
3. `pmoapp/webapp/src/components/PlayListManager.vue`
|
||||
- Ajout pagination client (100 items/page) avec navigation Précédent/Suivant
|
||||
-Ajout cache mémorisé pour sortedTracks par playlist ID
|
||||
- Simplification lazyTracksCount derivé de sortedTracks
|
||||
|
||||
4. `pmoapp/webapp/src/composables/useMediaServers.ts`
|
||||
- Ajout fenêtre glissante limitant le cache browse aux 200 derniers items
|
||||
@@ -559,12 +559,24 @@
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
<div v-if="totalPages > 1" class="pagination-controls">
|
||||
<button @click="prevPage" :disabled="currentPage === 0">
|
||||
← Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage + 1 }} of {{ totalPages }}
|
||||
</span>
|
||||
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="track-grid"
|
||||
v-if="sortedTracks.length > 0"
|
||||
v-if="paginatedTracks.length > 0"
|
||||
>
|
||||
<article
|
||||
v-for="track in sortedTracks"
|
||||
v-for="track in paginatedTracks"
|
||||
:key="`${track.cache_pk}-${track.added_at}`"
|
||||
class="track-card"
|
||||
:class="{ lazy: isLazyTrack(track) }"
|
||||
@@ -880,20 +892,51 @@ const sortedPlaylists = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
return [...detail.tracks].sort(
|
||||
const playlistId = detail.summary.id;
|
||||
const cached = sortedTracksCache.get(playlistId);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
||||
);
|
||||
sortedTracksCache.set(playlistId, sorted);
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value - 1) {
|
||||
currentPage.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 0) {
|
||||
currentPage.value--;
|
||||
}
|
||||
}
|
||||
|
||||
watch(sortedTracks, () => {
|
||||
currentPage.value = 0;
|
||||
});
|
||||
|
||||
const lazyTracksCount = computed(() =>
|
||||
selectedPlaylist.value
|
||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
||||
.length
|
||||
: 0,
|
||||
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
||||
);
|
||||
|
||||
const updateCoverPreview = computed(
|
||||
@@ -1881,6 +1924,40 @@ button:disabled {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.pagination-controls button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-controls button:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import QueueItem from "./QueueItem.vue";
|
||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
||||
@@ -17,7 +19,7 @@ const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "
|
||||
|
||||
const isAttached = computed(() => !!binding.value);
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null);
|
||||
const queueContainer = ref<any>(null);
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit("clickItem", item);
|
||||
@@ -33,15 +35,7 @@ watch(
|
||||
queueContainer.value
|
||||
) {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(
|
||||
".queue-item.current",
|
||||
);
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -81,16 +75,23 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des items -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<!-- Liste des items virtualisée -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
:min-item-size="64"
|
||||
>
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</RecycleScroller>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else class="queue-empty">
|
||||
@@ -105,6 +106,8 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
@@ -196,20 +199,11 @@ watch(
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
|
||||
.queue-list::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 80px; /* Espace pour la barre fixe en bas */
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
@@ -142,6 +142,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
@@ -153,6 +154,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Colonne gauche - Contrôles */
|
||||
@@ -185,11 +187,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-viewer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||
@@ -349,8 +354,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
@media (min-width: 1200px) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 350px 1fr;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.browser-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
const BROWSE_WINDOW_SIZE = 200
|
||||
|
||||
// Initialiser SSE une seule fois via le composable centralisé
|
||||
let sseInitialized = false
|
||||
@@ -197,11 +198,13 @@ export function useMediaServers() {
|
||||
const offset = state.currentOffset ?? state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
// Accumuler les nouvelles entrées avec fenêtre glissante
|
||||
const combined = [...state.entries, ...data.entries]
|
||||
// Ne garder que les últimos BROWSE_WINDOW_SIZE items
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE)
|
||||
state.total_count = data.total_count
|
||||
state.currentOffset = state.entries.length
|
||||
state.hasMore = state.entries.length < state.total_count
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
|
||||
state.hasMore = state.currentOffset < state.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
|
||||
@@ -27,6 +27,10 @@ const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Debounce pour les refetches queue_updated
|
||||
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
@@ -201,8 +205,15 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
break;
|
||||
|
||||
case "binding_changed":
|
||||
|
||||
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'vue-virtual-scroller' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
export interface RecycleScrollerProps {
|
||||
items?: any[];
|
||||
itemSize?: number;
|
||||
keyField?: string;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
minItemSize?: number;
|
||||
sizeField?: string;
|
||||
typeField?: string;
|
||||
buffer?: number;
|
||||
pageMode?: boolean;
|
||||
prerender?: number;
|
||||
}
|
||||
|
||||
export interface RecycleScrollerRef {
|
||||
scrollToItem(index: number): void;
|
||||
scrollToOffset(offset: number): void;
|
||||
$el: HTMLElement;
|
||||
}
|
||||
|
||||
export const RecycleScroller: DefineComponent<RecycleScrollerProps>;
|
||||
}
|
||||
@@ -270,6 +270,7 @@ const currentTabProps = computed(() => {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
@@ -278,6 +279,7 @@ const currentTabProps = computed(() => {
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Placeholder temporaire */
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pmodidl::DIDLLite;
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use pmoupnp::soap::error_codes;
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use tracing::{debug, warn};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::errors::ControlPointError;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||
use crate::queue::PlaybackItem;
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
||||
use crate::soap_client::{invoke_upnp_action_with_timeout, SoapCallResult};
|
||||
use crate::{DeviceId, DeviceIdentity, DEFAULT_HTTP_TIMEOUT};
|
||||
|
||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -91,7 +91,9 @@ impl UpnpMediaServer {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
|
||||
Ok(self
|
||||
.browse_with_flag_paged(object_id, browse_flag, start, count)?
|
||||
.entries)
|
||||
}
|
||||
|
||||
fn browse_with_flag_paged(
|
||||
@@ -109,17 +111,23 @@ impl UpnpMediaServer {
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
// Tri par titre (alphabétique croissant) pour avoir un ordre déterministe
|
||||
("SortCriteria", "+dc:title".to_string()),
|
||||
];
|
||||
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
|
||||
ControlPointError::MediaServerError(
|
||||
"Missing SOAP envelope in Browse response".to_string(),
|
||||
)
|
||||
})?;
|
||||
let total_count = extract_total_matches(&envelope, "BrowseResponse");
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
let entries = map_didl_entries(&didl_xml)?;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
Ok(BrowsePage {
|
||||
entries,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_content_directory(&self) -> bool {
|
||||
@@ -398,7 +406,10 @@ pub trait MediaBrowser {
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let entries = self.browse_children(object_id, start, count)?;
|
||||
let total_count = entries.len() as u32 + start;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
Ok(BrowsePage {
|
||||
entries,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||
fn search(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use pmodidl::{DIDLLite, MediaMetadataParser};
|
||||
use tracing::{debug, error};
|
||||
@@ -312,17 +312,37 @@ impl MusicRenderer {
|
||||
|
||||
/// Main loop for the watcher thread.
|
||||
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
||||
let Some(interval) = strategy.polling_interval() else {
|
||||
let Some(base_interval) = strategy.polling_interval() else {
|
||||
// Pure push strategy - no polling needed (future implementation)
|
||||
return;
|
||||
};
|
||||
|
||||
let short_interval = base_interval;
|
||||
let long_interval = Duration::from_millis(5_000);
|
||||
let inactivity_threshold = Duration::from_secs(10);
|
||||
|
||||
let mut tick: u32 = 0;
|
||||
let mut next_poll_time = SystemTime::now();
|
||||
let mut last_activity_time = SystemTime::now();
|
||||
|
||||
while !stop_flag.load(Ordering::SeqCst) {
|
||||
let is_active = {
|
||||
let watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active
|
||||
};
|
||||
|
||||
let interval = if is_active {
|
||||
short_interval
|
||||
} else {
|
||||
long_interval
|
||||
};
|
||||
|
||||
if self.is_online() {
|
||||
self.poll_and_emit_changes(tick);
|
||||
last_activity_time = SystemTime::now();
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
@@ -341,6 +361,17 @@ impl MusicRenderer {
|
||||
);
|
||||
next_poll_time = SystemTime::now();
|
||||
}
|
||||
|
||||
// Reset is_active flag if no activity for threshold
|
||||
if let Ok(elapsed) = SystemTime::now().duration_since(last_activity_time) {
|
||||
if elapsed >= inactivity_threshold {
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
@@ -1012,6 +1043,15 @@ impl MusicRenderer {
|
||||
/// Démarre ou reprend la lecture. Si une queue non vide existe,
|
||||
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
// Vérifier si on a une queue non vide
|
||||
let backend = self.lock_backend_for("play");
|
||||
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
||||
@@ -1034,6 +1074,15 @@ impl MusicRenderer {
|
||||
/// Transport control: stop
|
||||
#[track_caller]
|
||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
// Reset the has_played flag when stopping playback.
|
||||
// This ensures that if we start a new track, the flag will be false
|
||||
// until PLAYING state is observed, preventing auto-advance on
|
||||
@@ -1067,6 +1116,15 @@ impl MusicRenderer {
|
||||
|
||||
/// Transport control: seek to relative time
|
||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
self.lock_backend_for("seek_rel_time").seek_rel_time(hhmmss)
|
||||
}
|
||||
|
||||
@@ -1314,6 +1372,15 @@ impl MusicRenderer {
|
||||
/// - 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
|
||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
let mut backend = self.lock_backend_for("sync_queue");
|
||||
backend.sync_queue(items)?;
|
||||
drop(backend);
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct WatchedState {
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
/// Last known stream state (continuous stream vs bounded media)
|
||||
pub is_stream: Option<bool>,
|
||||
/// Flag indicating if renderer was active recently (used for adaptive polling)
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -65,25 +65,7 @@ impl InternalQueue {
|
||||
/// Vérifie si une durée a diminué (format HH:MM:SS).
|
||||
/// Retourne true si new_duration < old_duration.
|
||||
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(old_secs), Some(new_secs)) =
|
||||
(parse_duration(old_duration), parse_duration(new_duration))
|
||||
{
|
||||
new_secs < old_secs
|
||||
} else {
|
||||
false // Impossible de parser: considérer que ça n'a pas diminué
|
||||
}
|
||||
super::stream_duration_decreased(old_duration, new_duration)
|
||||
}
|
||||
|
||||
/// Protège les durées des streams contre la diminution.
|
||||
@@ -164,45 +146,23 @@ impl InternalQueue {
|
||||
// Même chanson sur un stream: vérifier que la durée n'a pas diminué
|
||||
let should_update = match (&old_meta.duration, &new_meta.duration) {
|
||||
(Some(old_dur), Some(new_dur)) => {
|
||||
// Parser les durées (format HH:MM:SS)
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(old_secs), Some(new_secs)) =
|
||||
(parse_duration(old_dur), parse_duration(new_dur))
|
||||
{
|
||||
if new_secs < old_secs {
|
||||
// Durée a diminué: garder l'ancienne
|
||||
tracing::trace!(
|
||||
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
||||
if super::stream_duration_decreased(old_dur, new_dur) {
|
||||
tracing::trace!(
|
||||
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
if super::stream_duration_increased(old_dur, new_dur) {
|
||||
tracing::debug!(
|
||||
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
// Durée a augmenté ou est égale: accepter
|
||||
if new_secs > old_secs {
|
||||
tracing::debug!(
|
||||
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// Impossible de parser: accepter par défaut
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,24 @@ pub(crate) use interne::InternalQueue;
|
||||
pub(crate) use openhome::OpenHomeQueue;
|
||||
|
||||
use crate::{RendererInfo, errors::ControlPointError};
|
||||
use crate::music_renderer::time_utils::parse_time_flexible;
|
||||
|
||||
/// 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.
|
||||
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()) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait QueueFromRendererInfo {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||
|
||||
@@ -218,6 +218,19 @@ impl OpenHomeQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
|
||||
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
|
||||
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();
|
||||
}
|
||||
|
||||
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
||||
///
|
||||
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
|
||||
@@ -274,45 +287,23 @@ impl OpenHomeQueue {
|
||||
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
||||
) {
|
||||
(Some(cached_dur), Some(new_dur)) => {
|
||||
// Parser les durées (format HH:MM:SS)
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(cached_secs), Some(new_secs)) =
|
||||
(parse_duration(cached_dur), parse_duration(new_dur))
|
||||
{
|
||||
if new_secs < cached_secs {
|
||||
// Durée a diminué pour la même chanson: refuser
|
||||
tracing::trace!(
|
||||
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||
if super::stream_duration_decreased(cached_dur, new_dur) {
|
||||
tracing::trace!(
|
||||
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
if super::stream_duration_increased(cached_dur, new_dur) {
|
||||
tracing::debug!(
|
||||
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
// Durée a augmenté ou est égale: accepter
|
||||
if new_secs > cached_secs {
|
||||
tracing::debug!(
|
||||
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// Impossible de parser: accepter par défaut
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -452,8 +443,7 @@ impl OpenHomeQueue {
|
||||
);
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -550,11 +540,9 @@ impl OpenHomeQueue {
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Find the pivot index in our current state
|
||||
let pivot_idx = current_track_ids
|
||||
.iter()
|
||||
@@ -576,10 +564,10 @@ impl OpenHomeQueue {
|
||||
let new_after = &new_items[pivot_idx_new + 1..];
|
||||
|
||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
||||
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
||||
let (keep_old_after, keep_new_after) = lcs_flags_optimized(&old_after, new_after);
|
||||
|
||||
// LCS on the BEFORE part (using fresh data from OpenHome)
|
||||
let (keep_old_before, keep_new_before) = lcs_flags(&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)
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
||||
@@ -631,8 +619,7 @@ impl OpenHomeQueue {
|
||||
);
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -641,12 +628,9 @@ impl OpenHomeQueue {
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_current_index: Option<usize>,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_count = snapshot.items.len(),
|
||||
@@ -658,7 +642,7 @@ impl OpenHomeQueue {
|
||||
"LCS input: current vs desired items"
|
||||
);
|
||||
|
||||
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
|
||||
let (keep_current, keep_desired) = lcs_flags_optimized(&snapshot.items, &items);
|
||||
|
||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
|
||||
@@ -768,8 +752,7 @@ impl OpenHomeQueue {
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -883,6 +866,52 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>,
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
fn lcs_flags_optimized(
|
||||
current: &[PlaybackItem],
|
||||
desired: &[PlaybackItem],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
if current.is_empty() {
|
||||
return (vec![], vec![true; desired.len()]);
|
||||
}
|
||||
if desired.is_empty() {
|
||||
return (vec![true; current.len()], vec![]);
|
||||
}
|
||||
|
||||
let leading = current
|
||||
.iter()
|
||||
.zip(desired.iter())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
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().saturating_sub(trailing)];
|
||||
let d_mid = &d_tail[..d_tail.len().saturating_sub(trailing)];
|
||||
|
||||
if c_mid.is_empty() && d_mid.is_empty() {
|
||||
return (vec![true; current.len()], vec![true; desired.len()]);
|
||||
}
|
||||
|
||||
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeQueue {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.track_ids()?.len())
|
||||
@@ -983,7 +1012,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
||||
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_BATCH: usize = 256;
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(MAX_BATCH) {
|
||||
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||
@@ -1056,9 +1085,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.playlist_client.stop()?;
|
||||
}
|
||||
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1082,9 +1109,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
@@ -1105,8 +1130,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
}
|
||||
|
||||
// Invalidate cache after insertions
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1131,9 +1155,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
// DIAGNOSTIC: Log state after delete_all
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
@@ -1218,7 +1240,15 @@ impl QueueBackend for OpenHomeQueue {
|
||||
pivot_idx
|
||||
);
|
||||
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
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,
|
||||
)?;
|
||||
} else {
|
||||
// CASE 1: Currently playing item NOT in the new playlist
|
||||
// Keep it as first item and append the new playlist after it
|
||||
@@ -1250,7 +1280,9 @@ impl QueueBackend for OpenHomeQueue {
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"No currently playing item, using standard LCS sync"
|
||||
);
|
||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
||||
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)?;
|
||||
}
|
||||
|
||||
// DIAGNOSTIC: Log state after sync completes
|
||||
@@ -1319,8 +1351,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1365,8 +1396,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1378,9 +1408,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
// Invalidate caches after clearing playlist (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,12 @@ impl ContentHandler {
|
||||
items.len()
|
||||
);
|
||||
|
||||
// Trier les containers par titre pour ordre déterministe (alphabétique, insensible à la casse)
|
||||
// Les HashMap/HashSet utilisés pour la déduplication ne garantissent pas l'ordre,
|
||||
// et les APIs externes peuvent renvoyer dans un ordre non-déterministe
|
||||
// On NE trie PAS les items (pistes) car l'ordre original (numéro de piste) est important
|
||||
containers.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
// Calculer le total avant pagination
|
||||
let total = (containers.len() + items.len()) as u32;
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.39
|
||||
0.3.40
|
||||
|
||||
Reference in New Issue
Block a user