From ec852c00027b35456d1e4413ec0731c0ff091ed0 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 29 Mar 2026 19:48:53 +0200 Subject: [PATCH 01/14] feat: ajouter middleware Axum BaseUrl pour centraliser les URLs HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprimer les fichiers obsolètes de configuration Claude (.claude/CLAUDE.md et .claude/hooks/preToolUse.sh), mettre à jour .gitignore pour ignorer les répertoires Claude, et ajouter une spécification détaillée pour implémenter un middleware Axum `BaseUrl` afin de centraliser la construction des URLs HTTP en fonction des headers X-Forwarded-*, évitant les hardcodages d'IP locale dans les réponses au frontend. --- .claude/CLAUDE.md | 39 -- .claude/hooks/preToolUse.sh | 25 - .gitignore | 2 + .../centraliser_base_url_axum_middleware.md | 459 ++++++++++++++++++ 4 files changed, 461 insertions(+), 64 deletions(-) delete mode 100644 .claude/CLAUDE.md delete mode 100755 .claude/hooks/preToolUse.sh create mode 100644 Blackboard/Todo/centraliser_base_url_axum_middleware.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 14a5ee2f..00000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,39 +0,0 @@ -# PMOMusic Project Configuration - -## Version Control -Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git. -- Utiliser les commandes `jj` au lieu des commandes `git` -- Bookmark principal : `main` -- Ne jamais suggérer de commandes git - -## Environnement -Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet. - -## Configuration de l'application -- Fichier de configuration principal : `.pmomusic/config.yaml` -- Configuration UPNP personnalisable pour différencier les instances en développement - -## Développement -Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances : - -```yaml -host: - upnp: - manufacturer: "PMOMusic-Dev1" - udn_prefix: "pmomusic-dev1" - model_name_prefix: "PMOMusic-Dev1" - friendly_name_prefix: "PMOMusic-Dev1" -``` - -## Méthode de travail -Avant de modifier quoi que ce soit sur une fonctionnalité non triviale : -1. Lire et comprendre le flux complet des données concernées, de bout en bout -2. Identifier précisément où ça casse et pourquoi -3. Faire une seule modification ciblée - -Ne pas avancer par tâtonnements ("vibe programming") — cela produit des allers-retours, des bugs introduits puis annulés, et du code inutilement compliqué. - -## Architecture -- Projet Rust multi-crates avec workspaces -- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig -- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs) diff --git a/.claude/hooks/preToolUse.sh b/.claude/hooks/preToolUse.sh deleted file mode 100755 index a309be01..00000000 --- a/.claude/hooks/preToolUse.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Lire les données JSON envoyées par Claude Code -INPUT=$(cat) - -# Extraire le nom de l'outil -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name') - -# Pour les éditions de fichiers, forcer la demande de confirmation -if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "MultiEdit" ]] || [[ "$TOOL_NAME" == "Write" ]]; then - # Retourner une décision "ask" qui force la confirmation - cat << EOF -{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "ask", - "permissionDecisionReason": "Validation requise pour toute édition de fichier" - } -} -EOF - exit 0 -fi - -# Pour les autres outils, laisser passer normalement -exit 0 diff --git a/.gitignore b/.gitignore index 4b5b564d..1f472a01 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ pmo*_[0_9]*.txt webapp_[0_9]*.txt RF.json RF_old.json +.claude/ +.claude.old diff --git a/Blackboard/Todo/centraliser_base_url_axum_middleware.md b/Blackboard/Todo/centraliser_base_url_axum_middleware.md new file mode 100644 index 00000000..e7a9c7e0 --- /dev/null +++ b/Blackboard/Todo/centraliser_base_url_axum_middleware.md @@ -0,0 +1,459 @@ +** 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) ** + +## Problème + +Quand le site est accédé via une adresse externe (reverse proxy, ex: `orbis.petite-maison-orange.fr`), +les URLs d'images et de ressources générées par le backend contiennent l'IP locale hardcodée +(ex: `http://192.168.0.32:80/covers/image/...`). Ces URLs sont inaccessibles depuis l'extérieur. + +## Cause racine + +Il existe deux contextes distincts de construction d'URL dans pmomusic : + +**A. Contexte UPnP / réseau local** : les URLs doivent être absolues avec l'IP locale, car les +renderers UPnP accèdent directement aux ressources sur le réseau local. +→ Elles utilisent correctement `PMO_SERVER_URL` / `covers_absolute_url_for()`. + +**B. Contexte HTTP / frontend webapp** : les URLs retournées au navigateur doivent refléter l'hôte +vu par le client (local ou via proxy). Elles utilisent actuellement aussi `PMO_SERVER_URL` — +c'est le bug. + +`pmoserver` dispose déjà de `request_base_url(headers)` (`pmoserver/src/server.rs:633`) qui lit +`X-Forwarded-Proto` / `X-Forwarded-Host` / `Host` et retourne la base URL correcte par requête. +Mais **aucun handler HTTP ne l'utilise** pour construire les URLs retournées au frontend. + +## Solution : Middleware Axum BaseUrl + +Ajouter un middleware Axum au niveau de la racine du routeur qui enrichit chaque requête d'une +`Extension` calculée depuis les headers. Tous les handlers HTTP qui retournent des URLs +au frontend extraient cette extension — **un seul point de calcul, zéro gestion ad hoc**. + +### 1. Nouveau type `BaseUrl` dans `pmoserver/src/lib.rs` + +```rust +/// URL de base effective pour la requête courante. +/// Calculée depuis X-Forwarded-Proto/Host ou Host header. +/// Injectée par `base_url_layer` dans toutes les requêtes Axum. +#[derive(Debug, Clone)] +pub struct BaseUrl(pub String); +``` + +### 2. Middleware `base_url_layer` dans `pmoserver/src/lib.rs` + +```rust +/// Middleware Axum : injecte BaseUrl dans chaque requête. +/// À appliquer sur le routeur racine via `.layer(base_url_layer())`. +pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> { + axum::middleware::from_fn(|request: Request, next: Next| async move { + // Fallback sur PMO_SERVER_URL (valeur de démarrage avec la vraie IP/port), + // pas sur localhost:8080 hardcodé. + let base = get_request_base_url(request.headers()) + .or_else(|| std::env::var("PMO_SERVER_URL").ok()) + .unwrap_or_else(|| { + tracing::warn!("BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL — fallback localhost:8080"); + "http://localhost:8080".to_string() + }); + let mut request = request; + tracing::debug!("BaseUrl calculée : {}", base); + request.extensions_mut().insert(BaseUrl(base)); + next.run(request).await + }) +} +``` + +### 3. Application du layer dans `pmoserver/src/server.rs` + +Dans Axum, le dernier `.layer()` appliqué est le plus extérieur (exécuté en premier sur la +requête entrante). Pour que `base_url_layer` voie les headers **après** tout layer de nettoyage, +il doit être **intérieur** — donc appliqué **avant** dans le code : + +```rust +router + .layer(header_clean_layer()) // extérieur → exécuté en premier, nettoie les headers + .layer(base_url_layer()) // intérieur → voit les headers nettoyés +``` + +Les endpoints UPnP (SSDP, description XML, control, event) ne doivent pas appeler +`covers_absolute_url_for_upnp()` via `BaseUrl` — l'injection du middleware ne les affecte pas +puisqu'ils n'extraient pas `Extension`. + +Si des routes non-HTTP sont ajoutées ultérieurement (métriques internes, health checks sans +contexte client), les isoler dans un sous-routeur dédié sans `base_url_layer()`. + +### 4. Utilisation dans les handlers + +Tous les handlers qui retournent des URLs au frontend ajoutent : + +```rust +Extension(base_url): Extension, +``` + +Et utilisent `base_url.url_for(&pmocovers::covers_route_for(pk, None))` à la place de +`covers_absolute_url_for()` (voir section 5 pour le pattern complet). + +Handlers REST concernés (liste non exhaustive) : +- `pmocontrol/src/pmoserver_ext.rs` : `get_renderer_full_snapshot` (album_art_uri dans snapshot) +- `pmocontrol/src/pmoserver_ext.rs` : handler browse (ContainerEntry.album_art_uri) +- `pmoradiofrance/src/api_rest.rs` : endpoints playlist/metadata +- `pmoplaylist/src/handle/read.rs` : album art dans les réponses playlist + +**Handlers SSE** (`pmocontrol/src/sse.rs`) : cas particulier. Le stream SSE est long-lived — +après le `stream!` block, on n'est plus dans le contexte du handler Axum. `BaseUrl` doit être +clonée dans une variable locale **avant** le `stream!`, puis `move`-ée dans la closure : + +```rust +pub async fn renderer_events_sse( + State(control_point): State>, + Extension(base_url): Extension, // ← extraite à la connexion +) -> impl IntoResponse { + let base_url = base_url.clone(); // clone avant le stream! pour le move + // ... + let stream = stream! { + while let Some(event) = rx_tokio.recv().await { + // base_url est disponible ici par move + let payload = renderer_event_to_payload(event, &base_url); + yield Ok(Event::default()...); + } + }; +} + +### 5. Méthode `url_for` sur `BaseUrl` + fonctions `route_for` dans chaque crate + +La combinaison `base_url + route` est identique pour tous les types de ressources. Elle est +factorisée en une méthode sur `BaseUrl` dans `pmoserver/src/lib.rs` : + +```rust +impl BaseUrl { + /// Construit une URL absolue en combinant la base URL de la requête avec une route relative. + /// Usage : base_url.url_for(&pmocovers::covers_route_for(pk, None)) + pub fn url_for(&self, route: &str) -> String { + debug_assert!(route.starts_with('/'), "route must start with '/'"); + format!("{}{}", self.0.trim_end_matches('/'), route) + } +} +``` + +Chaque crate spécialisée expose uniquement sa **route** (chemin relatif), pas l'URL complète : + +**`pmocovers/src/lib.rs`** — déplacer depuis `pmocache` : +```rust +/// Route relative d'une cover : `/covers/image/{pk}[/{param}]` +pub fn covers_route_for(pk: &str, param: Option<&str>) -> String { ... } +``` + +**`pmoaudiocache/src/lib.rs`** : +```rust +/// Route relative d'un fichier audio : `/audio/flac/{pk}` +pub fn audio_route_for(pk: &str) -> String { + format!("/audio/flac/{}", pk) +} +``` + +Usage dans les handlers : +```rust +base_url.url_for(&pmocovers::covers_route_for(pk, None)) +base_url.url_for(&pmoaudiocache::audio_route_for(pk)) +``` + +### 6. Renommage de `covers_absolute_url_for` → `covers_absolute_url_for_upnp` + +Pour rendre le contexte d'usage explicite et décourager l'appel depuis les handlers HTTP, +renommer dans `pmocache/src/lib.rs` : + +```rust +// Ancien nom — marqué deprecated pour faciliter la migration (warnings à la compilation) +#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")] +pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String { ... } + +// Nouveau nom — usage UPnP uniquement +pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String { ... } +``` + +Mettre à jour tous les appels existants (contextes UPnP/DIDL uniquement) via un grep : +`grep -rn "covers_absolute_url_for" src/ --include="*.rs"` + +À terme, `covers_route_for` et `covers_absolute_url_for_upnp` devraient migrer de `pmocache` +vers `pmocovers`, mais ce n'est pas le périmètre de ce ticket. + +## URLs dans les documents DIDL et SSE + +Les documents DIDL bruts (``, ``) ne transitent jamais vers le frontend — +pmocontrol les parse côté serveur et n'envoie que des champs extraits (JSON) via REST et SSE. +Il n'y a donc pas de "rebasage XML" : les champs extraits (`album_art_uri`, etc.) passent tous +par des handlers qui ont accès à `BaseUrl`. + +**SSE est per-client** : chaque connexion SSE crée son propre receiver (`subscribe_events()`). +La `BaseUrl` est figée à l'établissement de la connexion et ne sera pas mise à jour si le client +change de réseau en cours de stream — comportement attendu et documenté. +Il n'y a ni canal partagé, ni duplication LAN/WAN. Le handler SSE capture `Extension` +à l'établissement de la connexion et applique `base_url.url_for()` à toutes les URLs des événements +émis vers ce client. + +Les DIDL servis directement aux renderers UPnP (hors HTTP webapp) gardent l'IP locale — c'est +correct, les renderers sont sur le réseau local. + +## Audit préalable à l'implémentation + +Avant de modifier les handlers, faire un audit exhaustif de tous les endroits qui construisent +des URLs absolues dans des réponses JSON au frontend : + +```bash +# Appels directs aux fonctions URL connues +grep -rn "covers_absolute_url_for\|audio/flac\|cache/audio" src/ --include="*.rs" + +# Constructions format! utilisant PMO_SERVER_URL ou des littéraux http:// +grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" src/ --include="*.rs" +grep -rn 'format!.*"http' src/ --include="*.rs" +``` + +Note : les PKs de covers et audio sont des hashes hex (`[0-9a-f]+`) — ils ne peuvent pas +contenir de caractères spéciaux nécessitant un encodage URL. La concaténation `format!` est +donc sûre ; pas besoin de `url::Url::join`. + +## Tests à écrire + +- **Middleware** : `BaseUrl` correctement extraite depuis `X-Forwarded-Host`, `Host`, et en + leur absence (fallback sur `PMO_SERVER_URL`) +- **`url_for`** : assertion que toutes les routes commencent par `/` ; pas de double slash ; + trailing slash sur la base géré par `trim_end_matches` +- **Handlers REST** : `album_art_uri` rebased dans `FullRendererSnapshot` et `BrowseResponse` +- **SSE** : URLs rebased dans les événements `TrackChanged` +- **UPnP** : vérifier que les URLs servies aux renderers UPnP restent en IP locale (non affectées + par `BaseUrl`) +- **Intégration** : appeler **chaque endpoint frontend** avec un client HTTP de test pour + vérifier (a) qu'aucune panique ne se produit (middleware bien appliqué) et (b) que les URLs + produites utilisent l'hôte du header `X-Forwarded-Host` simulé et non l'IP locale. + La panique sur `Extension` manquante est un comportement voulu — elle doit être + détectée par ces tests et non silencieusement masquée par un `Option`. + +## Sécurité : headers X-Forwarded-* + +Les headers `X-Forwarded-Proto` / `X-Forwarded-Host` peuvent être forgés par n'importe quel client +si le reverse proxy ne les filtre pas. Dans le contexte de déploiement de pmomusic (usage domestique, +proxy Nginx/Caddy unique), le risque est faible et hors périmètre de ce ticket. + +À surveiller si le déploiement évolue : restreindre la lecture de ces headers aux requêtes venant +de l'IP du proxy (liste blanche de proxies de confiance côté Axum ou côté proxy). + +## Périmètre : ce qui ne change PAS + +- `covers_absolute_url_for()` dans `pmocache` : conservée pour les contextes UPnP +- `PMO_SERVER_URL` env var : conservée pour UPnP et les processus non-HTTP +- URLs dans les DIDL servis aux renderers UPnP : inchangées (doivent rester en IP locale) +- `server_base_url` passé aux sources (RadioFrance, RadioParadise, Qobuz) : inchangé + (ces sources construisent des URLs pour les renderers réseau) + +## Plan d'exécution + +### Corrections d'audit préalables + +Divergences entre le document et le code réel : + +- **Route audio** : `/audio/tracks/{pk}` (PAS `/audio/flac/{pk}`) +- **`covers_route_for`** existe déjà dans `pmocache/src/lib.rs:149` — à copier vers `pmocovers` +- **`album_art_uri`** dans les handlers est propagé depuis des caches amont ; le point de + construction réel est `pmoradiofrance/src/metadata_cache.rs:263` (tâche de fond, pas un handler) +- **`pmoqobuz/src/source.rs:1943`** construit des URLs audio avec `self.base_url` → contexte + UPnP/renderer, hors périmètre de ce ticket + +### Étape 0 — Audit exhaustif (avant tout changement) + +```bash +grep -rn "covers_absolute_url_for\|audio/tracks\|cache/audio" --include="*.rs" +grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" --include="*.rs" +grep -rn 'format!.*"http' --include="*.rs" +``` + +Identifier tous les call sites dans les contextes HTTP (handlers, caches de métadonnées servant +le frontend). Distinguer des contextes UPnP/renderer (hors périmètre). + +### Étape 1 — `pmoserver/src/lib.rs` : ajouter `BaseUrl` + `base_url_layer` + +`get_request_base_url(headers)` existe déjà à la ligne 199. Ajouter : + +```rust +use axum::{extract::Request, middleware::Next, response::Response}; + +#[derive(Debug, Clone)] +pub struct BaseUrl(pub String); + +impl BaseUrl { + pub fn url_for(&self, route: &str) -> String { + debug_assert!(route.starts_with('/'), "route must start with '/'"); + format!("{}{}", self.0.trim_end_matches('/'), route) + } +} + +pub async fn base_url_middleware(mut request: Request, next: Next) -> Response { + let base = get_request_base_url(request.headers()) + .unwrap_or_else(|| { + std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| { + tracing::warn!( + "BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL \ + — fallback localhost:8080" + ); + "http://localhost:8080".to_string() + }) + }); + tracing::debug!("BaseUrl calculée : {}", base); + request.extensions_mut().insert(BaseUrl(base)); + next.run(request).await +} + +pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> { + axum::middleware::from_fn(base_url_middleware) +} +``` + +### Étape 2 — `pmoserver/src/server.rs` : appliquer le layer + +Trouver la construction du routeur principal. Ajouter `base_url_layer()` avant les layers +existants (= intérieur dans la pile Tower) : + +```rust +router + .layer(some_existing_layer()) // extérieur → exécuté en premier + .layer(base_url_layer()) // intérieur → voit les headers après nettoyage +``` + +### Étape 3 — `pmocovers/src/lib.rs` : ajouter `covers_route_for` + +Copier depuis `pmocache/src/lib.rs:149` : + +```rust +/// Route relative d'une cover : `/covers/image/{pk}[/{param}]` +pub fn covers_route_for(pk: &str, param: Option<&str>) -> String { + if let Some(p) = param { + format!("/covers/image/{}/{}", pk, p) + } else { + format!("/covers/image/{}", pk) + } +} +``` + +### Étape 4 — `pmoaudiocache/src/lib.rs` : ajouter `audio_route_for` + +```rust +/// Route relative d'un fichier audio : `/audio/tracks/{pk}` +pub fn audio_route_for(pk: &str) -> String { + format!("/audio/tracks/{}", pk) +} +``` + +### Étape 5 — `pmocache/src/lib.rs` : renommer `covers_absolute_url_for` + +```rust +#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")] +pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String { + covers_absolute_url_for_upnp(pk, param) +} + +pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String { + let base = std::env::var("PMO_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param)) +} +``` + +Mettre à jour l'appel UPnP connu : `pmoupnp/src/cache_registry.rs:57` +→ `covers_absolute_url_for(` → `covers_absolute_url_for_upnp(` + +### Étape 6 — `pmoradiofrance/src/metadata_cache.rs:263` : stocker la route, pas l'URL + +**Contexte** : tâche de fond — ne peut pas extraire `Extension`. +**Principe** : stocker la route relative dans le cache ; le handler rebase au moment de la réponse. + +```rust +// Avant : +let public_url = pmocache::covers_absolute_url_for(&pk, None); + +// Après : +let public_url = pmocovers::covers_route_for(&pk, None); // route relative +``` + +Le handler REST dans `pmoradiofrance/src/api_rest.rs` qui retourne ces métadonnées doit : +1. Ajouter `Extension(base_url): Extension` à sa signature +2. Construire l'URL : `base_url.url_for(&metadata.album_art_uri)` + +Lire `api_rest.rs` pour identifier le handler exact qui inclut `album_art_uri` dans la réponse. + +### Étape 7 — Handlers REST `pmocontrol/src/pmoserver_ext.rs` + +`get_renderer_full_snapshot` (l.170) et `browse_container` (l.2080) propagent `album_art_uri` +depuis les résultats DIDL des media servers UPnP — ces URLs pointent vers l'IP du media server, +pas de pmomusic. + +**Action** : après l'audit, vérifier si ces URLs passent par `covers_absolute_url_for`. +Si oui → même traitement qu'étape 6. Sinon → pas de changement. + +### Étape 8 — Handlers SSE `pmocontrol/src/sse.rs` + +Pour `renderer_events_sse`, `media_server_events_sse`, `all_events_sse` : + +```rust +pub async fn renderer_events_sse( + State(control_point): State>, + Extension(base_url): Extension, // ← ajouter +) -> impl IntoResponse { + let base_url = base_url.clone(); // avant le stream! + let stream = stream! { + while let Some(event) = rx.recv().await { + // base_url.url_for(...) pour les URLs dans les événements + } + }; +} +``` + +Vérifier si les événements SSE contiennent des `album_art_uri` construits avec +`covers_absolute_url_for` ou propagés depuis le cache. +Si propagation → même traitement qu'étape 6. + +### Étape 9 — Vérification finale + +```bash +# Ne doit retourner aucun appel dans les handlers HTTP +grep -rn "covers_absolute_url_for[^_]" --include="*.rs" + +# Ne doit retourner aucun résultat dans les handlers HTTP +grep -rn "PMO_SERVER_URL" --include="*.rs" | grep -v "pmocache\|pmoserver\|test" + +# Warnings deprecated +cargo build 2>&1 | grep "deprecated" +``` + +### Étape 10 — Tests + +```rust +#[test] +fn url_for_combines_base_and_route() { + let b = BaseUrl("https://example.com".to_string()); + assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc"); +} + +#[test] +fn url_for_trims_trailing_slash() { + let b = BaseUrl("https://example.com/".to_string()); + assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc"); +} +// + tests middleware X-Forwarded-Host, fallback PMO_SERVER_URL, fallback localhost +// + test intégration : chaque endpoint frontend avec X-Forwarded-Host simulé +``` + +### Ordre d'exécution + +1. Étape 0 — audit (confirmer la liste des call sites) +2. Étapes 3, 4 — ajouter `covers_route_for` / `audio_route_for` (sans breaking change) +3. Étape 5 — renommer + `#[deprecated]` (les warnings guident la suite) +4. Étape 1 — `BaseUrl` + `base_url_layer` dans `pmoserver` +5. Étape 2 — appliquer le layer dans `server.rs` +6. Étapes 6, 7, 8 — migrer les handlers (guidés par les warnings de compilation) +7. Étapes 9, 10 — vérification + tests + +## Règle après cette modification + +**Interdit** : appeler `covers_absolute_url_for_upnp()`, lire `PMO_SERVER_URL`, ou utiliser +`format!("{}/audio/flac/{}", base_url, pk)` dans un handler HTTP qui retourne du JSON au frontend. + +**Obligatoire** : extraire `Extension` et utiliser : +- `base_url.url_for(&pmocovers::covers_route_for(pk, None))` pour les images +- `base_url.url_for(&pmoaudiocache::audio_route_for(pk))` pour les fichiers audio From 843f474a98a7b59cbd5b04503dbef46ba1dd18fb Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 29 Mar 2026 20:58:20 +0200 Subject: [PATCH 02/14] feat: ajouter la recherche dans les serveurs media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente une fonctionnalité de recherche pour les serveurs media (Qobuz et autres) avec: - Nouvelle API endpoint /servers/{serverId}/search côté backend - Fonction searchServer dans useMediaServers.ts pour gérer la requête - Composants MediaBrowser.vue et ServerDrawer.vue avec barre de recherche interactive - Support des résultats mixtes (albums, artistes, pistes, playlists) - Gestion des états de chargement, erreur et résultat vide - Affichage conditionnel selon le mode navigation/recherche --- .../components/pmocontrol/MediaBrowser.vue | 112 +++++++++++++- .../src/components/unified/ServerDrawer.vue | 142 ++++++++++++++++++ .../webapp/src/composables/useMediaServers.ts | 40 +++++ pmoapp/webapp/src/services/pmocontrol/api.ts | 10 ++ pmocontrol/src/pmoserver_ext.rs | 127 ++++++++++++++++ pmoqobuz/src/source.rs | 73 +++++++-- 6 files changed, 490 insertions(+), 14 deletions(-) diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue index 9c88bda0..d08028de 100644 --- a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue +++ b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue @@ -6,7 +6,7 @@ import { useUIStore } from "@/stores/ui"; import Breadcrumb from "./Breadcrumb.vue"; import ContainerItem from "./ContainerItem.vue"; import MediaItem from "./MediaItem.vue"; -import { Loader2 } from "lucide-vue-next"; +import { Loader2, Search, X } from "lucide-vue-next"; const props = defineProps<{ serverId: string; @@ -22,8 +22,27 @@ const { loading, loadingMore, error, + searchResults, + searchQuery, + searchServer, + clearSearch, } = useMediaServers(); +const searchInput = ref(''); + +async function handleSearch() { + if (searchInput.value.trim()) { + await searchServer(props.serverId, searchInput.value.trim()); + } +} + +function handleClearSearch() { + searchInput.value = ''; + clearSearch(); +} + +const isSearchMode = computed(() => searchQuery.value !== ''); + const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } = useRenderers(); const uiStore = useUIStore(); @@ -33,7 +52,9 @@ const sentinelRef = ref(null); let observer: IntersectionObserver | null = null; const browseData = computed(() => - getBrowseCached(props.serverId, props.containerId), + isSearchMode.value + ? searchResults.value + : getBrowseCached(props.serverId, props.containerId), ); const containers = computed( @@ -44,7 +65,7 @@ const items = computed( () => browseData.value?.entries.filter((e) => !e.is_container) || [], ); -const canLoadMore = computed(() => hasMore(props.serverId, props.containerId)); +const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId)); function setupObserver() { if (observer) observer.disconnect(); @@ -165,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) { @navigate="handleNavigate" /> + + +
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) { v-if="!containers.length && !items.length" class="browser-empty" > -

Ce dossier est vide

+

{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}

@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) { color: var(--color-text-secondary); } +/* Search */ +.search-bar { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.search-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: var(--spacing-sm); + color: var(--color-text-tertiary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + color: var(--color-text); + font-size: var(--text-sm); +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.search-clear { + position: absolute; + right: var(--spacing-xs); + background: none; + border: none; + cursor: pointer; + color: var(--color-text-tertiary); + display: flex; + align-items: center; + padding: 2px; +} + +.search-clear:hover { + color: var(--color-text); +} + +.search-btn { + white-space: nowrap; + padding: var(--spacing-xs) var(--spacing-md); + font-size: var(--text-sm); +} + /* Scrollbar styling */ .browser-content::-webkit-scrollbar { width: 6px; diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index b400cfbb..19a7a0df 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -41,8 +41,25 @@ const { currentPath, setPath, clearPath, + searchResults, + searchQuery, + searchServer, + clearSearch, } = useMediaServers(); +const searchInput = ref(''); +const isSearchMode = computed(() => searchQuery.value !== ''); + +async function handleSearch() { + if (!currentServer.value || !searchInput.value.trim()) return; + await searchServer(currentServer.value.id, searchInput.value.trim()); +} + +function handleClearSearch() { + searchInput.value = ''; + clearSearch(); +} + const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } = useRenderers(); @@ -427,6 +444,28 @@ function handleSettingsClick() { + + +
@@ -520,6 +559,53 @@ function handleSettingsClick() {

Chargement...

+ +
+

Aucun résultat

+
    +
  • +
    + +
    + + +
    +
    + +
    + + +
    + + +
    +
    + +
  • +
+
+
  • >(new Map()) const browseCache = ref>(new Map()) const currentPath = ref([]) +const searchResults = ref(null) +const searchQuery = ref('') // Timestamps const lastFetch = { @@ -193,6 +195,39 @@ export function useMediaServers() { } } + // Recherche dans un serveur + async function searchServer(serverId: string, query: string) { + if (!query.trim()) { + searchResults.value = null + searchQuery.value = '' + return + } + + try { + loading.value = true + error.value = null + searchQuery.value = query + + const data = await api.searchServer(serverId, query) + searchResults.value = { + container_id: 'search', + entries: data.entries, + total_count: data.total_count, + } + } catch (e) { + error.value = e instanceof Error ? e.message : 'Erreur recherche' + console.error(`[useMediaServers] Erreur search ${serverId}:`, e) + throw e + } finally { + loading.value = false + } + } + + function clearSearch() { + searchResults.value = null + searchQuery.value = '' + } + // Getters function getServerById(id: string) { return serversCache.value.get(id) @@ -247,6 +282,11 @@ export function useMediaServers() { getServerById, getBrowseCached, hasMore, + // Search + searchResults, + searchQuery, + searchServer, + clearSearch, // Actions fetchServers, browseContainer, diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index dcb5f5f5..fb846f62 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -426,6 +426,16 @@ class PMOControlAPI { ); } + /** + * Recherche dans un serveur media + * GET /api/control/servers/{serverId}/search?q={query} + */ + async searchServer(serverId: string, query: string): Promise { + return this.request( + `/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`, + ); + } + // ============================================================================ // SLEEP TIMER // ============================================================================ diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index a80bcb21..549f28c6 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -2307,6 +2307,132 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar } } +/// Paramètres de recherche +#[cfg(feature = "pmoserver")] +#[derive(Debug, serde::Deserialize)] +struct SearchQuery { + q: String, +} + +/// GET /control/servers/{server_id}/search?q= - Recherche dans un serveur +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/servers/{server_id}/search", + params( + ("server_id" = String, Path, description = "ID unique du serveur"), + ("q" = String, Query, description = "Requête de recherche"), + ), + responses( + (status = 200, description = "Résultats de recherche", body = BrowseResponse), + (status = 404, description = "Serveur non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors de la recherche", body = ErrorResponse) + ), + tag = "control" +)] +async fn search_server( + State(state): State, + Path(server_id): Path, + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let sid = DeviceId(server_id.clone()); + + let server = state.control_point.media_server(&sid).ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Server {} not found", server_id), + }), + ) + })?; + + if !server.is_online() { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: format!("Server {} is offline", server_id), + }), + )); + } + + if !server.has_content_directory() { + return Err(( + StatusCode::NOT_IMPLEMENTED, + Json(ErrorResponse { + error: format!("Server {} does not support ContentDirectory", server_id), + }), + )); + } + + debug!(server_id = %server_id, query = %params.q, "Search request"); + + let query = params.q.clone(); + let server_clone = server.clone(); + let search_task = tokio::task::spawn_blocking(move || { + server_clone.search("0", &query, 0, 200) + }); + + let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, search_task) + .await + .map_err(|_| { + warn!( + "Search request on server {} exceeded {:?}", + server_id, BROWSE_REQUEST_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Search request timed out after {}s", + BROWSE_REQUEST_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during search: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!("Failed to search on server {}: {}", server_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to search: {}", e), + }), + ) + })?; + + let total_count = entries.len() as u32; + debug!(server_id = %server_id, count = total_count, "Search results"); + + let container_entries: Vec = entries + .into_iter() + .map(|e| ContainerEntry { + id: e.id, + title: e.title, + class: e.class, + is_container: e.is_container, + child_count: None, + artist: e.artist, + album: e.album, + album_art_uri: e.album_art_uri, + }) + .collect(); + + Ok(Json(BrowseResponse { + container_id: "search".to_string(), + entries: container_entries, + total_count, + offset: 0, + })) +} + // ============================================================================ // ROUTER & TRAIT // ============================================================================ @@ -2405,6 +2531,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc Result { - // Search across Qobuz catalog + use tracing::debug; + debug!(query = %query, "Qobuz search started"); + + // Search across Qobuz catalog (albums, tracks, artists, playlists) let results = self .inner .client @@ -2023,22 +2026,72 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - let (albums, tracks) = tokio::join!( - self.cache_covers(results.albums), - self.cache_covers(results.tracks), + debug!( + albums = results.albums.len(), + artists = results.artists.len(), + tracks = results.tracks.len(), + playlists = results.playlists.len(), + "Qobuz search API results" ); - let containers: Vec = albums + + // Cache covers in parallel for all types + let (albums, tracks, artists, playlists) = tokio::join!( + self.cache_album_covers(results.albums), + self.cache_track_covers(results.tracks), + self.cache_artist_covers(results.artists), + self.cache_playlist_covers(results.playlists), + ); + + // Build containers from albums + let album_containers: Vec = albums .into_iter() - .filter_map(|album| album.to_didl_container("qobuz").ok()) + .filter_map(|a| a.to_didl_container("qobuz:search").ok()) .collect(); - let items: Vec = tracks + // Build containers from artists (manual construction) + let artist_containers: Vec = artists .into_iter() - .filter_map(|track| track.to_didl_item("qobuz").ok()) + .map(|artist| Container { + id: format!("qobuz:artist:{}", artist.id), + parent_id: "qobuz:search".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: artist.name.clone(), + class: "object.container".to_string(), + artist: Some(artist.name.clone()), + album_art: artist.image_cached, + containers: vec![], + items: vec![], + }) .collect(); - if !containers.is_empty() || !items.is_empty() { - Ok(BrowseResult::Mixed { containers, items }) + // Build containers from playlists + let playlist_containers: Vec = playlists + .into_iter() + .filter_map(|p| p.to_didl_container("qobuz:search").ok()) + .collect(); + + // Combine all containers + let mut all_containers = Vec::new(); + all_containers.extend(album_containers); + all_containers.extend(artist_containers); + all_containers.extend(playlist_containers); + + // Build items from tracks + let track_items: Vec = tracks + .into_iter() + .filter_map(|t| t.to_didl_item("qobuz:search").ok()) + .collect(); + + debug!( + containers = all_containers.len(), + items = track_items.len(), + "Qobuz search done" + ); + + if !all_containers.is_empty() || !track_items.is_empty() { + Ok(BrowseResult::Mixed { containers: all_containers, items: track_items }) } else { Ok(BrowseResult::Items(vec![])) } From c0d1e1a3d5f1ab8802242619ecdd39f433945845 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 31 Mar 2026 10:00:27 +0200 Subject: [PATCH 03/14] Add tracing logs and ReadList caching for OpenHome queue - Add detailed tracing logs in control_point, music_renderer, and openhome_renderer for queue length checks, auto_play flag consumption, playback state changes, and OpenHome transport state mapping. - Introduce a 500ms TTL cache for ReadList results in OpenHomeQueue to reduce redundant SOAP calls, especially during sync_queue operations. - Update all queue modification methods to invalidate the new read_list_cache. - Add debug logging for pivot search results in sync_queue. --- .DS_Store | Bin 18436 -> 18436 bytes pmocontrol/src/control_point.rs | 13 ++- .../src/music_renderer/musicrenderer.rs | 24 +++++ .../src/music_renderer/openhome_renderer.rs | 22 ++++- pmocontrol/src/queue/openhome.rs | 87 +++++++++++++++++- 5 files changed, 140 insertions(+), 6 deletions(-) diff --git a/.DS_Store b/.DS_Store index b37528614380d20bb943a33b4d980fdf4f02128b..eb344ba742cb9f4682b87c67b026cc93f58d7a32 100644 GIT binary patch delta 150 zcmZpfz}PZ@ae_Z%@5X@L{EX(4c?6 { + { + let s = self.state.lock().unwrap(); + tracing::trace!( + renderer = self.info.friendly_name(), + has_played = s.has_played_since_track_start, + playback_source = ?s.playback_source, + "STOPPED detected — evaluating auto-advance" + ); + } // Check if user requested stop (via Stop button in UI) if self.check_and_clear_user_stop_requested() { debug!( @@ -695,6 +710,15 @@ impl MusicRenderer { // Mark that we have seen a PLAYING state - auto-advance is now allowed self.set_has_played_flag(); } + PlaybackState::Transitioning => { + let s = self.state.lock().unwrap(); + tracing::trace!( + renderer = self.info.friendly_name(), + has_played = s.has_played_since_track_start, + playback_source = ?s.playback_source, + "TRANSITIONING detected" + ); + } _ => {} } } diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index 47002404..f046dcd4 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -342,8 +342,14 @@ impl VolumeControl for OpenHomeRenderer { impl PlaybackStatus for OpenHomeRenderer { fn playback_state(&self) -> Result { let client = self.playlist_client_for("playback_state")?; - let state = client.transport_state()?; - Ok(map_openhome_state(&state)) + let raw = client.transport_state()?; + let mapped = map_openhome_state(&raw); + tracing::trace!( + raw_state = raw.as_str(), + mapped_state = ?mapped, + "OpenHome TransportState" + ); + Ok(mapped) } } @@ -542,7 +548,19 @@ impl QueueTransportControl for OpenHomeRenderer { .queue .lock() .map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?; + let len = queue.len().unwrap_or(0); + let current = queue.current_index().ok().flatten(); + tracing::trace!( + queue_len = len, + current_index = ?current, + "OpenHome play_next: advancing queue" + ); if !queue.advance()? { + tracing::trace!( + queue_len = len, + current_index = ?current, + "OpenHome play_next: advance() returned false — no next track" + ); return Err(ControlPointError::QueueError("No next track".into())); } } diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index c37b0be4..9b0d5eca 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -66,6 +66,50 @@ impl TrackIdsCache { } } +/// Cache for ReadList results to avoid redundant SOAP calls within a short window. +/// Key: sorted list of requested IDs. TTL: 500ms. +#[derive(Debug)] +struct ReadListCache { + ids: Option>, + entries: Option>, + last_update: Option, +} + +impl ReadListCache { + fn new() -> Self { + Self { + ids: None, + entries: None, + last_update: None, + } + } + + fn get(&self, id_list: &[u32]) -> Option> { + if let (Some(cached_ids), Some(entries), Some(last_update)) = + (&self.ids, &self.entries, self.last_update) + { + if let Ok(elapsed) = SystemTime::now().duration_since(last_update) { + if elapsed.as_millis() < 500 && cached_ids.as_slice() == id_list { + return Some(entries.clone()); + } + } + } + None + } + + fn set(&mut self, ids: Vec, entries: Vec) { + self.ids = Some(ids); + self.entries = Some(entries); + self.last_update = Some(SystemTime::now()); + } + + fn invalidate(&mut self) { + self.ids = None; + self.entries = None; + self.last_update = None; + } +} + /// Cache for current track ID to avoid redundant Id SOAP calls #[derive(Debug)] struct CurrentTrackIdCache { @@ -130,6 +174,8 @@ pub struct OpenHomeQueue { track_ids_cache: Arc>, /// Cache for current track ID to avoid redundant Id SOAP calls current_track_id_cache: Arc>, + /// Cache for ReadList results (TTL 500ms) to avoid redundant SOAP calls + read_list_cache: Arc>, } impl OpenHomeQueue { @@ -147,6 +193,7 @@ impl OpenHomeQueue { metadata_cache: Mutex::new(HashMap::new()), track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())), current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())), + read_list_cache: Arc::new(Mutex::new(ReadListCache::new())), } } @@ -406,6 +453,7 @@ impl OpenHomeQueue { // Invalidate cache after playlist modifications self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); Ok(()) } @@ -584,6 +632,7 @@ impl OpenHomeQueue { // Invalidate cache after playlist modifications self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); Ok(()) } @@ -686,6 +735,7 @@ impl OpenHomeQueue { // Invalidate cache after playlist modifications self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); Ok(()) } @@ -890,13 +940,28 @@ impl QueueBackend for OpenHomeQueue { }); } - // Read metadata for all tracks (batched) - // playback_item_from_entry() will prioritize cached metadata over entry metadata + // 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; 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) { + trace!( + renderer = self.renderer_id.0.as_str(), + "ReadList cache hit for {} IDs", + chunk.len() + ); + entries.extend(cached); + continue; + } match self.playlist_client.read_list(chunk) { - Ok(mut batch) => entries.append(&mut batch), + Ok(batch) => { + self.read_list_cache + .lock() + .unwrap() + .set(chunk.to_vec(), batch.clone()); + entries.extend(batch); + } Err(err) => { // If batch fails, try one by one if chunk.len() > 1 { @@ -947,6 +1012,7 @@ impl QueueBackend for OpenHomeQueue { } // 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(); Ok(()) } @@ -972,6 +1038,7 @@ impl QueueBackend for OpenHomeQueue { // 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(); if items.is_empty() { @@ -994,6 +1061,7 @@ impl QueueBackend for OpenHomeQueue { // Invalidate cache after insertions self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); Ok(()) } @@ -1005,6 +1073,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(); return Ok(()); } @@ -1053,6 +1122,15 @@ impl QueueBackend for OpenHomeQueue { .position(|item| item.didl_id == playing_didl_id) }); + tracing::trace!( + renderer = self.renderer_id.0.as_str(), + playing_uri = playing_uri.as_str(), + playing_didl_id = ?playing_didl_id, + pivot_found = new_playing_idx.is_some(), + desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::>(), + "sync_queue: pivot search result" + ); + if let Some(pivot_idx) = new_playing_idx { // CASE 2: Currently playing item IS in the new playlist // Use gentle double-LCS strategy: preserve the pivot and sync before/after separately @@ -1142,6 +1220,7 @@ impl QueueBackend for OpenHomeQueue { // Invalidate cache after playlist modifications self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); Ok(()) } @@ -1187,6 +1266,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(); Ok(()) } @@ -1199,6 +1279,7 @@ impl QueueBackend for OpenHomeQueue { 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(); Ok(()) } From 494cc3b8e12821a51c6480712991deb28cc6273e Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 31 Mar 2026 15:43:12 +0200 Subject: [PATCH 04/14] :recycle: improve playlist reattachment and enhance OpenHome state logging - Skip renderer queue clear when rebinding to same container, triggering gentle refresh instead - Add detailed tracing for STOP commands across renderers and OpenHome clients - Improve `playback_state()` error handling with fallback to Transitioning for empty states - Log queue state (length, current index/track ID) in `play_next` - Add caller location tracing to OpenHome transport actions (`seek_id`, `delete_all`) - Enhance IdArrayResponse logging with raw XML children for debugging - Support both `` and elements in Transport State responses --- .DS_Store | Bin 18436 -> 18436 bytes pmocontrol/src/control_point.rs | 44 ++++++++++++++++-- .../src/music_renderer/musicrenderer.rs | 11 +++++ .../src/music_renderer/openhome_renderer.rs | 30 +++++++++--- pmocontrol/src/queue/openhome.rs | 11 +++++ .../src/upnp_clients/openhome_client.rs | 29 ++++++++++-- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/.DS_Store b/.DS_Store index eb344ba742cb9f4682b87c67b026cc93f58d7a32..58e2f0f507227499b386c330e07dc0c68d077235 100644 GIT binary patch delta 78 zcmZpfz}PZ@ae_Z%&&Gh={ETLsc?6cTZhk4M#5J+OY%{llDnDxl0}xK0DZFr Result<(), ControlPointError> { + // If already bound to the same container on the same server, don't clear the + // renderer queue — that would interrupt active playback. Instead, just trigger + // a gentle refresh (which uses LCS and preserves the currently playing track). + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) + })?; + + let already_bound = renderer + .get_playlist_binding() + .map(|b| b.server_id == *server_id && b.container_id == container_id) + .unwrap_or(false); + + if already_bound { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id, + auto_play, + "Re-attach to same container: skipping clear, triggering gentle refresh" + ); + let mut binding = renderer.get_playlist_binding().unwrap(); + binding.pending_refresh = true; + binding.auto_play_on_refresh = auto_play; + renderer.set_playlist_binding(Some(binding)); + + let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid); + let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> = + if auto_play { + Some(&mut auto_start_cb) + } else { + None + }; + return refresh_attached_queue_for( + &self.registry, + renderer_id, + &self.event_bus, + callback, + ); + } + // CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY // clear the RENDERER queue first (but NOT the local queue cache, which will be // replaced by refresh_attached_queue_for() using replace_entire_playlist()). @@ -1170,10 +1210,6 @@ impl ControlPoint { "Attaching new playlist: clearing renderer queue" ); - // Prepare the renderer for the new playlist (backend-agnostic) - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) - })?; renderer.clear_for_playlist_attach()?; // Sync backend state to local cache (backend-agnostic) diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index f91c8fb0..fdaeea79 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -849,6 +849,10 @@ impl MusicRenderer { } // Then stop playback (ignore errors if already stopped) + tracing::trace!( + renderer = self.id().0.as_str(), + "STOP command via clear_for_playlist_attach" + ); backend.stop().or_else(|err| { warn!( renderer = self.id().0.as_str(), @@ -964,6 +968,7 @@ impl MusicRenderer { } /// Transport control: stop + #[track_caller] pub fn stop(&self) -> Result<(), ControlPointError> { // Reset the has_played flag when stopping playback. // This ensures that if we start a new track, the flag will be false @@ -971,6 +976,12 @@ impl MusicRenderer { // transient STOPPED states during track initialization. self.clear_has_played_flag(); + let caller = std::panic::Location::caller(); + tracing::trace!( + renderer = self.info.friendly_name(), + caller = %caller, + "STOP command sent to renderer" + ); self.lock_backend_for("stop").stop() } diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index f046dcd4..fdef765e 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -342,13 +342,25 @@ impl VolumeControl for OpenHomeRenderer { impl PlaybackStatus for OpenHomeRenderer { fn playback_state(&self) -> Result { let client = self.playlist_client_for("playback_state")?; - let raw = client.transport_state()?; - let mapped = map_openhome_state(&raw); - tracing::trace!( - raw_state = raw.as_str(), - mapped_state = ?mapped, - "OpenHome TransportState" - ); + let raw = client.transport_state().map_err(|err| { + tracing::warn!( + error = %err, + "OpenHome transport_state() failed — state change detection disabled" + ); + err + })?; + let mapped = if raw.is_empty() { + tracing::trace!("OpenHome TransportState: empty (device initializing)"); + PlaybackState::Transitioning + } else { + let mapped = map_openhome_state(&raw); + tracing::trace!( + raw_state = raw.as_str(), + mapped_state = ?mapped, + "OpenHome TransportState" + ); + mapped + }; Ok(mapped) } } @@ -550,9 +562,13 @@ impl QueueTransportControl for OpenHomeRenderer { .map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?; let len = queue.len().unwrap_or(0); let current = queue.current_index().ok().flatten(); + let current_track_id = queue.current_track().ok().flatten(); + let all_ids = queue.track_ids().ok().unwrap_or_default(); tracing::trace!( queue_len = len, current_index = ?current, + current_track_id = ?current_track_id, + all_track_ids = ?all_ids, "OpenHome play_next: advancing queue" ); if !queue.advance()? { diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index 9b0d5eca..dc34859a 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -869,6 +869,13 @@ impl QueueBackend for OpenHomeQueue { // Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls) let ids = self.playlist_client.id_array()?; + tracing::trace!( + renderer = self.renderer_id.0.as_str(), + ids_count = ids.len(), + ids = ?ids, + "track_ids: cache miss, fetched from Pizzicato" + ); + // Update cache before releasing lock cache.set(ids.clone()); @@ -1008,6 +1015,10 @@ impl QueueBackend for OpenHomeQueue { self.playlist_client.seek_id(track_id)?; } else { self.ensure_playlist_source_selected()?; + tracing::trace!( + renderer = self.renderer_id.0.as_str(), + "STOP command via set_index(None) on OpenHome playlist" + ); self.playlist_client.stop()?; } // Invalidate caches (seek_id/stop modifies playlist state and current track) diff --git a/pmocontrol/src/upnp_clients/openhome_client.rs b/pmocontrol/src/upnp_clients/openhome_client.rs index dd8080d3..34bc26e3 100644 --- a/pmocontrol/src/upnp_clients/openhome_client.rs +++ b/pmocontrol/src/upnp_clients/openhome_client.rs @@ -2,8 +2,9 @@ use crate::errors::ControlPointError; use crate::model::TrackMetadata; use crate::soap_client::{ decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text, - extract_child_text_any, extract_child_text_local, extract_child_text_optional, - extract_child_text_optional_local, find_child_with_suffix, handle_action_response, + extract_child_text_allow_empty, extract_child_text_any, extract_child_text_local, + extract_child_text_optional, extract_child_text_optional_local, find_child_with_suffix, + handle_action_response, invoke_upnp_action, parse_bool, parse_visible_flag, }; use anyhow::{Result, anyhow}; @@ -270,7 +271,8 @@ impl OhPlaylistClient { ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string()) })?; - let state = extract_child_text_any(response, &["State", "Value"])?; + // upmpdcli returns , other implementations may use + let state = extract_child_text_any(response, &["Value", "State"])?; Ok(state) } @@ -331,7 +333,10 @@ impl OhPlaylistClient { handle_action_response("SeekSecondAbsolute", &call_result) } + #[track_caller] pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> { + let caller = std::panic::Location::caller(); + tracing::trace!(control_url = self.control_url.as_str(), id, caller = %caller, "OpenHome DeleteId"); let id_str = id.to_string(); let args = [("Value", id_str.as_str())]; @@ -379,7 +384,10 @@ impl OhPlaylistClient { } } + #[track_caller] pub fn delete_all(&self) -> Result<(), ControlPointError> { + let caller = std::panic::Location::caller(); + tracing::trace!(control_url = self.control_url.as_str(), caller = %caller, "OpenHome DeleteAll"); let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?; handle_action_response("DeleteAll", &call_result) @@ -409,6 +417,21 @@ impl OhPlaylistClient { let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse") .ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?; + // Log the raw IdArrayResponse XML for debugging + { + let raw_children: Vec = response.children.iter() + .map(|n: &xmltree::XMLNode| match n { + xmltree::XMLNode::Element(e) => format!("{}={:?}", e.name, e.get_text()), + _ => String::new(), + }) + .filter(|s| !s.is_empty()) + .collect(); + tracing::trace!( + children = ?raw_children, + "id_array: IdArrayResponse children" + ); + } + // Try to extract the array element. If missing, assume empty playlist. let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) { Ok(text) => text, From 2fd4c84e1ab9827823e425b588cd3b1d2d681dd8 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 3 Apr 2026 20:45:35 +0200 Subject: [PATCH 05/14] :sparkles: Add queue refreshing indicator to UI - Introduce `QueueRefreshing` event type for dedicated queue refresh state - Add reactive tracking of refreshing queues via new `queueRefreshingIds` Set in useRenderers composable - Display visual indicator (spinning icon + text) when queue is refreshing in QueueViewer component - Update `picomatch` dependency to v4.0.4 (patch) - Minor cleanup in package-lock.json files --- package-lock.json | 6 ++- pmoapp/webapp/package-lock.json | 6 +-- .../src/components/pmocontrol/QueueViewer.vue | 37 ++++++++++++++++++- pmoapp/webapp/src/composables/useRenderers.ts | 16 ++++++++ .../webapp/src/services/pmocontrol/types.ts | 5 +++ pmocontrol/src/control_point.rs | 5 +-- pmocontrol/src/model.rs | 3 ++ pmocontrol/src/queue/openhome.rs | 11 ++++++ pmocontrol/src/sse.rs | 8 ++++ 9 files changed, 88 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index a13a2658..254f4e73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,9 @@ "name": "pmomusic", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "name": "pmomusic" + } + } } diff --git a/pmoapp/webapp/package-lock.json b/pmoapp/webapp/package-lock.json index 220494aa..1c4d70b6 100644 --- a/pmoapp/webapp/package-lock.json +++ b/pmoapp/webapp/package-lock.json @@ -1458,9 +1458,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue index 6388ce12..49bd8e82 100644 --- a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue +++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue @@ -2,7 +2,7 @@ import { computed, ref, watch, nextTick, toRef } from "vue"; import { useRenderer } from "@/composables/useRenderers"; import QueueItem from "./QueueItem.vue"; -import { Link, Radio } from "lucide-vue-next"; +import { Link, Radio, RefreshCw } from "lucide-vue-next"; import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types"; const props = defineProps<{ @@ -13,7 +13,7 @@ const emit = defineEmits<{ clickItem: [item: QueueItemType]; }>(); -const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId")); +const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId")); const isAttached = computed(() => !!binding.value); @@ -72,6 +72,12 @@ watch( Web Radio + + +
    + + Mise à jour... +
    @@ -162,6 +168,33 @@ watch( font-size: var(--text-xs); } +.refresh-indicator { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + background-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 500; + border: 1px solid var(--color-border); + width: fit-content; +} + +.refresh-icon { + animation: spin 1s linear infinite; +} + +.refresh-text { + font-size: var(--text-xs); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .queue-list { flex: 1; overflow-y: auto; diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index a9947164..e607e8d9 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -20,6 +20,7 @@ interface RendererSnapshotState { lastSnapshotAt: Map; lastEventAt: Map; loadingIds: Set; + queueRefreshingIds: Set; selectedRendererId: string | null; } @@ -32,6 +33,7 @@ const snapshotState = reactive({ lastSnapshotAt: reactive(new Map()), lastEventAt: reactive(new Map()), loadingIds: reactive(new Set()), + queueRefreshingIds: reactive(new Set()), selectedRendererId: null, }); @@ -190,8 +192,13 @@ function ensureSSEConnected() { }); break; + case "queue_refreshing": + snapshotState.queueRefreshingIds.add(rendererId); + break; + case "queue_updated": snapshot.state.queue_len = event.queue_length; + snapshotState.queueRefreshingIds.delete(rendererId); // Pour la queue complète, on doit refetch void fetchRendererSnapshot(rendererId, { force: true }); break; @@ -268,6 +275,10 @@ function isSnapshotLoading(id: string) { return snapshotState.loadingIds.has(id); } +function isQueueRefreshing(id: string) { + return snapshotState.queueRefreshingIds.has(id); +} + function selectRenderer(id: string | null) { snapshotState.selectedRendererId = id; } @@ -454,6 +465,7 @@ export function useRenderers() { getQueueById, getBindingById, isSnapshotLoading, + isQueueRefreshing, selectRenderer, snapshotState, // Fetchers @@ -492,6 +504,9 @@ export function useRenderer(rendererId: Ref) { const queue = computed(() => snapshot.value?.queue ?? null); const binding = computed(() => snapshot.value?.binding ?? null); const isStream = computed(() => snapshot.value?.is_stream ?? false); + const queueRefreshing = computed(() => + snapshotState.queueRefreshingIds.has(rendererId.value), + ); async function refresh(force = true) { await Promise.all([ @@ -507,6 +522,7 @@ export function useRenderer(rendererId: Ref) { queue, binding, isStream, + queueRefreshing, refresh, }; } diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts index 527b57ab..3c8b1daf 100644 --- a/pmoapp/webapp/src/services/pmocontrol/types.ts +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -197,6 +197,11 @@ export type RendererEventPayload = queue_length: number; timestamp: string; } + | { + type: "queue_refreshing"; + renderer_id: string; + timestamp: string; + } | { type: "binding_changed"; renderer_id: string; diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 9a5e0dc3..877117d2 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1585,10 +1585,9 @@ fn refresh_attached_queue_for( return Ok(()); } - // Step 3: Notify UI that the renderer is loading (Transitioning state) - event_bus.broadcast(RendererEvent::StateChanged { + // Step 3: Notify UI that the queue is being refreshed (dedicated event, no state change) + event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone(), - state: PlaybackState::Transitioning, }); // Step 4: Browse container (renamed from Step 3 for clarity) diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 1bd6dd7c..9686d3a9 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -434,6 +434,9 @@ pub enum RendererEvent { id: DeviceId, queue_length: usize, }, + QueueRefreshing { + id: DeviceId, + }, BindingChanged { id: DeviceId, binding: Option, diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index dc34859a..d7047d5a 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -647,6 +647,17 @@ impl OpenHomeQueue { 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(), + desired_count = items.len(), + current_uris = ?snapshot.items.iter().map(|i| i.uri.as_str()).collect::>(), + current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::>(), + desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::>(), + desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::>(), + "LCS input: current vs desired items" + ); + let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items); let items_to_keep = keep_current.iter().filter(|&&k| k).count(); diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index f2a1c79d..d5448592 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -79,6 +79,10 @@ pub enum RendererEventPayload { queue_length: usize, timestamp: chrono::DateTime, }, + QueueRefreshing { + renderer_id: String, + timestamp: chrono::DateTime, + }, BindingChanged { renderer_id: String, server_id: Option, @@ -214,6 +218,10 @@ fn renderer_event_to_payload( queue_length, timestamp, }, + RendererEvent::QueueRefreshing { id } => RendererEventPayload::QueueRefreshing { + renderer_id: id.0, + timestamp, + }, RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged { renderer_id: id.0, server_id: binding.as_ref().map(|b| b.server_id.0.clone()), From 226297510a29969a4b1898fdfc11a36461f57fd7 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 3 Apr 2026 21:31:57 +0200 Subject: [PATCH 06/14] [DIAG] Add queue state diagnostics and fix OpenHome playlist sync - Added diagnostic logging before/after queue operations in control_point.rs and internal/openhome queues - Fixed OpenHome playlist sync to preserve currently playing track instead of clearing queue with delete_all() when current item is in new playlist - Added safety check for empty OpenHome playlists to avoid unintended queue clearing due to stale cache/device state - Minor import reordering and comment cleanup --- pmocontrol/src/control_point.rs | 27 ++++++-- pmocontrol/src/queue/interne.rs | 21 +++++- pmocontrol/src/queue/openhome.rs | 108 ++++++++++++++++++++++++++----- 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 877117d2..5b8b3068 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1596,6 +1596,17 @@ fn refresh_attached_queue_for( const BROWSE_RETRY_DELAY_MS: u64 = 200; const BROWSE_PAGE_SIZE: u32 = 64; + // DIAGNOSTIC: Log the queue state before browsing + let pre_snapshot = renderer.queue_snapshot()?; + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + pre_queue_items = pre_snapshot.items.len(), + pre_current_index = pre_snapshot.current_index, + "refresh_attached_queue_for: queue state BEFORE browse" + ); + // Paginated browse — une playlist peut dépasser BROWSE_PAGE_SIZE items let entries = { let mut all_entries = Vec::new(); @@ -1690,10 +1701,18 @@ fn refresh_attached_queue_for( renderer.sync_queue(new_items)?; - let final_queue_len = { - let snapshot = renderer.queue_snapshot()?; - snapshot.items.len() - }; + // DIAGNOSTIC: Log the queue state after sync_queue + let post_snapshot = renderer.queue_snapshot()?; + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + post_queue_items = post_snapshot.items.len(), + post_current_index = post_snapshot.current_index, + "refresh_attached_queue_for: queue state AFTER sync_queue" + ); + + let final_queue_len = post_snapshot.items.len(); // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { diff --git a/pmocontrol/src/queue/interne.rs b/pmocontrol/src/queue/interne.rs index 372bbd32..2834212a 100644 --- a/pmocontrol/src/queue/interne.rs +++ b/pmocontrol/src/queue/interne.rs @@ -16,9 +16,9 @@ //! - never starts playback (transport control is handled elsewhere). use crate::{ - DeviceId, DeviceIdentity, RendererInfo, errors::ControlPointError, queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}, + DeviceId, DeviceIdentity, RendererInfo, }; /// Internal/local queue implementation. @@ -374,6 +374,16 @@ impl QueueBackend for InternalQueue { ) -> Result<(), ControlPointError> { use crate::queue::EnqueueMode; + // DIAGNOSTIC: Log current queue state before enqueue + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + current_index = self.current_index, + items_count_before = self.items.len(), + mode = ?mode, + items_to_enqueue = items.len(), + "enqueue_items: START" + ); + // Protéger les durées des streams contre la diminution let protected_items = self.protect_stream_durations(items); @@ -398,6 +408,15 @@ impl QueueBackend for InternalQueue { } self.ensure_current_index_invariant(); + + // DIAGNOSTIC: Log queue state after enqueue + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + current_index = self.current_index, + items_count_after = self.items.len(), + "enqueue_items: END" + ); + Ok(()) } diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index d7047d5a..6c53b057 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -8,8 +8,8 @@ use tracing::{debug, trace, warn}; use crate::errors::ControlPointError; use crate::upnp_clients::{ - OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, - OhTrackEntry, + OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry, + OPENHOME_PLAYLIST_HEAD_ID, }; // use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::queue::{ @@ -374,7 +374,7 @@ impl OpenHomeQueue { fresh.as_ref().and_then(|m| m.duration.as_ref()) ); drop(cache); // Libérer le lock avant d'appeler cache_metadata - // Mettre en cache pour éviter les oscillations sur les flux radio + // Mettre en cache pour éviter les oscillations sur les flux radio self.cache_metadata(entry.id, fresh.clone()); fresh } @@ -661,8 +661,8 @@ impl OpenHomeQueue { let (keep_current, keep_desired) = lcs_flags(&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(); - let items_to_add = keep_desired.iter().filter(|&&k| !k).count(); + let items_to_delete = keep_current.iter().filter(|&k| !k).count(); + let items_to_add = keep_desired.iter().filter(|&k| !k).count(); debug!( renderer = self.renderer_id.0.as_str(), @@ -672,16 +672,39 @@ impl OpenHomeQueue { "LCS computed: minimizing OpenHome playlist operations" ); - // If we're replacing everything (keep=0), use delete_all() instead of - // individual delete_id() calls. This is much more robust for live playlists - // where track IDs can become invalid between refresh and deletion. + // Get current playing track ID BEFORE any modifications + let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0); + + // Check if the currently playing track is in the new playlist + // If so, we should NOT use delete_all() - we must preserve it + let current_track_in_new_playlist = current_track_id.and_then(|current_id| { + items + .iter() + .position(|item| item.backend_id as u32 == current_id) + }); + + // If we're replacing everything (keep=0), use delete_all() BUT only if + // there's no currently playing track, OR if the current track is not in the new playlist. + // If current track IS in new playlist, we must preserve it using insert/delete operations. if items_to_keep == 0 && items_to_delete > 0 { - debug!( - renderer = self.renderer_id.0.as_str(), - "Using delete_all() for complete replacement (more robust for live playlists)" - ); - self.playlist_client.delete_all()?; - self.metadata_cache.lock().unwrap().clear(); + if current_track_in_new_playlist.is_some() { + // Current track is in new playlist - use insert/delete instead of delete_all + // to preserve playback + debug!( + renderer = self.renderer_id.0.as_str(), + current_track_in_playlist = true, + "Preserving currently playing track - using insert/delete instead of delete_all" + ); + // Fall through to selective deletion below + } else { + // No current track or not in new playlist - safe to use delete_all + debug!( + renderer = self.renderer_id.0.as_str(), + "Using delete_all() for complete replacement (safe - no current track or not in new playlist)" + ); + self.playlist_client.delete_all()?; + self.metadata_cache.lock().unwrap().clear(); + } } else { // Selective deletion when keeping some items for idx in (0..current_track_ids.len()).rev() { @@ -1090,13 +1113,35 @@ impl QueueBackend for OpenHomeQueue { fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { self.ensure_playlist_source_selected()?; + + // DIAGNOSTIC: Log current track state before any modifications + let pre_current_track = self.playlist_client.id().ok(); + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + pre_current_track_id = pre_current_track, + pre_items_count = items.len(), + "sync_queue: START - current track before modification" + ); + if items.is_empty() { + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + "sync_queue: Empty playlist - clearing queue with delete_all" + ); 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.read_list_cache.lock().unwrap().invalidate(); self.current_track_id_cache.lock().unwrap().invalidate(); + + // DIAGNOSTIC: Log state after delete_all + let post_current_track = self.playlist_client.id().ok(); + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + post_current_track_id = post_current_track, + "sync_queue: END - current track after delete_all (should be 0)" + ); return Ok(()); } @@ -1104,6 +1149,15 @@ impl QueueBackend for OpenHomeQueue { // differences. Without this, any drift between our cache and the renderer // (e.g., manual edits from another control point) would keep the stale items. let snapshot = self.queue_snapshot()?; + + debug!( + renderer = self.renderer_id.0.as_str(), + snapshot_items_len = snapshot.items.len(), + snapshot_current_index = snapshot.current_index, + new_items_count = items.len(), + "sync_queue: snapshot vs new items comparison" + ); + // Note: current_index may point to an index that doesn't exist in items // if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns // IDs but ReadList returns empty TrackList). We must bounds-check here. @@ -1178,6 +1232,20 @@ impl QueueBackend for OpenHomeQueue { } } else { // No currently playing item or can't determine it - use standard LCS + // BUT first check if this is because the OpenHome device returned empty playlist + // This could cause the queue to be cleared incorrectly + if snapshot.items.is_empty() && !items.is_empty() { + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + snapshot_items = snapshot.items.len(), + new_items = items.len(), + "OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue" + ); + // Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue + // Instead, just add the new items without deleting existing ones + return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd); + } + debug!( renderer = self.renderer_id.0.as_str(), "No currently playing item, using standard LCS sync" @@ -1185,6 +1253,16 @@ impl QueueBackend for OpenHomeQueue { self.replace_queue_standard_lcs(items, Some(0))?; } + // DIAGNOSTIC: Log state after sync completes + let post_current_track = self.playlist_client.id().ok(); + let post_ids = self.track_ids(); + tracing::warn!( + renderer = self.renderer_id.0.as_str(), + post_current_track_id = post_current_track, + post_items_count = post_ids.as_ref().map(|v| v.len()).unwrap_or(0), + "sync_queue: END - current track after modifications" + ); + Ok(()) } From 037ddc99ee47580daff1e2645b519c0c080c6768 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 3 Apr 2026 22:26:54 +0200 Subject: [PATCH 07/14] :recycle: Centraliser la gestion SSE dans un composable unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Création d'un nouveau composant `useSSE` pour centraliser la gestion des connexions SSE - Remplacement direct de l'import `sse` par le nouveau composable dans :useMediaServers:, useRenderers: et useWebRenderer: - Refactorisation des fonctions `ensureSSEConnected` → ensureSCEInitialized pour utiliser le composable centralisé - Ajout d'une méthode `connect()` explicite dans chaque usage pour garantir la connexion au bon moment - Suppression des variables `sseConnected` (doublons) et remplacement par un état partagé via `useSSE` - Ajout de hooks spécialisés : useRendererEvents et useMediaServerEvent pour simplifier les abonnements filtrées - Nettoyage des imports obsolètes et harmonisation de la gestion d'état SSE --- .../webapp/src/composables/useMediaServers.ts | 21 +- pmoapp/webapp/src/composables/useRenderers.ts | 26 +- pmoapp/webapp/src/composables/useSSE.ts | 244 ++++++++++++++++++ .../webapp/src/composables/useWebRenderer.ts | 7 +- pmoapp/webapp/tsconfig.tsbuildinfo | 1 + 5 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 pmoapp/webapp/src/composables/useSSE.ts create mode 100644 pmoapp/webapp/tsconfig.tsbuildinfo diff --git a/pmoapp/webapp/src/composables/useMediaServers.ts b/pmoapp/webapp/src/composables/useMediaServers.ts index c5bc0dc0..7a0a0b4d 100644 --- a/pmoapp/webapp/src/composables/useMediaServers.ts +++ b/pmoapp/webapp/src/composables/useMediaServers.ts @@ -4,7 +4,7 @@ */ import { ref, computed } from 'vue' import { api } from '../services/pmocontrol/api' -import { sse } from '../services/pmocontrol/sse' +import { useSSE } from './useSSE' import type { MediaServerSummary, ContainerEntry, @@ -35,12 +35,17 @@ const lastFetch = { const CACHE_DURATION_MS = 2000 -// Connecter SSE une seule fois -let sseConnected = false -function ensureSSEConnected() { - if (sseConnected) return +// Initialiser SSE une seule fois via le composable centralisé +let sseInitialized = false +function ensureSSEInitialized() { + if (sseInitialized) return - sse.onMediaServerEvent((event) => { + const { onMediaServerEvent, connect } = useSSE() + + // Démarrer la connexion SSE + connect() + + onMediaServerEvent((event) => { const serverId = event.server_id switch (event.type) { @@ -98,14 +103,14 @@ function ensureSSEConnected() { } }) - sseConnected = true + sseInitialized = true } /** * Composable principal pour gérer les media servers */ export function useMediaServers() { - ensureSSEConnected() + ensureSSEInitialized() const loading = ref(false) const loadingMore = ref(false) diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index e607e8d9..470ac10a 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -6,7 +6,7 @@ */ import { ref, reactive, computed, toRaw, type Ref } from "vue"; import { api } from "../services/pmocontrol/api"; -import { sse } from "../services/pmocontrol/sse"; +import { useSSE } from "./useSSE"; import type { RendererSummary, RendererState, @@ -40,11 +40,17 @@ const snapshotState = reactive({ const loading = ref(false); const error = ref(null); -let sseConnected = false; -function ensureSSEConnected() { - if (sseConnected) return; +// Utiliser le composable SSE centralisé +let sseInitialized = false; +function ensureSSEInitialized() { + if (sseInitialized) return; - sse.onRendererEvent((event) => { + const { onRendererEvent, connect } = useSSE(); + + // Démarrer la connexion SSE + connect(); + + onRendererEvent((event) => { const rendererId = event.renderer_id; const timestamp = Date.parse(event.timestamp ?? "") || Date.now(); @@ -235,7 +241,7 @@ function ensureSSEConnected() { snapshotState.snapshots.set(rendererId, snapshot); }); - sseConnected = true; + sseInitialized = true; } const allRenderers = computed(() => Array.from(renderersCache.value.values())); @@ -284,7 +290,7 @@ function selectRenderer(id: string | null) { } async function fetchRenderers(force = false) { - ensureSSEConnected(); + ensureSSEInitialized(); const now = Date.now(); if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { @@ -311,7 +317,7 @@ async function fetchRendererSnapshot( rendererId: string, opts?: { force?: boolean }, ) { - ensureSSEConnected(); + ensureSSEInitialized(); const force = opts?.force ?? false; const hasSnapshot = snapshotState.snapshots.has(rendererId); @@ -449,7 +455,7 @@ async function addAfterCurrent( } export function useRenderers() { - ensureSSEConnected(); + ensureSSEInitialized(); return { loading, @@ -494,7 +500,7 @@ export function useRenderers() { } export function useRenderer(rendererId: Ref) { - ensureSSEConnected(); + ensureSSEInitialized(); const renderer = computed(() => renderersCache.value.get(rendererId.value)); const snapshot = computed( diff --git a/pmoapp/webapp/src/composables/useSSE.ts b/pmoapp/webapp/src/composables/useSSE.ts new file mode 100644 index 00000000..97bf1a51 --- /dev/null +++ b/pmoapp/webapp/src/composables/useSSE.ts @@ -0,0 +1,244 @@ +/** + * Composable centralisé pour la gestion des Server-Sent Events + * + * Ce composable fournit une interface unifiée pour: + * - Une seule connexion SSE (évite les connexions multiples) + * - Des abonnements typés aux événements (renderers, servers) + * - Le suivi de l'état de connexion + * + * Usage: + * const { connected, onRendererEvent, onMediaServerEvent } = useSSE() + */ +import { ref, readonly, onUnmounted } from 'vue' +import { sse } from '../services/pmocontrol/sse' +import type { + RendererEventPayload, + MediaServerEventPayload +} from '../services/pmocontrol/types' + +// État global partagé +const connected = ref(sse.isConnectedState()) +const connectionCallbacks: Set<(connected: boolean) => void> = new Set() + +// Abonnement à l'état de connexion global +function setupConnectionListener() { + // S'assurer qu'on ne s'abonne qu'une seule fois + if (connectionCallbacks.size === 0) { + sse.onConnectionChange((isConnected) => { + connected.value = isConnected + connectionCallbacks.forEach(cb => cb(isConnected)) + }) + } +} + +/** + * Hook principal pour utiliser SSE + */ +export function useSSE() { + // S'assurer que le listener de connexion est configuré + setupConnectionListener() + + /** + * Abonnement aux événements de type renderer + * Retourne une fonction de cleanup + */ + function onRendererEvent( + callback: (event: RendererEventPayload) => void + ): () => void { + return sse.onRendererEvent(callback) + } + + /** + * Abonnement aux événements de type media server + * Retourne une fonction de cleanup + */ + function onMediaServerEvent( + callback: (event: MediaServerEventPayload) => void + ): () => void { + return sse.onMediaServerEvent(callback) + } + + /** + * Abonnement aux changements de connexion + * Retourne une fonction de cleanup + */ + function onConnectionChange( + callback: (connected: boolean) => void + ): () => void { + connectionCallbacks.add(callback) + // Appeler immédiatement avec l'état actuel + callback(connected.value) + + // Retourner fonction de cleanup + return () => { + connectionCallbacks.delete(callback) + } + } + + /** + * Force la connexion SSE + */ + function connect(): void { + sse.connect() + } + + /** + * Force la déconnexion SSE + */ + function disconnect(): void { + sse.disconnect() + } + + /** + * Vérifie si actuellement connecté + */ + function isConnected(): boolean { + return connected.value + } + + return { + // État (readonly pour éviter les modifications directes) + connected: readonly(connected), + + // Abonnements + onRendererEvent, + onMediaServerEvent, + onConnectionChange, + + // Actions + connect, + disconnect, + isConnected, + } +} + +/** + * Hook pour s'abonner à un type spécifique d'événement renderer + * avec filtrage optionnel par rendererId + */ +export function useRendererEvents( + rendererId: () => string | null, + options?: { + onStateChanged?: (event: RendererEventPayload) => void + onPositionChanged?: (event: RendererEventPayload) => void + onVolumeChanged?: (event: RendererEventPayload) => void + onMetadataChanged?: (event: RendererEventPayload) => void + onQueueUpdated?: (event: RendererEventPayload) => void + onBindingChanged?: (event: RendererEventPayload) => void + onTimerEvent?: (event: RendererEventPayload) => void + } +) { + const { onRendererEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onRendererEvent((event) => { + const currentId = rendererId() + + // Si un rendererId est spécifié, filtrer + if (currentId && event.renderer_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'state_changed': + case 'online': + case 'offline': + options?.onStateChanged?.(event) + break + case 'position_changed': + options?.onPositionChanged?.(event) + break + case 'volume_changed': + case 'mute_changed': + options?.onVolumeChanged?.(event) + break + case 'metadata_changed': + options?.onMetadataChanged?.(event) + break + case 'queue_updated': + case 'queue_refreshing': + options?.onQueueUpdated?.(event) + break + case 'binding_changed': + options?.onBindingChanged?.(event) + break + case 'timer_started': + case 'timer_updated': + case 'timer_tick': + case 'timer_expired': + case 'timer_cancelled': + options?.onTimerEvent?.(event) + break + } + }) + } + + setup() + + // Cleanup automatique au unmount du composant + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, // Permet de recréer l'abonnement si besoin + } +} + +/** + * Hook pour s'abonner aux événements media server + * avec filtrage optionnel par serverId + */ +export function useMediaServerEvents( + serverId: () => string | null, + options?: { + onOnline?: (event: MediaServerEventPayload) => void + onOffline?: (event: MediaServerEventPayload) => void + onGlobalUpdated?: (event: MediaServerEventPayload) => void + onContainersUpdated?: (event: MediaServerEventPayload) => void + } +) { + const { onMediaServerEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onMediaServerEvent((event) => { + const currentId = serverId() + + // Si un serverId est spécifié, filtrer + if (currentId && event.server_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'online': + options?.onOnline?.(event) + break + case 'offline': + options?.onOffline?.(event) + break + case 'global_updated': + options?.onGlobalUpdated?.(event) + break + case 'containers_updated': + options?.onContainersUpdated?.(event) + break + } + }) + } + + setup() + + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, + } +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index 93bb6a2a..6754b3ad 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -14,7 +14,7 @@ */ import { ref, onMounted, onUnmounted, readonly } from "vue"; -import { sse } from "../services/pmocontrol/sse"; +import { useSSE } from "./useSSE"; // ─── Identifiant stable de l'instance navigateur ───────────────────────────── @@ -200,10 +200,11 @@ export function useWebRenderer() { onConnectedCallback?.(); // S'abonner aux événements SSE du renderer pour piloter la lecture - sse.connect(); + const { connect, onRendererEvent } = useSSE(); + connect(); const udn = data.udn; sseUnsubscribe?.(); - sseUnsubscribe = sse.onRendererEvent((event) => { + sseUnsubscribe = onRendererEvent((event) => { if (event.renderer_id !== udn) return; if (event.type !== "state_changed") return; diff --git a/pmoapp/webapp/tsconfig.tsbuildinfo b/pmoapp/webapp/tsconfig.tsbuildinfo new file mode 100644 index 00000000..8e541cc8 --- /dev/null +++ b/pmoapp/webapp/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"} \ No newline at end of file From 0cb3cbe47052860efcbeaad247f6d5c73514f195 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 3 Apr 2026 22:32:02 +0200 Subject: [PATCH 08/14] =?UTF-8?q?[webapp]=20Optimise=20le=20chargement=20d?= =?UTF-8?q?es=20snapshots=20renderers=20avec=20batch=20control=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajoute la fonction fetchBatchSnapshots pour limiter le nombre de requêtes simultanées (concurrency=3) et évite la saturation réseau avec un délai entre les batches - Remplace le chargement séquentiel des snapshots par une approche batchée dans DashboardView.vue - Introduit un état de chargement (isLoadingSnapshots) et une indiqueur visuel avec Loader2 animé - Charge les renderers/servers en parallèle au montage via Promise.all --- pmoapp/webapp/src/composables/useRenderers.ts | 41 +++++++++++++ pmoapp/webapp/src/views/DashboardView.vue | 57 ++++++++++++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index 470ac10a..e38c40a9 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -345,6 +345,46 @@ async function fetchRendererSnapshot( } } +/** + * Fetch les snapshots de plusieurs renderers en parallèle controlée. + * - Limite le nombre de requêtes simultanées (concurrency) + * - Ajoute un délai entre chaque batch pour ne pas saturer le réseau + * - Continue même si certaines requêtes échouent + */ +async function fetchBatchSnapshots( + rendererIds: string[], + options: { + concurrency?: number; // Nombre max de requêtes parallèles (défaut: 3) + batchDelay?: number; // Délai entre les batches en ms (défaut: 100ms) + force?: boolean; // Forcer le refetch même en cache + } = {} +): Promise { + const { concurrency = 3, batchDelay = 100, force = false } = options; + + // Filtrer les rendererIds valides + const validIds = rendererIds.filter(id => id && typeof id === 'string'); + + if (validIds.length === 0) return; + + // Fonction pour traiter un batch + const processBatch = async (batch: string[]): Promise => { + await Promise.allSettled( + batch.map(id => fetchRendererSnapshot(id, { force })) + ); + }; + + // Exécuter par batches avec controlled concurrency + for (let i = 0; i < validIds.length; i += concurrency) { + const batch = validIds.slice(i, i + concurrency); + await processBatch(batch); + + // Délai entre les batches (sauf pour le dernier) + if (i + concurrency < validIds.length) { + await new Promise(resolve => setTimeout(resolve, batchDelay)); + } + } +} + // Transport controls async function play(id: string) { await api.play(id); @@ -477,6 +517,7 @@ export function useRenderers() { // Fetchers fetchRenderers, fetchRendererSnapshot, + fetchBatchSnapshots, // Transport controls play, resumeOrPlayFromQueue, diff --git a/pmoapp/webapp/src/views/DashboardView.vue b/pmoapp/webapp/src/views/DashboardView.vue index 52a77a40..e4a1b8c3 100644 --- a/pmoapp/webapp/src/views/DashboardView.vue +++ b/pmoapp/webapp/src/views/DashboardView.vue @@ -1,17 +1,17 @@