push-kqxnunnmpyxt #85
@@ -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)
|
||||
@@ -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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,3 +47,5 @@ pmo*_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
RF.json
|
||||
RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
|
||||
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
@@ -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<BaseUrl>` 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<BaseUrl>`.
|
||||
|
||||
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<BaseUrl>,
|
||||
```
|
||||
|
||||
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<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← 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 (`<res>`, `<upnp:albumArtURI>`) 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<BaseUrl>`
|
||||
à 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<BaseUrl>` 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<BaseUrl>`.
|
||||
**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<BaseUrl>` à 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<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← 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<BaseUrl>` 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
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.30"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.30"
|
||||
version = "0.3.32"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2,5 +2,9 @@
|
||||
"name": "pmomusic",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pmomusic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
pmoapp/webapp/package-lock.json
generated
6
pmoapp/webapp/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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<HTMLElement | null>(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"
|
||||
/>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<Search :size="16" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary search-btn" @click="handleSearch">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
v-if="!containers.length && !items.length"
|
||||
class="browser-empty"
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<Radio :size="16" />
|
||||
<span class="stream-text"> Web Radio </span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de mise à jour de queue -->
|
||||
<div v-if="queueRefreshing" class="refresh-indicator">
|
||||
<RefreshCw :size="14" class="refresh-icon" />
|
||||
<span class="refresh-text"> Mise à jour... </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Search bar (visible en mode navigation) -->
|
||||
<div v-if="isNavigating" class="search-bar">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
<button class="search-btn" @click="handleSearch" title="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div ref="drawerContentRef" class="drawer-content">
|
||||
<!-- Liste des serveurs -->
|
||||
@@ -520,6 +559,53 @@ function handleSettingsClick() {
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div v-else-if="isSearchMode && searchResults">
|
||||
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
|
||||
<ul v-else class="content-list">
|
||||
<li
|
||||
v-for="item in searchResults.entries"
|
||||
:key="item.id"
|
||||
class="content-item"
|
||||
:class="{ navigable: item.is_container }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="content-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri && !getImageState(item.id).error"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-img"
|
||||
:class="{ loaded: getImageState(item.id).loaded }"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Folder v-if="item.is_container" :size="24" />
|
||||
<Music v-else :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<p class="content-title">{{ item.title }}</p>
|
||||
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
|
||||
<Play :size="14" />
|
||||
</button>
|
||||
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
<div v-if="openMenuId === item.id" class="item-menu">
|
||||
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
|
||||
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contenu du serveur -->
|
||||
<ul v-else-if="browseData" class="content-list">
|
||||
<li
|
||||
@@ -735,6 +821,62 @@ function handleSettingsClick() {
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.search-clear-btn,
|
||||
.search-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Cache API centralisé pour les requêtes HTTP
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Cache mémoire avec TTL configurable
|
||||
* - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers)
|
||||
* - Invalidation par pattern (ex: invalidate('renderers/*'))
|
||||
* - Subscribe aux changements de données pour reactivity
|
||||
*/
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface ApiCacheOptions {
|
||||
ttl?: number;
|
||||
staleWhileRevalidate?: boolean;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>;
|
||||
subscribers: Set<(data: unknown) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe principale du cache API
|
||||
*/
|
||||
class ApiCacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private subscriptions = new Map<string, Set<(data: unknown) => void>>();
|
||||
|
||||
private options: Required<ApiCacheOptions> = {
|
||||
ttl: 2000,
|
||||
staleWhileRevalidate: true,
|
||||
};
|
||||
|
||||
configure(options: Partial<ApiCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
private makeKey(endpoint: string, params?: Record<string, string | number | boolean>): string {
|
||||
if (!params) return endpoint;
|
||||
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
||||
const query = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||
return `${endpoint}?${query}`;
|
||||
}
|
||||
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < this.options.ttl;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) return null;
|
||||
if (!this.isFresh(key)) {
|
||||
return this.options.staleWhileRevalidate ? entry.data : null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
etag,
|
||||
});
|
||||
|
||||
this.notifySubscribers(key, data);
|
||||
}
|
||||
|
||||
async fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options: { force?: boolean; ttl?: number } = {}
|
||||
): Promise<T> {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const { force = false, ttl } = options;
|
||||
|
||||
if (!force && this.isFresh(key)) {
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const existing = this.pendingRequests.get(key);
|
||||
if (existing) {
|
||||
return existing.promise as Promise<T>;
|
||||
}
|
||||
|
||||
let resolvePromise!: (value: unknown) => void;
|
||||
let rejectPromise!: (reason: unknown) => void;
|
||||
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
this.pendingRequests.set(key, {
|
||||
promise,
|
||||
subscribers: new Set(),
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await fetcher();
|
||||
|
||||
if (ttl) {
|
||||
const originalTtl = this.options.ttl;
|
||||
this.options.ttl = ttl;
|
||||
this.set(endpoint, data, params);
|
||||
this.options.ttl = originalTtl;
|
||||
} else {
|
||||
this.set(endpoint, data, params);
|
||||
}
|
||||
|
||||
resolvePromise(data);
|
||||
|
||||
const pending = this.pendingRequests.get(key);
|
||||
if (pending) {
|
||||
pending.subscribers.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
rejectPromise(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Unreachable'));
|
||||
}
|
||||
|
||||
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
if (!this.subscriptions.has(key)) {
|
||||
this.subscriptions.set(key, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(key)!.add(callback as (data: unknown) => void);
|
||||
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) {
|
||||
callback(cached);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.delete(callback as (data: unknown) => void);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const prefix = pattern.replace('*', '');
|
||||
this.cache.forEach((_, key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (this.cache.has(pattern)) {
|
||||
keysToDelete.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.subscriptions.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
async invalidateAndFetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
this.invalidate(this.makeKey(endpoint, params));
|
||||
return this.fetch(endpoint, params, fetcher, { force: true });
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let fresh = 0;
|
||||
let stale = 0;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.forEach((entry) => {
|
||||
if (now - entry.timestamp < this.options.ttl) {
|
||||
fresh++;
|
||||
} else {
|
||||
stale++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
fresh,
|
||||
stale,
|
||||
pending: this.pendingRequests.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
|
||||
private notifySubscribers(key: string, data: unknown) {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (e) {
|
||||
console.error('[ApiCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiCache = new ApiCacheService();
|
||||
|
||||
export function useApiCache() {
|
||||
return {
|
||||
fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options?: { force?: boolean; ttl?: number }
|
||||
): Promise<T> {
|
||||
return apiCache.fetch(endpoint, params, fetcher, options);
|
||||
},
|
||||
|
||||
subscribe<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
callback: (data: T) => void
|
||||
): () => void {
|
||||
return apiCache.subscribe(endpoint, params, callback);
|
||||
},
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
apiCache.invalidate(pattern);
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
apiCache.clear();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return apiCache.getStats();
|
||||
},
|
||||
};
|
||||
}
|
||||
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Cache centralisé pour les images de couvertures
|
||||
*
|
||||
* Ce service fournit:
|
||||
* - Cache mémoire pour les états de chargement (évite les requêtes doubles)
|
||||
* - Intégration avec le cache serveur (/api/covers)
|
||||
* - Gestion centralisée des retries
|
||||
* - Subscription aux changements d'état (plusieurs composants partagent le même état)
|
||||
*/
|
||||
|
||||
import { ref, computed, onUnmounted, watch, type Ref } from 'vue';
|
||||
|
||||
// Types pour le cache
|
||||
export interface ImageCacheEntry {
|
||||
url: string;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
loading: boolean;
|
||||
retryCount: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCacheOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
useServerCache?: boolean;
|
||||
}
|
||||
|
||||
// Singleton - état du cache global
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, ImageCacheEntry>();
|
||||
private subscriptions = new Map<string, Set<(entry: ImageCacheEntry) => void>>();
|
||||
private options: ImageCacheOptions = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
useServerCache: true,
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
getOrCreate(url: string | null | undefined): ImageCacheEntry | null {
|
||||
if (!url) return null;
|
||||
|
||||
const normalizedUrl = url.replace(/[?&]_cb=[^&]*/, '');
|
||||
const cacheKey = normalizedUrl;
|
||||
|
||||
if (!this.cache.has(cacheKey)) {
|
||||
this.cache.set(cacheKey, {
|
||||
url: normalizedUrl,
|
||||
loaded: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
retryCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
subscribe(url: string | null | undefined, callback: (entry: ImageCacheEntry) => void): () => void {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return () => {};
|
||||
|
||||
const normalizedUrl = entry.url;
|
||||
|
||||
if (!this.subscriptions.has(normalizedUrl)) {
|
||||
this.subscriptions.set(normalizedUrl, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(normalizedUrl)!.add(callback);
|
||||
callback(entry);
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(normalizedUrl);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(normalizedUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startLoading(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markLoaded(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loaded = true;
|
||||
entry.error = false;
|
||||
entry.loading = false;
|
||||
entry.retryCount = 0;
|
||||
entry.lastError = null;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markError(url: string | null | undefined, error: string) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
entry.lastError = error;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
shouldRetry(url: string | null | undefined): boolean {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return false;
|
||||
|
||||
if (entry.retryCount >= (this.options.maxRetries ?? 5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.retryCount++;
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
getRetryDelay(retryCount: number): number {
|
||||
const baseDelay = this.options.retryDelay ?? 500;
|
||||
return baseDelay * Math.pow(2, retryCount - 1);
|
||||
}
|
||||
|
||||
private notifySubscribers(url: string) {
|
||||
const entry = this.cache.get(url);
|
||||
if (!entry) return;
|
||||
|
||||
const subs = this.subscriptions.get(url);
|
||||
if (subs) {
|
||||
subs.forEach(callback => {
|
||||
try {
|
||||
callback(entry);
|
||||
} catch (e) {
|
||||
console.error('[ImageCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.cache.forEach((entry, url) => {
|
||||
const hasSubs = this.subscriptions.has(url);
|
||||
if (!hasSubs && entry.loaded) {
|
||||
toDelete.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach(url => this.cache.delete(url));
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let loaded = 0;
|
||||
let loading = 0;
|
||||
let error = 0;
|
||||
let pending = 0;
|
||||
|
||||
this.cache.forEach(entry => {
|
||||
if (entry.loaded) loaded++;
|
||||
else if (entry.loading) loading++;
|
||||
else if (entry.error) error++;
|
||||
else pending++;
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
pending,
|
||||
subscribers: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const imageCache = new ImageCacheService();
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le cache d'images de manière reactive
|
||||
*/
|
||||
export function useImageCache(imageUrl: Ref<string | null | undefined>) {
|
||||
const entry = ref<ImageCacheEntry | null>(null);
|
||||
const cleanup = ref<(() => void) | null>(null);
|
||||
|
||||
const loading = computed(() => entry.value?.loading ?? false);
|
||||
const loaded = computed(() => entry.value?.loaded ?? false);
|
||||
const error = computed(() => entry.value?.error ?? false);
|
||||
const lastError = computed(() => entry.value?.lastError ?? null);
|
||||
const retryCount = computed(() => entry.value?.retryCount ?? 0);
|
||||
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUrl) => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
cleanup.value = imageCache.subscribe(newUrl, (newEntry) => {
|
||||
entry.value = newEntry;
|
||||
});
|
||||
} else {
|
||||
entry.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.startLoading(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastError,
|
||||
retryCount,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -1,171 +1,182 @@
|
||||
import { ref, watch, onMounted, nextTick, type Ref } from "vue";
|
||||
/**
|
||||
* Composable pour gérer les images de couvertures
|
||||
*
|
||||
* Version optimisée avec:
|
||||
* - Cache centralisé pour partager l'état entre composants
|
||||
* - Intégration optionnelle avec le cache serveur
|
||||
* - Retry automatique avec backoff exponentiel
|
||||
* - Cache-busting pour éviter les problèmes de cache navigateur
|
||||
*/
|
||||
import { ref, watch, computed, type Ref } from "vue";
|
||||
import { imageCache, useImageCache } from "./imageCache";
|
||||
import { simpleHash } from "../utils/string";
|
||||
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
forceReload?: boolean;
|
||||
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options: CoverImageOptions = {},
|
||||
) {
|
||||
const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
|
||||
const {
|
||||
maxRetries = 5,
|
||||
retryDelay = 500,
|
||||
forceReload = true,
|
||||
useServerCache = true
|
||||
} = options;
|
||||
|
||||
// Configurer le cache global
|
||||
imageCache.configure({ maxRetries, retryDelay });
|
||||
|
||||
// État local
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
const retryCount = ref(0);
|
||||
const currentUrl = ref<string | null>(null);
|
||||
const cacheBustedUrl = ref<string | null>(null);
|
||||
const isLoadingNewImage = ref(false);
|
||||
|
||||
// Function to check if the image is already loaded (cached)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Simple hash function for URL
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Function to add cache-busting parameter
|
||||
|
||||
// Utiliser le cache centralisé pour l'état de chargement
|
||||
const cacheEntry = useImageCache(imageUrl);
|
||||
|
||||
// Computed: synchroniser avec le cache centralisé
|
||||
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
|
||||
|
||||
// Génère une URL avec cache-busting
|
||||
function getCacheBustedUrl(url: string, retry: number): string {
|
||||
if (!forceReload && retry === 0) {
|
||||
return url;
|
||||
}
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
// Use URL hash for stable cache-busting, timestamp only for retries
|
||||
const cacheBuster =
|
||||
retry > 0
|
||||
|
||||
// Si on utilise le cache serveur, transformer l'URL
|
||||
if (useServerCache && url.startsWith('http')) {
|
||||
// L'URL sera transformée côté serveur via le cache
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Retry loading the image
|
||||
function retryLoad() {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
if (retryCount.value < maxRetries) {
|
||||
retryCount.value++;
|
||||
|
||||
// Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers
|
||||
// en cours de téléchargement, plus espacé ensuite pour les erreurs réseau
|
||||
const delay = retryDelay * Math.pow(2, retryCount.value - 1);
|
||||
setTimeout(() => {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
// Update cache-busted URL with new retry count
|
||||
cacheBustedUrl.value = getCacheBustedUrl(
|
||||
currentUrl.value,
|
||||
retryCount.value,
|
||||
);
|
||||
}, delay);
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
|
||||
);
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle successful image load
|
||||
// Gère le chargement réussi
|
||||
function handleImageLoad() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.markLoaded(url);
|
||||
}
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
isLoadingNewImage.value = false;
|
||||
}
|
||||
|
||||
// Handle image load error
|
||||
// Gère l'erreur de chargement
|
||||
function handleImageError(event: Event) {
|
||||
const url = imageUrl.value;
|
||||
const img = event.target as HTMLImageElement;
|
||||
console.warn(
|
||||
`[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`,
|
||||
);
|
||||
|
||||
|
||||
console.warn(`[useCoverImage] Image load error: ${img.src}`);
|
||||
|
||||
imageLoaded.value = false;
|
||||
|
||||
// Retry if we haven't reached max retries
|
||||
if (retryCount.value < maxRetries) {
|
||||
const delay = retryDelay * Math.pow(2, retryCount.value);
|
||||
console.log(
|
||||
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
|
||||
);
|
||||
retryLoad();
|
||||
if (url) {
|
||||
// Demander au cache si on doit réessayer
|
||||
if (imageCache.shouldRetry(url)) {
|
||||
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
|
||||
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
|
||||
|
||||
setTimeout(() => {
|
||||
// Générer une nouvelle URL avec retry count
|
||||
const retry = cacheEntry.retryCount.value;
|
||||
cacheBustedUrl.value = getCacheBustedUrl(url, retry);
|
||||
|
||||
// Forcer le rechargement de l'image
|
||||
if (coverImageRef.value) {
|
||||
coverImageRef.value.src = cacheBustedUrl.value;
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
imageError.value = true;
|
||||
imageCache.markError(url, "Max retries reached");
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
|
||||
);
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset image state when URL changes
|
||||
// Watch sur l'URL pour générer la cache-busted URL
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUri, oldUri) => {
|
||||
// Reset de l'état d'erreur
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
|
||||
// Si c'est un changement d'URL (pas l'initialisation)
|
||||
|
||||
// Gestion des transitions
|
||||
if (oldUri && newUri && oldUri !== newUri) {
|
||||
isLoadingNewImage.value = true;
|
||||
// On garde imageLoaded à true pour garder l'ancienne image visible
|
||||
// Garder l'image précédente visible pendant le chargement
|
||||
} else if (!newUri) {
|
||||
// Pas d'URL, on cache tout
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = false;
|
||||
cacheBustedUrl.value = null;
|
||||
} else if (!oldUri && newUri) {
|
||||
// Initialisation, on part de zéro
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = true;
|
||||
}
|
||||
|
||||
currentUrl.value = newUri || null;
|
||||
|
||||
if (newUri) {
|
||||
// Generate cache-busted URL
|
||||
// Indiquer au cache qu'on commence à charger
|
||||
imageCache.startLoading(newUri);
|
||||
|
||||
// Générer l'URL avec cache-busting
|
||||
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
|
||||
} else {
|
||||
cacheBustedUrl.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Check on mount
|
||||
onMounted(() => {
|
||||
currentUrl.value = imageUrl.value || null;
|
||||
if (currentUrl.value) {
|
||||
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
|
||||
// Callback pour le ref de l'image
|
||||
function setImageRef(el: HTMLImageElement | null) {
|
||||
coverImageRef.value = el;
|
||||
|
||||
// Si on a une URL et une référence, initiate le chargement
|
||||
if (el && cacheBustedUrl.value && !imageLoaded.value) {
|
||||
// L'image va commencer à charger naturellement via le src
|
||||
// Le handler handleImageLoad sera appelé quand terminé
|
||||
}
|
||||
checkImageComplete();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
// État
|
||||
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
|
||||
imageError: computed(() => imageError.value || cacheEntry.error.value),
|
||||
coverImageRef: ref(coverImageRef),
|
||||
cacheBustedUrl,
|
||||
isLoadingNewImage,
|
||||
|
||||
// Méthodes
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
setImageRef,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version simplifiée de useCoverImage pour les cas où on n'a pas besoin
|
||||
* de tous les options. Utilise le cache centralisé par défaut.
|
||||
*/
|
||||
export function useCover(url: Ref<string | null | undefined>) {
|
||||
return useCoverImage(url, { forceReload: false, useServerCache: true });
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import { useSSE } from './useSSE'
|
||||
import { apiCache } from './apiCache'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
ContainerEntry,
|
||||
@@ -25,20 +26,22 @@ export interface BrowseState {
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
const lastFetch = {
|
||||
servers: 0
|
||||
}
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
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) {
|
||||
@@ -96,14 +99,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)
|
||||
@@ -115,19 +118,20 @@ export function useMediaServers() {
|
||||
|
||||
// Fetch servers list
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getServers()
|
||||
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/servers',
|
||||
undefined,
|
||||
() => api.getServers(),
|
||||
{ force, ttl: CACHE_DURATION_MS }
|
||||
)
|
||||
|
||||
serversCache.value.clear()
|
||||
data.forEach(s => serversCache.value.set(s.id, s))
|
||||
lastFetch.servers = now
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||
console.error('[useMediaServers] Erreur fetch:', e)
|
||||
@@ -193,6 +197,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 +284,11 @@ export function useMediaServers() {
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Search
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
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 { apiCache } from "./apiCache";
|
||||
import { parseTimeToMs } from "../utils/time";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
@@ -20,29 +22,36 @@ interface RendererSnapshotState {
|
||||
lastSnapshotAt: Map<string, number>;
|
||||
lastEventAt: Map<string, number>;
|
||||
loadingIds: Set<string>;
|
||||
queueRefreshingIds: Set<string>;
|
||||
selectedRendererId: string | null;
|
||||
}
|
||||
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
const lastRenderersFetch = ref(0);
|
||||
|
||||
const snapshotState = reactive<RendererSnapshotState>({
|
||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||
lastEventAt: reactive(new Map<string, number>()),
|
||||
loadingIds: reactive(new Set<string>()),
|
||||
queueRefreshingIds: reactive(new Set<string>()),
|
||||
selectedRendererId: null,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(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();
|
||||
|
||||
@@ -117,35 +126,14 @@ function ensureSSEConnected() {
|
||||
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
||||
|
||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||
if (event.rel_time) {
|
||||
const parts = event.rel_time.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.position_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
} else {
|
||||
// Si rel_time est null/undefined, mettre position à 0
|
||||
snapshot.state.position_ms = 0;
|
||||
}
|
||||
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
||||
snapshot.state.position_ms = positionMs ?? 0;
|
||||
|
||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||
if (event.track_duration) {
|
||||
const parts = event.track_duration.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.duration_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
} else {
|
||||
// Si track_duration est null/undefined (flux continu sans durée),
|
||||
// mettre duration_ms à null pour afficher "--:--"
|
||||
snapshot.state.duration_ms = null;
|
||||
}
|
||||
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||
// Si track_duration est null/undefined (flux continu sans durée),
|
||||
// mettre duration_ms à null pour afficher "--:--"
|
||||
snapshot.state.duration_ms = durationMs;
|
||||
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
|
||||
@@ -190,8 +178,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;
|
||||
@@ -228,7 +221,7 @@ function ensureSSEConnected() {
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
});
|
||||
|
||||
sseConnected = true;
|
||||
sseInitialized = true;
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
||||
@@ -268,26 +261,32 @@ 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;
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected();
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return;
|
||||
}
|
||||
ensureSSEInitialized();
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const data = await api.getRenderers();
|
||||
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/renderers',
|
||||
undefined,
|
||||
() => api.getRenderers(),
|
||||
{ force, ttl: RENDERERS_CACHE_MS }
|
||||
);
|
||||
|
||||
renderersCache.value = new Map(
|
||||
data.map((renderer) => [renderer.id, renderer]),
|
||||
);
|
||||
lastRenderersFetch.value = now;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||
console.error("[useRenderers] Erreur fetch:", err);
|
||||
@@ -300,7 +299,7 @@ async function fetchRendererSnapshot(
|
||||
rendererId: string,
|
||||
opts?: { force?: boolean },
|
||||
) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
||||
|
||||
@@ -328,6 +327,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<void> {
|
||||
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<void> => {
|
||||
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);
|
||||
@@ -438,7 +477,7 @@ async function addAfterCurrent(
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -454,11 +493,13 @@ export function useRenderers() {
|
||||
getQueueById,
|
||||
getBindingById,
|
||||
isSnapshotLoading,
|
||||
isQueueRefreshing,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
fetchBatchSnapshots,
|
||||
// Transport controls
|
||||
play,
|
||||
resumeOrPlayFromQueue,
|
||||
@@ -482,16 +523,16 @@ export function useRenderers() {
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value));
|
||||
const snapshot = computed(
|
||||
() => snapshotState.snapshots.get(rendererId.value) ?? null,
|
||||
);
|
||||
const state = computed(() => snapshot.value?.state ?? null);
|
||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
||||
// Delegates to useRenderers functions - no duplication
|
||||
const renderer = computed(() => getRendererById(rendererId.value));
|
||||
const snapshot = computed(() => getSnapshotById(rendererId.value));
|
||||
const state = computed(() => getStateById(rendererId.value));
|
||||
const queue = computed(() => getQueueById(rendererId.value));
|
||||
const binding = computed(() => getBindingById(rendererId.value));
|
||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
||||
|
||||
async function refresh(force = true) {
|
||||
await Promise.all([
|
||||
@@ -507,6 +548,7 @@ export function useRenderer(rendererId: Ref<string>) {
|
||||
queue,
|
||||
binding,
|
||||
isStream,
|
||||
queueRefreshing,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
244
pmoapp/webapp/src/composables/useSSE.ts
Normal file
244
pmoapp/webapp/src/composables/useSSE.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
pmoapp/webapp/src/utils/string.ts
Normal file
49
pmoapp/webapp/src/utils/string.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Utilitaires pour les chaînes de caractères
|
||||
*/
|
||||
|
||||
/**
|
||||
* Génère un hash simple et rapide pour une chaîne
|
||||
* @param str - Chaîne à hasher
|
||||
* @returns Hash sous forme de chaîne hexadécimale positive
|
||||
*/
|
||||
export function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un paramètre cache-busting à une URL
|
||||
* @param url - URL originale
|
||||
* @param cacheKey - Clé de cache (hash ou timestamp)
|
||||
* @returns URL avec le paramètre _cb ajouté
|
||||
*/
|
||||
export function addCacheBust(url: string, cacheKey: string): string {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_cb=${cacheKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une URL en supprimant les paramètres de cache-busting
|
||||
* @param url - URL avec possibly _cb params
|
||||
* @returns URL nettoyée
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
return url.replace(/[?&]_cb=[^&]*/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tronque une chaîne à une longueur maximale
|
||||
* @param str - Chaîne à tronquer
|
||||
* @param maxLength - Longueur maximale
|
||||
* @returns Chaîne tronquée avec suffix si nécessaire
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
60
pmoapp/webapp/src/utils/time.ts
Normal file
60
pmoapp/webapp/src/utils/time.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utilitaires pour les dates et durées
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une durée au format HH:MM:SS en millisecondes
|
||||
* @param time - Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
* @returns Durée en millisecondes, ou null si invalide
|
||||
*/
|
||||
export function parseTimeToMs(time: string | null | undefined): number | null {
|
||||
if (!time) return null;
|
||||
|
||||
const parts = time.split(':').map(Number);
|
||||
|
||||
if (parts.length === 3) {
|
||||
// HH:MM:SS
|
||||
const hours = parts[0] ?? 0;
|
||||
const minutes = parts[1] ?? 0;
|
||||
const seconds = parts[2] ?? 0;
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||
} else if (parts.length === 2) {
|
||||
// MM:SS
|
||||
const minutes = parts[0] ?? 0;
|
||||
const seconds = parts[1] ?? 0;
|
||||
if (isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (minutes * 60 + seconds) * 1000;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des millisecondes en format HH:MM:SS
|
||||
* @param ms - Durée en millisecondes
|
||||
* @returns Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
*/
|
||||
export function formatMsToTime(ms: number | null): string {
|
||||
if (ms === null || ms === undefined || ms < 0) return '--:--';
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const h = hours > 0 ? `${hours}:` : '';
|
||||
const m = `${minutes.toString().padStart(2, '0')}:`;
|
||||
const s = seconds.toString().padStart(2, '0');
|
||||
|
||||
return `${h}${m}${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des millisecondes en format court (pour l'affichage progress)
|
||||
* @param ms - Durée en millisecondes
|
||||
* @returns Durée au format "X:XX" ou "X:XX:XX"
|
||||
*/
|
||||
export function formatMsToShortTime(ms: number | null): string {
|
||||
return formatMsToTime(ms);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
import { Radio, Server, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const {
|
||||
allRenderers: renderers,
|
||||
onlineRenderers,
|
||||
getStateById,
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot
|
||||
fetchBatchSnapshots
|
||||
} = useRenderers()
|
||||
|
||||
const {
|
||||
@@ -20,15 +20,34 @@ const {
|
||||
fetchServers
|
||||
} = useMediaServers()
|
||||
|
||||
// État de chargement pour l'UI
|
||||
const isLoadingSnapshots = ref(false)
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await fetchRenderers()
|
||||
await fetchServers()
|
||||
// Charger les listes de renderers et servers en parallèle
|
||||
await Promise.all([
|
||||
fetchRenderers(),
|
||||
fetchServers()
|
||||
])
|
||||
|
||||
for (const renderer of renderers.value) {
|
||||
fetchRendererSnapshot(renderer.id, { force: true })
|
||||
// Charger les snapshots en batch controlé
|
||||
// On récupère les IDs des renderers découverts
|
||||
const rendererIds = renderers.value.map(r => r.id)
|
||||
|
||||
if (rendererIds.length > 0) {
|
||||
isLoadingSnapshots.value = true
|
||||
|
||||
// Appel batch avec concurrency controlée
|
||||
await fetchBatchSnapshots(rendererIds, {
|
||||
concurrency: 3, // Max 3 requêtes simultanées
|
||||
batchDelay: 100, // 100ms entre chaque batch
|
||||
force: true
|
||||
})
|
||||
|
||||
isLoadingSnapshots.value = false
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,6 +74,10 @@ onMounted(async () => {
|
||||
<Radio :size="24" />
|
||||
<span>Renderers Audio</span>
|
||||
</h2>
|
||||
<span v-if="isLoadingSnapshots" class="loading-indicator">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="renderers.length" class="renderers-grid">
|
||||
@@ -175,6 +198,24 @@ onMounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-indicator .spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
|
||||
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"}
|
||||
@@ -892,7 +892,13 @@ impl ControlPoint {
|
||||
})?;
|
||||
|
||||
// Check if queue is empty before trying to play next
|
||||
if renderer.len()? == 0 {
|
||||
let queue_len = renderer.len()?;
|
||||
tracing::trace!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
queue_len,
|
||||
"play_next_from_queue: checking queue length"
|
||||
);
|
||||
if queue_len == 0 {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"play_next_from_queue: queue is empty"
|
||||
@@ -1151,6 +1157,46 @@ impl ControlPoint {
|
||||
container_id: &str,
|
||||
auto_play: bool,
|
||||
) -> 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()).
|
||||
@@ -1164,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)
|
||||
@@ -1510,6 +1552,11 @@ fn refresh_attached_queue_for(
|
||||
// Reset the pending_refresh flag and consume auto_play
|
||||
renderer.reset_pending_refresh();
|
||||
let auto_play = renderer.consume_auto_play();
|
||||
tracing::trace!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
auto_play,
|
||||
"refresh_attached_queue_for: auto_play flag consumed"
|
||||
);
|
||||
|
||||
// Step 2: Get server from registry
|
||||
let music_server = {
|
||||
@@ -1538,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)
|
||||
@@ -1550,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();
|
||||
@@ -1644,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 {
|
||||
|
||||
@@ -434,6 +434,9 @@ pub enum RendererEvent {
|
||||
id: DeviceId,
|
||||
queue_length: usize,
|
||||
},
|
||||
QueueRefreshing {
|
||||
id: DeviceId,
|
||||
},
|
||||
BindingChanged {
|
||||
id: DeviceId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
|
||||
@@ -16,28 +16,28 @@ use std::thread::JoinHandle;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::discovery::chromecast_discovery::{
|
||||
extract_host_from_location, extract_port_from_location,
|
||||
};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
use rust_cast::{
|
||||
CastDevice, ChannelMessage,
|
||||
channels::{
|
||||
heartbeat::HeartbeatResponse,
|
||||
media::{Media, PlayerState as CastPlayerState, StreamType},
|
||||
receiver::CastDeviceApp,
|
||||
},
|
||||
CastDevice, ChannelMessage,
|
||||
};
|
||||
|
||||
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
|
||||
@@ -172,6 +172,29 @@ impl ChromecastRenderer {
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Connect to the device with retry on connection failures.
|
||||
/// Uses exponential backoff: 200ms, 400ms, 800ms
|
||||
fn connect_with_retry(&self) -> Result<CastDevice<'_>, ControlPointError> {
|
||||
// Try up to 3 times with exponential backoff
|
||||
for attempt in 0..3 {
|
||||
match connect_to_device(&self.host, self.port) {
|
||||
Ok(device) => return Ok(device),
|
||||
Err(e) if attempt < 2 => {
|
||||
let delay = 200 * 2u64.pow(attempt);
|
||||
tracing::warn!(
|
||||
"Chromecast connection failed (attempt {}/3), retrying in {}ms: {}",
|
||||
attempt + 1,
|
||||
delay,
|
||||
e
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ChromecastRenderer {
|
||||
@@ -231,7 +254,27 @@ impl TransportControl for ChromecastRenderer {
|
||||
let handle = std::thread::spawn(move || {
|
||||
tracing::info!("Play thread starting for URI: {}", uri);
|
||||
|
||||
let device = match connect_to_device(&host, port) {
|
||||
// Connect with retry (inlined for thread context)
|
||||
let device =
|
||||
(|| {
|
||||
for attempt in 0..3 {
|
||||
match connect_to_device(&host, port) {
|
||||
Ok(d) => return Ok(d),
|
||||
Err(e) if attempt < 2 => {
|
||||
let delay = 200 * 2u64.pow(attempt);
|
||||
tracing::warn!(
|
||||
"Chromecast connection failed (attempt {}/3), retrying in {}ms: {}",
|
||||
attempt + 1, delay, e
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
})();
|
||||
|
||||
let device = match device {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect in play thread: {}", e);
|
||||
@@ -334,7 +377,7 @@ impl TransportControl for ChromecastRenderer {
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play()");
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
@@ -379,7 +422,7 @@ impl TransportControl for ChromecastRenderer {
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: pause()");
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
@@ -430,7 +473,7 @@ impl TransportControl for ChromecastRenderer {
|
||||
// If a new play_uri() is called, it will properly wait for this thread.
|
||||
|
||||
// Also send stop command to the device
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
@@ -473,7 +516,7 @@ impl TransportControl for ChromecastRenderer {
|
||||
|
||||
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
@@ -519,17 +562,28 @@ impl TransportControl for ChromecastRenderer {
|
||||
|
||||
impl PlaybackStatus for ChromecastRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} apps running",
|
||||
status.applications.len()
|
||||
);
|
||||
|
||||
// If no app is running, return NoMedia
|
||||
let app = match status.applications.first() {
|
||||
Some(app) => app,
|
||||
None => return Ok(PlaybackState::NoMedia),
|
||||
Some(app) => {
|
||||
tracing::debug!("Chromecast playback_state: app={}", app.display_name);
|
||||
app
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the app
|
||||
@@ -548,10 +602,25 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} media entries",
|
||||
media_status.entries.len()
|
||||
);
|
||||
|
||||
// If no media entry, return NoMedia
|
||||
let media_entry = match media_status.entries.first() {
|
||||
Some(entry) => entry,
|
||||
None => return Ok(PlaybackState::NoMedia),
|
||||
Some(entry) => {
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: player_state={:?}, current_time={:?}",
|
||||
entry.player_state,
|
||||
entry.current_time
|
||||
);
|
||||
entry
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(map_player_state(&media_entry.player_state))
|
||||
@@ -560,7 +629,7 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
|
||||
impl PlaybackPosition for ChromecastRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
@@ -680,7 +749,7 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String {
|
||||
|
||||
impl VolumeControl for ChromecastRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
@@ -696,7 +765,7 @@ impl VolumeControl for ChromecastRenderer {
|
||||
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: set_volume({})", volume);
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let level = (volume as f32) / 100.0;
|
||||
device.receiver.set_volume(level).map_err(|e| {
|
||||
@@ -707,7 +776,7 @@ impl VolumeControl for ChromecastRenderer {
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
@@ -719,7 +788,7 @@ impl VolumeControl for ChromecastRenderer {
|
||||
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: set_mute({})", mute);
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
device.receiver.set_volume(mute).map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::errors::ControlPointError;
|
||||
use crate::events::RendererEventBus;
|
||||
use crate::model::RendererEvent;
|
||||
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
@@ -30,13 +29,12 @@ use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||
use crate::music_renderer::sleep_timer::SleepTimer;
|
||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||
use crate::music_renderer::watcher::{
|
||||
WatchStrategy, WatchedState, extract_track_metadata, playback_position_equal,
|
||||
playback_state_equal,
|
||||
extract_track_metadata, playback_position_equal, playback_state_equal, WatchStrategy,
|
||||
WatchedState,
|
||||
};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::online::DeviceConnectionState;
|
||||
use crate::queue::{
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
|
||||
};
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
|
||||
use tracing::warn;
|
||||
@@ -552,6 +550,12 @@ impl MusicRenderer {
|
||||
|
||||
// Emit event for all state changes including Transitioning
|
||||
if changed {
|
||||
tracing::trace!(
|
||||
renderer = self.info.friendly_name(),
|
||||
prev_state = ?watched.state,
|
||||
new_state = ?raw_state,
|
||||
"Playback state changed"
|
||||
);
|
||||
let state_clone = raw_state.clone();
|
||||
drop(watched);
|
||||
self.emit_event(RendererEvent::StateChanged {
|
||||
@@ -631,6 +635,16 @@ impl MusicRenderer {
|
||||
fn handle_state_change(&self, state: &PlaybackState) {
|
||||
match state {
|
||||
PlaybackState::Stopped => {
|
||||
{
|
||||
let s = self.state.lock().unwrap();
|
||||
tracing::debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
has_played = s.has_played_since_track_start,
|
||||
playback_source = ?s.playback_source,
|
||||
user_stop_requested = s.user_stop_requested,
|
||||
"STOPPED detected — evaluating auto-advance"
|
||||
);
|
||||
}
|
||||
// Check if user requested stop (via Stop button in UI)
|
||||
if self.check_and_clear_user_stop_requested() {
|
||||
debug!(
|
||||
@@ -690,11 +704,84 @@ impl MusicRenderer {
|
||||
self.clear_has_played_flag();
|
||||
}
|
||||
}
|
||||
PlaybackState::NoMedia => {
|
||||
// Handle end of track (Chromecast returns NoMedia when track ends)
|
||||
// This is equivalent to Stopped for auto-advance purposes
|
||||
let s = self.state.lock().unwrap();
|
||||
let playback_source = s.playback_source;
|
||||
let has_played = s.has_played_since_track_start;
|
||||
let user_stop = s.user_stop_requested;
|
||||
drop(s);
|
||||
|
||||
tracing::debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
has_played = has_played,
|
||||
playback_source = ?playback_source,
|
||||
user_stop_requested = user_stop,
|
||||
"NoMedia detected — evaluating auto-advance"
|
||||
);
|
||||
|
||||
// Check if user requested stop (via Stop button in UI)
|
||||
if self.check_and_clear_user_stop_requested() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"NoMedia after user request; not auto-advancing"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
self.clear_has_played_flag();
|
||||
} else if matches!(playback_source, PlaybackSource::FromQueue) {
|
||||
// Auto-advance if we have seen a PLAYING state
|
||||
if self.check_and_clear_has_played_flag() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"NoMedia after queue-driven playback; advancing to next track"
|
||||
);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.play_next_from_queue()
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
error = %err,
|
||||
"Auto-advance from NoMedia failed; clearing queue playback state"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
Err(_panic) => {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Auto-advance from NoMedia panicked; clearing queue playback state"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"NoMedia but no PLAYING state seen yet; ignoring"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
self.clear_has_played_flag();
|
||||
}
|
||||
}
|
||||
PlaybackState::Playing => {
|
||||
self.mark_external_if_idle();
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -825,6 +912,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(),
|
||||
@@ -940,6 +1031,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
|
||||
@@ -947,6 +1039,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()
|
||||
}
|
||||
|
||||
@@ -1570,8 +1668,8 @@ pub(crate) fn build_didl_lite_metadata(
|
||||
uri: &str,
|
||||
protocol_info: &str,
|
||||
) -> String {
|
||||
use pmodidl::{DIDLLite, Item, Resource};
|
||||
use pmodidl::ToXmlElement;
|
||||
use pmodidl::{DIDLLite, Item, Resource};
|
||||
|
||||
// Construire l'Item DIDL avec toutes les métadonnées
|
||||
let item = Item {
|
||||
@@ -1807,7 +1905,6 @@ fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
|
||||
}
|
||||
|
||||
|
||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||
/// the request, returning a standardized error if the backend lacks support.
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
|
||||
@@ -342,8 +342,26 @@ impl VolumeControl for OpenHomeRenderer {
|
||||
impl PlaybackStatus for OpenHomeRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let client = self.playlist_client_for("playback_state")?;
|
||||
let state = client.transport_state()?;
|
||||
Ok(map_openhome_state(&state))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +560,23 @@ 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();
|
||||
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()? {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<query> - 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<ControlPointState>,
|
||||
Path(server_id): Path<String>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
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<ContainerEntry> = 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<ControlPoi
|
||||
"/servers/{server_id}/containers/{container_id}",
|
||||
get(browse_container),
|
||||
)
|
||||
.route("/servers/{server_id}/search", get(search_server))
|
||||
.with_state(state)
|
||||
// SSE events - merge the SSE router
|
||||
.merge(crate::sse::create_sse_router(control_point))
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{
|
||||
@@ -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<Vec<u32>>,
|
||||
entries: Option<Vec<OhTrackEntry>>,
|
||||
last_update: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl ReadListCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ids: None,
|
||||
entries: None,
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, id_list: &[u32]) -> Option<Vec<OhTrackEntry>> {
|
||||
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<u32>, entries: Vec<OhTrackEntry>) {
|
||||
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<Mutex<TrackIdsCache>>,
|
||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||
current_track_id_cache: Arc<Mutex<CurrentTrackIdCache>>,
|
||||
/// Cache for ReadList results (TTL 500ms) to avoid redundant SOAP calls
|
||||
read_list_cache: Arc<Mutex<ReadListCache>>,
|
||||
}
|
||||
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -598,11 +647,22 @@ 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::<Vec<_>>(),
|
||||
current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
|
||||
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
|
||||
desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
|
||||
"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();
|
||||
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(),
|
||||
@@ -612,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() {
|
||||
@@ -686,6 +769,7 @@ impl OpenHomeQueue {
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -819,6 +903,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());
|
||||
|
||||
@@ -890,13 +981,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 {
|
||||
@@ -943,10 +1049,15 @@ 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)
|
||||
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 +1083,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,18 +1106,42 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
// Invalidate cache after insertions
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> 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.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(());
|
||||
}
|
||||
|
||||
@@ -1013,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.
|
||||
@@ -1053,6 +1198,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::<Vec<_>>(),
|
||||
"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
|
||||
@@ -1078,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"
|
||||
@@ -1085,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(())
|
||||
}
|
||||
|
||||
@@ -1142,6 +1320,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 +1366,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 +1379,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(())
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
|
||||
queue_length: usize,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
QueueRefreshing {
|
||||
renderer_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
BindingChanged {
|
||||
renderer_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 <Value>, other implementations may use <State>
|
||||
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<String> = 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,
|
||||
|
||||
@@ -2015,7 +2015,10 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
||||
// 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<Container> = 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<Container> = 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<Item> = tracks
|
||||
// Build containers from artists (manual construction)
|
||||
let artist_containers: Vec<Container> = 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<Container> = 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<Item> = 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![]))
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.30
|
||||
0.3.32
|
||||
|
||||
Reference in New Issue
Block a user