push-uplrprqrknlm #90

Merged
eric merged 3 commits from push-uplrprqrknlm into main 2026-04-06 18:47:03 +02:00
21 changed files with 1188 additions and 181 deletions

View 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()

View 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 (50200 ms) implique 0,8 à 3,2 secondes
uniquement pour la lecture des métadonnées.
La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est
conservatrice. Augmenter à 256 réduit à **4 appels** (75 %).
Le mécanisme de fallback one-by-one (lignes 10071019) assure la rétrocompatibilité
avec les devices qui refuseraient un payload plus large.
### P3 — Polling à 500 ms indépendant de l'activité
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille.
Avec plusieurs renderers actifs, les appels de polling et les opérations `sync_queue`
se chevauchent sur le même device réseau, créant de la contention.
### P4 — Redondances de code (nettoyage conservatif)
**a. Invalidation des caches dupliquée** (`openhome.rs`)
La séquence d'invalidation apparaît en 3 endroits distincts (lignes 11341136,
454456, 634635) :
```rust
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
// parfois aussi :
self.current_track_id_cache.lock().unwrap().invalidate();
```
**b. Protection durée stream dupliquée** (`openhome.rs` et `interne.rs`)
La logique de protection de durée pour les flux continus (radio) est implémentée :
- Dans `cache_metadata()` de `OpenHomeQueue` (`openhome.rs:250351`)
- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92143`)
- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148215`)
**c. `parse_duration()` défini 3 fois**
La conversion `HH:MM:SS` → secondes apparaît dans `openhome.rs`, `interne.rs`,
et dans `time_utils::parse_hhmmss_u32()` (déjà publique).
## Ce qui fonctionne déjà correctement
**Pagination du Browse** : La boucle de pagination est correctement implémentée dans
`control_point.rs:16101653` avec `browse_children()` + offset incrémental.
**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 10071019)
assure la robustesse sur les devices stricts.
**Protection multi-control-point** : `delete_id_if_exists()` gère proprement le cas où
un autre control point a déjà supprimé un titre.
**Stratégie double-LCS avec pivot** : La logique de `replace_queue_with_pivot()` est
correcte et importante pour ne pas interrompre la lecture en cours.
**Cache métadonnées stream** : La protection de durée décroissante pour les flux radio
est un comportement essentiel à préserver scrupuleusement.
## Plan d'exécution
### Crate concernée : `pmocontrol`
---
### Étape 1 — Augmenter le batch `ReadList` à 256
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
```rust
// Avant
const MAX_BATCH: usize = 64;
// Après
const MAX_BATCH: usize = 256;
```
Le fallback one-by-one (lignes 10071019) reste intact. Si un renderer refuse
un payload de 256 IDs, il retombe automatiquement sur le mode unitaire.
---
### Étape 2 — Éliminer le double appel à `queue_snapshot()`
**Fichier** : `pmocontrol/src/queue/openhome.rs`
Le snapshot calculé dans `sync_queue()` contient déjà les items **et** leurs IDs
backend (`backend_id: usize`). Il n'est pas nécessaire de le recalculer dans les
sous-fonctions.
#### 2a. Passer le snapshot à `replace_queue_with_pivot()`
Signature actuelle (ligne 548) :
```rust
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids())
) -> Result<(), ControlPointError>
```
À l'intérieur de `replace_queue_with_pivot()`, supprimer :
```rust
// Supprimer ces deux lignes (ligne 555556)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
```
Et utiliser directement les paramètres `snapshot` et `current_track_ids`.
Appel depuis `sync_queue()` (ligne 1221) :
```rust
// Avant
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
// Après — passer le snapshot et les IDs déjà disponibles
let current_ids_for_pivot: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, &current_ids_for_pivot)?;
```
**Note importante** : dans `sync_queue()`, le snapshot est pris APRÈS
`ensure_playlist_source_selected()` (ligne 1115) et APRÈS la résolution du `playing_info`.
Cet ordre est correct et doit être conservé.
#### 2b. Passer le snapshot à `replace_queue_standard_lcs()`
Signature actuelle (ligne 641) :
```rust
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
_current_index: Option<usize>,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté
) -> Result<(), ControlPointError>
```
À l'intérieur, supprimer :
```rust
// Supprimer ces deux lignes (lignes 647648)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
```
Appel depuis `sync_queue()` (ligne 1253) :
```rust
// Avant
self.replace_queue_standard_lcs(items, Some(0))?;
// Après
let current_ids_for_lcs: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_standard_lcs(items, &snapshot, &current_ids_for_lcs)?;
```
**Cas particulier à préserver** (ligne 12371246) : le guard sur `snapshot.items.is_empty()`
dans `sync_queue()` est exécuté **avant** l'appel à `replace_queue_standard_lcs`, donc
le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé.
---
### Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs
**Fichier** : `pmocontrol/src/queue/openhome.rs`
La fonction `lcs_flags()` (ligne 851) reste inchangée. L'optimisation s'applique
**aux appels** dans `replace_queue_with_pivot()` et `replace_queue_standard_lcs()`.
#### Principe
Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue :
```rust
/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs
/// avant d'appeler l'algorithme DP O(m×n).
///
/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n),
/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements.
fn lcs_flags_optimized(
current: &[PlaybackItem],
desired: &[PlaybackItem],
) -> (Vec<bool>, Vec<bool>) {
// Préfixe commun
let leading = current
.iter()
.zip(desired.iter())
.take_while(|(c, d)| items_match(c, d))
.count();
// Suffixe commun (sur les portions restantes uniquement)
let c_tail = &current[leading..];
let d_tail = &desired[leading..];
let trailing = c_tail
.iter()
.rev()
.zip(d_tail.iter().rev())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_mid = &c_tail[..c_tail.len() - trailing];
let d_mid = &d_tail[..d_tail.len() - trailing];
// Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout)
if c_mid.is_empty() && d_mid.is_empty() {
return (vec![true; current.len()], vec![true; desired.len()]);
}
// LCS DP sur le delta central uniquement
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
// Reconstituer les vecteurs complets
let mut keep_current = vec![true; leading];
keep_current.extend(keep_c_mid);
keep_current.extend(vec![true; trailing]);
let mut keep_desired = vec![true; leading];
keep_desired.extend(keep_d_mid);
keep_desired.extend(vec![true; trailing]);
(keep_current, keep_desired)
}
```
Remplacer les 3 appels à `lcs_flags()` (lignes 579, 582, 661) par `lcs_flags_optimized()`.
La fonction `lcs_flags()` originale est **conservée** (utilisée en interne par
`lcs_flags_optimized()`).
---
### Étape 4 — Polling adaptatif selon l'activité
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
Ajouter un flag partagé `is_active` dans `MusicRenderer` (ou `WatchedState`) pour
signaler si le renderer est en activité récente.
Le renderer met `is_active = true` lors de chaque opération (play, sync, seek, stop).
Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité.
```rust
// Dans la boucle du watcher :
let interval = if is_active.load(Ordering::Relaxed) {
Duration::from_millis(500)
} else {
Duration::from_millis(5_000)
};
thread::sleep(interval);
```
**Fonctionnalités à préserver** :
- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable
- Sleep timer countdown : reste actif au polling suivant
- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe,
pas par le polling — non affectée
---
### Étape 5 — Consolider les redondances (nettoyage conservatif)
**À réaliser uniquement après validation fonctionnelle des étapes 14.**
#### 5a. Méthode `invalidate_all_caches()` sur `OpenHomeQueue`
```rust
fn invalidate_all_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
}
fn invalidate_track_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
}
```
Remplacer les séquences d'invalidation en 3 endroits (lignes 11341136, 454456, 634635).
Garder les appels sélectifs là où seulement 2 caches sont invalidés.
#### 5b. Factoriser `parse_duration()`
Supprimer les définitions locales de `parse_duration` dans `openhome.rs` et `interne.rs`.
Utiliser `crate::music_renderer::time_utils::parse_hhmmss_u32()` (déjà publique).
La sémantique est identique : conversion `HH:MM:SS` → u64 secondes.
#### 5c. Factoriser la protection durée stream
Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux
continu pour le même titre/artiste ») dans une fonction privée dans `openhome.rs`,
et y référencer depuis `interne.rs` via le module `queue`.
**Règle absolue** : ne pas modifier la sémantique de détection de stream continu
(`is_continuous_stream_url()`) ni la logique de comparaison titre/artiste. Uniquement
factoriser le code existant.
---
## Ordre d'exécution
1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat 75 % appels)
2. **Étape 2** — Élimination double `queue_snapshot()` (50 % appels SOAP totaux)
3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n))
4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille)
5. **Étape 5** — Consolidation redondances (nettoyage, après validation)
## Périmètre : ce qui ne change pas
- La logique à 3 cas de `sync_queue()` (avec pivot, préserver courant, LCS standard)
- La protection durée décroissante pour les flux radio (cache stream)
- Le mécanisme `delete_id_if_exists()` pour la robustesse multi-control-point
- Le fallback `ReadList` one-by-one en cas d'erreur batch
- La pagination Browse dans `control_point.rs` (déjà correcte)
- Le comportement des queues `InternalQueue` (Chromecast, UPnP) — non affectées
- Les TTL des caches existants (1 s, 500 ms, 250 ms)
- Tous les logs de diagnostic (`tracing::warn!`, `debug!`) — à conserver
## Tests recommandés
Demander à l'humain de compiler et tester :
```
cargo build -p pmocontrol
```
Puis tester avec un renderer OpenHome physique :
- Playlist de 1 000 titres : mesurer le temps de `sync_queue` avant/après
- Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions
- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue
- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste
- Renderer Chromecast : vérifier l'absence de régression (queue interne)

View 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

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.39"
version = "0.3.40"
edition = "2024"
[dependencies]

View 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

View File

@@ -559,12 +559,24 @@
>
</span>
</div>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="pagination-controls">
<button @click="prevPage" :disabled="currentPage === 0">
&larr; Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
Next &rarr;
</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;

View File

@@ -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 */

View File

@@ -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;
}
}

View File

@@ -187,8 +187,6 @@ onMounted(async () => {
/* Large desktop */
@media (min-width: 1200px) {
.browser-section {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
}

View File

@@ -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) {

View File

@@ -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":

View 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>;
}

View File

@@ -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 */

View File

@@ -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(

View File

@@ -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);

View File

@@ -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,
}
// ============================================================================

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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 = &current[leading..];
let d_tail = &desired[leading..];
let trailing = c_tail
.iter()
.rev()
.zip(d_tail.iter().rev())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_mid = &c_tail[..c_tail.len().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,
&current_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, &current_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(())
}

View File

@@ -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;

View File

@@ -1 +1 @@
0.3.39
0.3.40