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]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.39"
|
version = "0.3.40"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="track-grid"
|
class="track-grid"
|
||||||
v-if="sortedTracks.length > 0"
|
v-if="paginatedTracks.length > 0"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
v-for="track in sortedTracks"
|
v-for="track in paginatedTracks"
|
||||||
:key="`${track.cache_pk}-${track.added_at}`"
|
:key="`${track.cache_pk}-${track.added_at}`"
|
||||||
class="track-card"
|
class="track-card"
|
||||||
:class="{ lazy: isLazyTrack(track) }"
|
: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 sortedTracks = computed(() => {
|
||||||
const detail = selectedPlaylist.value;
|
const detail = selectedPlaylist.value;
|
||||||
if (!detail) return [];
|
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) =>
|
(a, b) =>
|
||||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
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(() =>
|
const lazyTracksCount = computed(() =>
|
||||||
selectedPlaylist.value
|
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
||||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
|
||||||
.length
|
|
||||||
: 0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCoverPreview = computed(
|
const updateCoverPreview = computed(
|
||||||
@@ -1881,6 +1924,40 @@ button:disabled {
|
|||||||
margin-bottom: var(--spacing-sm);
|
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 {
|
.track-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
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 { useRenderer } from "@/composables/useRenderers";
|
||||||
import QueueItem from "./QueueItem.vue";
|
import QueueItem from "./QueueItem.vue";
|
||||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
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 isAttached = computed(() => !!binding.value);
|
||||||
|
|
||||||
const queueContainer = ref<HTMLElement | null>(null);
|
const queueContainer = ref<any>(null);
|
||||||
|
|
||||||
function handleItemClick(item: QueueItemType) {
|
function handleItemClick(item: QueueItemType) {
|
||||||
emit("clickItem", item);
|
emit("clickItem", item);
|
||||||
@@ -33,15 +35,7 @@ watch(
|
|||||||
queueContainer.value
|
queueContainer.value
|
||||||
) {
|
) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const currentItem = queueContainer.value.querySelector(
|
queueContainer.value.scrollToItem(currentIndex);
|
||||||
".queue-item.current",
|
|
||||||
);
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "nearest",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -81,16 +75,23 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste des items -->
|
<!-- Liste des items virtualisée -->
|
||||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
<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
|
<QueueItem
|
||||||
v-for="item in queue.items"
|
|
||||||
:key="item.index"
|
|
||||||
:item="item"
|
:item="item"
|
||||||
:is-current="item.index === queue.current_index"
|
:is-current="item.index === queue.current_index"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</RecycleScroller>
|
||||||
|
|
||||||
<!-- État vide -->
|
<!-- État vide -->
|
||||||
<div v-else class="queue-empty">
|
<div v-else class="queue-empty">
|
||||||
@@ -105,6 +106,8 @@ watch(
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-header {
|
.queue-header {
|
||||||
@@ -196,20 +199,11 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-list {
|
.queue-list {
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding-right: var(--spacing-xs);
|
padding-right: var(--spacing-xs);
|
||||||
}
|
min-width: 0;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||||
@@ -153,6 +154,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Colonne gauche - Contrôles */
|
/* Colonne gauche - Contrôles */
|
||||||
@@ -185,11 +187,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-viewer {
|
.queue-viewer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||||
@@ -349,8 +354,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
.renderer-layout {
|
.renderer-layout {
|
||||||
grid-template-columns: 350px 1fr;
|
grid-template-columns: 350px 1fr;
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
|||||||
/* Large desktop */
|
/* Large desktop */
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
.browser-section {
|
.browser-section {
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const searchResults = ref<BrowseState | null>(null)
|
|||||||
const searchQuery = ref<string>('')
|
const searchQuery = ref<string>('')
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000
|
const CACHE_DURATION_MS = 2000
|
||||||
|
const BROWSE_WINDOW_SIZE = 200
|
||||||
|
|
||||||
// Initialiser SSE une seule fois via le composable centralisé
|
// Initialiser SSE une seule fois via le composable centralisé
|
||||||
let sseInitialized = false
|
let sseInitialized = false
|
||||||
@@ -197,11 +198,13 @@ export function useMediaServers() {
|
|||||||
const offset = state.currentOffset ?? state.entries.length
|
const offset = state.currentOffset ?? state.entries.length
|
||||||
const data = await api.browseContainer(serverId, containerId, offset)
|
const data = await api.browseContainer(serverId, containerId, offset)
|
||||||
|
|
||||||
// Accumuler les nouvelles entrées
|
// Accumuler les nouvelles entrées avec fenêtre glissante
|
||||||
state.entries.push(...data.entries)
|
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.total_count = data.total_count
|
||||||
state.currentOffset = state.entries.length
|
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
|
||||||
state.hasMore = state.entries.length < state.total_count
|
state.hasMore = state.currentOffset < state.total_count
|
||||||
// Forcer la réactivité
|
// Forcer la réactivité
|
||||||
browseCache.value.set(key, { ...state })
|
browseCache.value.set(key, { ...state })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const loadingIds = reactive(new Set<string>());
|
|||||||
const queueRefreshingIds = reactive(new Set<string>());
|
const queueRefreshingIds = reactive(new Set<string>());
|
||||||
const selectedRendererId = ref<string | null>(null);
|
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)
|
// Cache des renderers (summary)
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||||
const RENDERERS_CACHE_MS = 2000;
|
const RENDERERS_CACHE_MS = 2000;
|
||||||
@@ -201,8 +205,15 @@ function ensureSSEInitialized() {
|
|||||||
snapshot.state.queue_len = event.queue_length;
|
snapshot.state.queue_len = event.queue_length;
|
||||||
queueRefreshingIds.delete(rendererId);
|
queueRefreshingIds.delete(rendererId);
|
||||||
|
|
||||||
// Pour la queue complète, on doit refetch
|
// 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 });
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
|
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "binding_changed":
|
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;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
@@ -278,6 +279,7 @@ const currentTabProps = computed(() => {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Placeholder temporaire */
|
/* Placeholder temporaire */
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use pmodidl::DIDLLite;
|
use pmodidl::DIDLLite;
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
|
||||||
use pmoupnp::soap::error_codes;
|
use pmoupnp::soap::error_codes;
|
||||||
|
use pmoupnp::soap::SoapEnvelope;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ use crate::errors::ControlPointError;
|
|||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||||
use crate::queue::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
use crate::soap_client::{invoke_upnp_action_with_timeout, SoapCallResult};
|
||||||
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
use crate::{DeviceId, DeviceIdentity, DEFAULT_HTTP_TIMEOUT};
|
||||||
|
|
||||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -91,7 +91,9 @@ impl UpnpMediaServer {
|
|||||||
start: u32,
|
start: u32,
|
||||||
count: u32,
|
count: u32,
|
||||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
) -> 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(
|
fn browse_with_flag_paged(
|
||||||
@@ -109,17 +111,23 @@ impl UpnpMediaServer {
|
|||||||
("Filter", "*".to_string()),
|
("Filter", "*".to_string()),
|
||||||
("StartingIndex", start_str),
|
("StartingIndex", start_str),
|
||||||
("RequestedCount", count_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 response = self.invoke_content_directory("Browse", None, args)?;
|
||||||
let envelope = response.envelope.ok_or_else(|| {
|
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 total_count = extract_total_matches(&envelope, "BrowseResponse");
|
||||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||||
let entries = map_didl_entries(&didl_xml)?;
|
let entries = map_didl_entries(&didl_xml)?;
|
||||||
Ok(BrowsePage { entries, total_count })
|
Ok(BrowsePage {
|
||||||
|
entries,
|
||||||
|
total_count,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_content_directory(&self) -> bool {
|
fn has_content_directory(&self) -> bool {
|
||||||
@@ -398,7 +406,10 @@ pub trait MediaBrowser {
|
|||||||
) -> Result<BrowsePage, ControlPointError> {
|
) -> Result<BrowsePage, ControlPointError> {
|
||||||
let entries = self.browse_children(object_id, start, count)?;
|
let entries = self.browse_children(object_id, start, count)?;
|
||||||
let total_count = entries.len() as u32 + start;
|
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 browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||||
fn search(
|
fn search(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::SystemTime;
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use pmodidl::{DIDLLite, MediaMetadataParser};
|
use pmodidl::{DIDLLite, MediaMetadataParser};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
@@ -312,17 +312,37 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Main loop for the watcher thread.
|
/// Main loop for the watcher thread.
|
||||||
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
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)
|
// Pure push strategy - no polling needed (future implementation)
|
||||||
return;
|
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 tick: u32 = 0;
|
||||||
let mut next_poll_time = SystemTime::now();
|
let mut next_poll_time = SystemTime::now();
|
||||||
|
let mut last_activity_time = SystemTime::now();
|
||||||
|
|
||||||
while !stop_flag.load(Ordering::SeqCst) {
|
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() {
|
if self.is_online() {
|
||||||
self.poll_and_emit_changes(tick);
|
self.poll_and_emit_changes(tick);
|
||||||
|
last_activity_time = SystemTime::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
tick = tick.wrapping_add(1);
|
tick = tick.wrapping_add(1);
|
||||||
@@ -341,6 +361,17 @@ impl MusicRenderer {
|
|||||||
);
|
);
|
||||||
next_poll_time = SystemTime::now();
|
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!(
|
debug!(
|
||||||
@@ -1012,6 +1043,15 @@ impl MusicRenderer {
|
|||||||
/// Démarre ou reprend la lecture. Si une queue non vide existe,
|
/// 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).
|
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
||||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
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
|
// Vérifier si on a une queue non vide
|
||||||
let backend = self.lock_backend_for("play");
|
let backend = self.lock_backend_for("play");
|
||||||
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
||||||
@@ -1034,6 +1074,15 @@ impl MusicRenderer {
|
|||||||
/// Transport control: stop
|
/// Transport control: stop
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
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.
|
// Reset the has_played flag when stopping playback.
|
||||||
// This ensures that if we start a new track, the flag will be false
|
// This ensures that if we start a new track, the flag will be false
|
||||||
// until PLAYING state is observed, preventing auto-advance on
|
// until PLAYING state is observed, preventing auto-advance on
|
||||||
@@ -1067,6 +1116,15 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Transport control: seek to relative time
|
/// Transport control: seek to relative time
|
||||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
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)
|
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 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
|
/// - If there's no current track, the queue is simply replaced
|
||||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
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");
|
let mut backend = self.lock_backend_for("sync_queue");
|
||||||
backend.sync_queue(items)?;
|
backend.sync_queue(items)?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ pub struct WatchedState {
|
|||||||
pub metadata: Option<TrackMetadata>,
|
pub metadata: Option<TrackMetadata>,
|
||||||
/// Last known stream state (continuous stream vs bounded media)
|
/// Last known stream state (continuous stream vs bounded media)
|
||||||
pub is_stream: Option<bool>,
|
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).
|
/// Vérifie si une durée a diminué (format HH:MM:SS).
|
||||||
/// Retourne true si new_duration < old_duration.
|
/// Retourne true si new_duration < old_duration.
|
||||||
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
|
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
|
||||||
let parse_duration = |dur: &str| -> Option<u32> {
|
super::stream_duration_decreased(old_duration, new_duration)
|
||||||
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é
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Protège les durées des streams contre la diminution.
|
/// Protège les durées des streams contre la diminution.
|
||||||
@@ -164,24 +146,7 @@ impl InternalQueue {
|
|||||||
// Même chanson sur un stream: vérifier que la durée n'a pas diminué
|
// 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) {
|
let should_update = match (&old_meta.duration, &new_meta.duration) {
|
||||||
(Some(old_dur), Some(new_dur)) => {
|
(Some(old_dur), Some(new_dur)) => {
|
||||||
// Parser les durées (format HH:MM:SS)
|
if super::stream_duration_decreased(old_dur, new_dur) {
|
||||||
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!(
|
tracing::trace!(
|
||||||
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
||||||
uri,
|
uri,
|
||||||
@@ -190,8 +155,7 @@ impl InternalQueue {
|
|||||||
);
|
);
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
// Durée a augmenté ou est égale: accepter
|
if super::stream_duration_increased(old_dur, new_dur) {
|
||||||
if new_secs > old_secs {
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
||||||
uri,
|
uri,
|
||||||
@@ -201,10 +165,6 @@ impl InternalQueue {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Impossible de parser: accepter par défaut
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => true, // Pas de durée ou une seule des deux: accepter
|
_ => true, // Pas de durée ou une seule des deux: accepter
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ pub(crate) use interne::InternalQueue;
|
|||||||
pub(crate) use openhome::OpenHomeQueue;
|
pub(crate) use openhome::OpenHomeQueue;
|
||||||
|
|
||||||
use crate::{RendererInfo, errors::ControlPointError};
|
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 {
|
pub trait QueueFromRendererInfo {
|
||||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
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é.
|
/// 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,
|
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
|
||||||
@@ -274,24 +287,7 @@ impl OpenHomeQueue {
|
|||||||
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
||||||
) {
|
) {
|
||||||
(Some(cached_dur), Some(new_dur)) => {
|
(Some(cached_dur), Some(new_dur)) => {
|
||||||
// Parser les durées (format HH:MM:SS)
|
if super::stream_duration_decreased(cached_dur, new_dur) {
|
||||||
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!(
|
tracing::trace!(
|
||||||
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||||
track_id,
|
track_id,
|
||||||
@@ -300,8 +296,7 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
// Durée a augmenté ou est égale: accepter
|
if super::stream_duration_increased(cached_dur, new_dur) {
|
||||||
if new_secs > cached_secs {
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||||
track_id,
|
track_id,
|
||||||
@@ -311,10 +306,6 @@ impl OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Impossible de parser: accepter par défaut
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => true, // Pas de durée ou une seule des deux: accepter
|
_ => true, // Pas de durée ou une seule des deux: accepter
|
||||||
};
|
};
|
||||||
@@ -452,8 +443,7 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -550,11 +540,9 @@ impl OpenHomeQueue {
|
|||||||
new_items: Vec<PlaybackItem>,
|
new_items: Vec<PlaybackItem>,
|
||||||
pivot_idx_new: usize,
|
pivot_idx_new: usize,
|
||||||
pivot_id: usize,
|
pivot_id: usize,
|
||||||
|
snapshot: &QueueSnapshot,
|
||||||
|
current_track_ids: &[u32],
|
||||||
) -> Result<(), ControlPointError> {
|
) -> 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
|
// Find the pivot index in our current state
|
||||||
let pivot_idx = current_track_ids
|
let pivot_idx = current_track_ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -576,10 +564,10 @@ impl OpenHomeQueue {
|
|||||||
let new_after = &new_items[pivot_idx_new + 1..];
|
let new_after = &new_items[pivot_idx_new + 1..];
|
||||||
|
|
||||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
// 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)
|
// 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)
|
// Delete items marked for deletion in AFTER part (reverse order)
|
||||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
||||||
@@ -631,8 +619,7 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -641,12 +628,9 @@ impl OpenHomeQueue {
|
|||||||
fn replace_queue_standard_lcs(
|
fn replace_queue_standard_lcs(
|
||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
_current_index: Option<usize>,
|
snapshot: &QueueSnapshot,
|
||||||
|
current_track_ids: &[u32],
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
// Get current state from OpenHome
|
|
||||||
let snapshot = self.queue_snapshot()?;
|
|
||||||
let current_track_ids = self.track_ids()?;
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
current_count = snapshot.items.len(),
|
current_count = snapshot.items.len(),
|
||||||
@@ -658,7 +642,7 @@ impl OpenHomeQueue {
|
|||||||
"LCS input: current vs desired items"
|
"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_keep = keep_current.iter().filter(|&&k| k).count();
|
||||||
let items_to_delete = 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
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -883,6 +866,52 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>,
|
|||||||
(keep_current, keep_desired)
|
(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 {
|
impl QueueBackend for OpenHomeQueue {
|
||||||
fn len(&self) -> Result<usize, ControlPointError> {
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
Ok(self.track_ids()?.len())
|
Ok(self.track_ids()?.len())
|
||||||
@@ -983,7 +1012,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
||||||
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
|
// 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());
|
let mut entries = Vec::with_capacity(ids.len());
|
||||||
for chunk in ids.chunks(MAX_BATCH) {
|
for chunk in ids.chunks(MAX_BATCH) {
|
||||||
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||||
@@ -1056,9 +1085,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.playlist_client.stop()?;
|
self.playlist_client.stop()?;
|
||||||
}
|
}
|
||||||
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_all_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,9 +1109,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.metadata_cache.lock().unwrap().clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
|
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_all_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1105,8 +1130,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache after insertions
|
// Invalidate cache after insertions
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1131,9 +1155,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.lock().unwrap().clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_all_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
// DIAGNOSTIC: Log state after delete_all
|
// DIAGNOSTIC: Log state after delete_all
|
||||||
let post_current_track = self.playlist_client.id().ok();
|
let post_current_track = self.playlist_client.id().ok();
|
||||||
@@ -1218,7 +1240,15 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
pivot_idx
|
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 {
|
} else {
|
||||||
// CASE 1: Currently playing item NOT in the new playlist
|
// CASE 1: Currently playing item NOT in the new playlist
|
||||||
// Keep it as first item and append the new playlist after it
|
// 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(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
"No currently playing item, using standard LCS sync"
|
"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
|
// DIAGNOSTIC: Log state after sync completes
|
||||||
@@ -1319,8 +1351,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1365,8 +1396,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_track_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1378,9 +1408,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
// Invalidate caches after clearing playlist (clears queue and current track)
|
// Invalidate caches after clearing playlist (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.invalidate_all_caches();
|
||||||
self.read_list_cache.lock().unwrap().invalidate();
|
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,6 +432,12 @@ impl ContentHandler {
|
|||||||
items.len()
|
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
|
// Calculer le total avant pagination
|
||||||
let total = (containers.len() + items.len()) as u32;
|
let total = (containers.len() + items.len()) as u32;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.39
|
0.3.40
|
||||||
|
|||||||
Reference in New Issue
Block a user