Merge pull request 'push-kqxnunnmpyxt' (#85) from push-kqxnunnmpyxt into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m43s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m43s
Reviewed-on: https://gargoton.petite-maison-orange.fr/eric/pmomusic/pulls/85
This commit was merged in pull request #85.
This commit is contained in:
@@ -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
|
webapp_[0_9]*.txt
|
||||||
RF.json
|
RF.json
|
||||||
RF_old.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]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.30"
|
version = "0.3.32"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.30"
|
version = "0.3.32"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2,5 +2,9 @@
|
|||||||
"name": "pmomusic",
|
"name": "pmomusic",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"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"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useUIStore } from "@/stores/ui";
|
|||||||
import Breadcrumb from "./Breadcrumb.vue";
|
import Breadcrumb from "./Breadcrumb.vue";
|
||||||
import ContainerItem from "./ContainerItem.vue";
|
import ContainerItem from "./ContainerItem.vue";
|
||||||
import MediaItem from "./MediaItem.vue";
|
import MediaItem from "./MediaItem.vue";
|
||||||
import { Loader2 } from "lucide-vue-next";
|
import { Loader2, Search, X } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -22,8 +22,27 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
error,
|
error,
|
||||||
|
searchResults,
|
||||||
|
searchQuery,
|
||||||
|
searchServer,
|
||||||
|
clearSearch,
|
||||||
} = useMediaServers();
|
} = 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 } =
|
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||||
useRenderers();
|
useRenderers();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
@@ -33,7 +52,9 @@ const sentinelRef = ref<HTMLElement | null>(null);
|
|||||||
let observer: IntersectionObserver | null = null;
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
const browseData = computed(() =>
|
const browseData = computed(() =>
|
||||||
getBrowseCached(props.serverId, props.containerId),
|
isSearchMode.value
|
||||||
|
? searchResults.value
|
||||||
|
: getBrowseCached(props.serverId, props.containerId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containers = computed(
|
const containers = computed(
|
||||||
@@ -44,7 +65,7 @@ const items = computed(
|
|||||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
() => 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() {
|
function setupObserver() {
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
@@ -165,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
@navigate="handleNavigate"
|
@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 -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="browser-loading">
|
<div v-if="loading" class="browser-loading">
|
||||||
<Loader2 :size="32" class="spinner" />
|
<Loader2 :size="32" class="spinner" />
|
||||||
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
v-if="!containers.length && !items.length"
|
v-if="!containers.length && !items.length"
|
||||||
class="browser-empty"
|
class="browser-empty"
|
||||||
>
|
>
|
||||||
<p>Ce dossier est vide</p>
|
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sentinel infinite scroll -->
|
<!-- Sentinel infinite scroll -->
|
||||||
@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
color: var(--color-text-secondary);
|
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 */
|
/* Scrollbar styling */
|
||||||
.browser-content::-webkit-scrollbar {
|
.browser-content::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||||
import { useRenderer } from "@/composables/useRenderers";
|
import { useRenderer } from "@/composables/useRenderers";
|
||||||
import QueueItem from "./QueueItem.vue";
|
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";
|
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -13,7 +13,7 @@ const emit = defineEmits<{
|
|||||||
clickItem: [item: QueueItemType];
|
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);
|
const isAttached = computed(() => !!binding.value);
|
||||||
|
|
||||||
@@ -72,6 +72,12 @@ watch(
|
|||||||
<Radio :size="16" />
|
<Radio :size="16" />
|
||||||
<span class="stream-text"> Web Radio </span>
|
<span class="stream-text"> Web Radio </span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,6 +168,33 @@ watch(
|
|||||||
font-size: var(--text-xs);
|
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 {
|
.queue-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -41,8 +41,25 @@ const {
|
|||||||
currentPath,
|
currentPath,
|
||||||
setPath,
|
setPath,
|
||||||
clearPath,
|
clearPath,
|
||||||
|
searchResults,
|
||||||
|
searchQuery,
|
||||||
|
searchServer,
|
||||||
|
clearSearch,
|
||||||
} = useMediaServers();
|
} = 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 } =
|
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||||
useRenderers();
|
useRenderers();
|
||||||
|
|
||||||
@@ -427,6 +444,28 @@ function handleSettingsClick() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</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 -->
|
<!-- Contenu -->
|
||||||
<div ref="drawerContentRef" class="drawer-content">
|
<div ref="drawerContentRef" class="drawer-content">
|
||||||
<!-- Liste des serveurs -->
|
<!-- Liste des serveurs -->
|
||||||
@@ -520,6 +559,53 @@ function handleSettingsClick() {
|
|||||||
<p>Chargement...</p>
|
<p>Chargement...</p>
|
||||||
</div>
|
</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 -->
|
<!-- Contenu du serveur -->
|
||||||
<ul v-else-if="browseData" class="content-list">
|
<ul v-else-if="browseData" class="content-list">
|
||||||
<li
|
<li
|
||||||
@@ -735,6 +821,62 @@ function handleSettingsClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Breadcrumb */
|
/* 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 {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
export interface CoverImageOptions {
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
forceReload?: boolean;
|
forceReload?: boolean;
|
||||||
|
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCoverImage(
|
export function useCoverImage(
|
||||||
imageUrl: Ref<string | null | undefined>,
|
imageUrl: Ref<string | null | undefined>,
|
||||||
options: CoverImageOptions = {},
|
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 imageLoaded = ref(false);
|
||||||
const imageError = ref(false);
|
const imageError = ref(false);
|
||||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||||
const retryCount = ref(0);
|
|
||||||
const currentUrl = ref<string | null>(null);
|
|
||||||
const cacheBustedUrl = ref<string | null>(null);
|
const cacheBustedUrl = ref<string | null>(null);
|
||||||
const isLoadingNewImage = ref(false);
|
const isLoadingNewImage = ref(false);
|
||||||
|
|
||||||
// Function to check if the image is already loaded (cached)
|
// Utiliser le cache centralisé pour l'état de chargement
|
||||||
function checkImageComplete() {
|
const cacheEntry = useImageCache(imageUrl);
|
||||||
nextTick(() => {
|
|
||||||
if (
|
// Computed: synchroniser avec le cache centralisé
|
||||||
coverImageRef.value?.complete &&
|
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
|
||||||
coverImageRef.value?.naturalWidth > 0
|
|
||||||
) {
|
// Génère une URL avec cache-busting
|
||||||
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
|
|
||||||
function getCacheBustedUrl(url: string, retry: number): string {
|
function getCacheBustedUrl(url: string, retry: number): string {
|
||||||
if (!forceReload && retry === 0) {
|
if (!forceReload && retry === 0) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
|
||||||
// Use URL hash for stable cache-busting, timestamp only for retries
|
// Si on utilise le cache serveur, transformer l'URL
|
||||||
const cacheBuster =
|
if (useServerCache && url.startsWith('http')) {
|
||||||
retry > 0
|
// 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)}_r${retry}_${Date.now()}`
|
||||||
: simpleHash(url);
|
: 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}`;
|
return `${url}${separator}_cb=${cacheBuster}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry loading the image
|
// Gère le chargement réussi
|
||||||
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
|
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
|
const url = imageUrl.value;
|
||||||
|
if (url) {
|
||||||
|
imageCache.markLoaded(url);
|
||||||
|
}
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
retryCount.value = 0;
|
|
||||||
isLoadingNewImage.value = false;
|
isLoadingNewImage.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle image load error
|
// Gère l'erreur de chargement
|
||||||
function handleImageError(event: Event) {
|
function handleImageError(event: Event) {
|
||||||
|
const url = imageUrl.value;
|
||||||
const img = event.target as HTMLImageElement;
|
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;
|
imageLoaded.value = false;
|
||||||
|
|
||||||
// Retry if we haven't reached max retries
|
if (url) {
|
||||||
if (retryCount.value < maxRetries) {
|
// Demander au cache si on doit réessayer
|
||||||
const delay = retryDelay * Math.pow(2, retryCount.value);
|
if (imageCache.shouldRetry(url)) {
|
||||||
console.log(
|
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
|
||||||
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
|
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
|
||||||
);
|
|
||||||
retryLoad();
|
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 {
|
} else {
|
||||||
console.error(
|
|
||||||
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
|
|
||||||
);
|
|
||||||
imageError.value = true;
|
imageError.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset image state when URL changes
|
// Watch sur l'URL pour générer la cache-busted URL
|
||||||
watch(
|
watch(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
(newUri, oldUri) => {
|
(newUri, oldUri) => {
|
||||||
|
// Reset de l'état d'erreur
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
retryCount.value = 0;
|
|
||||||
|
// Gestion des transitions
|
||||||
// Si c'est un changement d'URL (pas l'initialisation)
|
|
||||||
if (oldUri && newUri && oldUri !== newUri) {
|
if (oldUri && newUri && oldUri !== newUri) {
|
||||||
isLoadingNewImage.value = true;
|
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) {
|
} else if (!newUri) {
|
||||||
// Pas d'URL, on cache tout
|
|
||||||
imageLoaded.value = false;
|
imageLoaded.value = false;
|
||||||
isLoadingNewImage.value = false;
|
isLoadingNewImage.value = false;
|
||||||
|
cacheBustedUrl.value = null;
|
||||||
} else if (!oldUri && newUri) {
|
} else if (!oldUri && newUri) {
|
||||||
// Initialisation, on part de zéro
|
|
||||||
imageLoaded.value = false;
|
imageLoaded.value = false;
|
||||||
isLoadingNewImage.value = true;
|
isLoadingNewImage.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUrl.value = newUri || null;
|
|
||||||
|
|
||||||
if (newUri) {
|
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);
|
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
|
||||||
} else {
|
} else {
|
||||||
cacheBustedUrl.value = null;
|
cacheBustedUrl.value = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check on mount
|
// Callback pour le ref de l'image
|
||||||
onMounted(() => {
|
function setImageRef(el: HTMLImageElement | null) {
|
||||||
currentUrl.value = imageUrl.value || null;
|
coverImageRef.value = el;
|
||||||
if (currentUrl.value) {
|
|
||||||
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
|
// 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 {
|
return {
|
||||||
imageLoaded,
|
// État
|
||||||
imageError,
|
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
|
||||||
coverImageRef,
|
imageError: computed(() => imageError.value || cacheEntry.error.value),
|
||||||
|
coverImageRef: ref(coverImageRef),
|
||||||
cacheBustedUrl,
|
cacheBustedUrl,
|
||||||
|
isLoadingNewImage,
|
||||||
|
|
||||||
|
// Méthodes
|
||||||
handleImageLoad,
|
handleImageLoad,
|
||||||
handleImageError,
|
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 { ref, computed } from 'vue'
|
||||||
import { api } from '../services/pmocontrol/api'
|
import { api } from '../services/pmocontrol/api'
|
||||||
import { sse } from '../services/pmocontrol/sse'
|
import { useSSE } from './useSSE'
|
||||||
|
import { apiCache } from './apiCache'
|
||||||
import type {
|
import type {
|
||||||
MediaServerSummary,
|
MediaServerSummary,
|
||||||
ContainerEntry,
|
ContainerEntry,
|
||||||
@@ -25,20 +26,22 @@ export interface BrowseState {
|
|||||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||||
const currentPath = ref<BreadcrumbItem[]>([])
|
const currentPath = ref<BreadcrumbItem[]>([])
|
||||||
|
const searchResults = ref<BrowseState | null>(null)
|
||||||
// Timestamps
|
const searchQuery = ref<string>('')
|
||||||
const lastFetch = {
|
|
||||||
servers: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000
|
const CACHE_DURATION_MS = 2000
|
||||||
|
|
||||||
// Connecter SSE une seule fois
|
// Initialiser SSE une seule fois via le composable centralisé
|
||||||
let sseConnected = false
|
let sseInitialized = false
|
||||||
function ensureSSEConnected() {
|
function ensureSSEInitialized() {
|
||||||
if (sseConnected) return
|
if (sseInitialized) return
|
||||||
|
|
||||||
sse.onMediaServerEvent((event) => {
|
const { onMediaServerEvent, connect } = useSSE()
|
||||||
|
|
||||||
|
// Démarrer la connexion SSE
|
||||||
|
connect()
|
||||||
|
|
||||||
|
onMediaServerEvent((event) => {
|
||||||
const serverId = event.server_id
|
const serverId = event.server_id
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -96,14 +99,14 @@ function ensureSSEConnected() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sseConnected = true
|
sseInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable principal pour gérer les media servers
|
* Composable principal pour gérer les media servers
|
||||||
*/
|
*/
|
||||||
export function useMediaServers() {
|
export function useMediaServers() {
|
||||||
ensureSSEConnected()
|
ensureSSEInitialized()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
@@ -115,19 +118,20 @@ export function useMediaServers() {
|
|||||||
|
|
||||||
// Fetch servers list
|
// Fetch servers list
|
||||||
async function fetchServers(force = false) {
|
async function fetchServers(force = false) {
|
||||||
const now = Date.now()
|
|
||||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
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()
|
serversCache.value.clear()
|
||||||
data.forEach(s => serversCache.value.set(s.id, s))
|
data.forEach(s => serversCache.value.set(s.id, s))
|
||||||
lastFetch.servers = now
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||||
console.error('[useMediaServers] Erreur fetch:', e)
|
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
|
// Getters
|
||||||
function getServerById(id: string) {
|
function getServerById(id: string) {
|
||||||
return serversCache.value.get(id)
|
return serversCache.value.get(id)
|
||||||
@@ -247,6 +284,11 @@ export function useMediaServers() {
|
|||||||
getServerById,
|
getServerById,
|
||||||
getBrowseCached,
|
getBrowseCached,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
// Search
|
||||||
|
searchResults,
|
||||||
|
searchQuery,
|
||||||
|
searchServer,
|
||||||
|
clearSearch,
|
||||||
// Actions
|
// Actions
|
||||||
fetchServers,
|
fetchServers,
|
||||||
browseContainer,
|
browseContainer,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||||
import { api } from "../services/pmocontrol/api";
|
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 {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
@@ -20,29 +22,36 @@ interface RendererSnapshotState {
|
|||||||
lastSnapshotAt: Map<string, number>;
|
lastSnapshotAt: Map<string, number>;
|
||||||
lastEventAt: Map<string, number>;
|
lastEventAt: Map<string, number>;
|
||||||
loadingIds: Set<string>;
|
loadingIds: Set<string>;
|
||||||
|
queueRefreshingIds: Set<string>;
|
||||||
selectedRendererId: string | null;
|
selectedRendererId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||||
const RENDERERS_CACHE_MS = 2000;
|
const RENDERERS_CACHE_MS = 2000;
|
||||||
const lastRenderersFetch = ref(0);
|
|
||||||
|
|
||||||
const snapshotState = reactive<RendererSnapshotState>({
|
const snapshotState = reactive<RendererSnapshotState>({
|
||||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||||
lastEventAt: reactive(new Map<string, number>()),
|
lastEventAt: reactive(new Map<string, number>()),
|
||||||
loadingIds: reactive(new Set<string>()),
|
loadingIds: reactive(new Set<string>()),
|
||||||
|
queueRefreshingIds: reactive(new Set<string>()),
|
||||||
selectedRendererId: null,
|
selectedRendererId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
let sseConnected = false;
|
// Utiliser le composable SSE centralisé
|
||||||
function ensureSSEConnected() {
|
let sseInitialized = false;
|
||||||
if (sseConnected) return;
|
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 rendererId = event.renderer_id;
|
||||||
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
|
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)
|
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
||||||
|
|
||||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||||
if (event.rel_time) {
|
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
||||||
const parts = event.rel_time.split(":").map(Number);
|
snapshot.state.position_ms = positionMs ?? 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||||
if (event.track_duration) {
|
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||||
const parts = event.track_duration.split(":").map(Number);
|
// Si track_duration est null/undefined (flux continu sans durée),
|
||||||
if (parts.length === 3) {
|
// mettre duration_ms à null pour afficher "--:--"
|
||||||
snapshot.state.duration_ms =
|
snapshot.state.duration_ms = durationMs;
|
||||||
((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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
// 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
|
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
|
||||||
@@ -190,8 +178,13 @@ function ensureSSEConnected() {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "queue_refreshing":
|
||||||
|
snapshotState.queueRefreshingIds.add(rendererId);
|
||||||
|
break;
|
||||||
|
|
||||||
case "queue_updated":
|
case "queue_updated":
|
||||||
snapshot.state.queue_len = event.queue_length;
|
snapshot.state.queue_len = event.queue_length;
|
||||||
|
snapshotState.queueRefreshingIds.delete(rendererId);
|
||||||
// Pour la queue complète, on doit refetch
|
// Pour la queue complète, on doit refetch
|
||||||
void fetchRendererSnapshot(rendererId, { force: true });
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
break;
|
break;
|
||||||
@@ -228,7 +221,7 @@ function ensureSSEConnected() {
|
|||||||
snapshotState.snapshots.set(rendererId, snapshot);
|
snapshotState.snapshots.set(rendererId, snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
sseConnected = true;
|
sseInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
||||||
@@ -268,26 +261,32 @@ function isSnapshotLoading(id: string) {
|
|||||||
return snapshotState.loadingIds.has(id);
|
return snapshotState.loadingIds.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQueueRefreshing(id: string) {
|
||||||
|
return snapshotState.queueRefreshingIds.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
function selectRenderer(id: string | null) {
|
function selectRenderer(id: string | null) {
|
||||||
snapshotState.selectedRendererId = id;
|
snapshotState.selectedRendererId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRenderers(force = false) {
|
async function fetchRenderers(force = false) {
|
||||||
ensureSSEConnected();
|
ensureSSEInitialized();
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
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(
|
renderersCache.value = new Map(
|
||||||
data.map((renderer) => [renderer.id, renderer]),
|
data.map((renderer) => [renderer.id, renderer]),
|
||||||
);
|
);
|
||||||
lastRenderersFetch.value = now;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||||
console.error("[useRenderers] Erreur fetch:", err);
|
console.error("[useRenderers] Erreur fetch:", err);
|
||||||
@@ -300,7 +299,7 @@ async function fetchRendererSnapshot(
|
|||||||
rendererId: string,
|
rendererId: string,
|
||||||
opts?: { force?: boolean },
|
opts?: { force?: boolean },
|
||||||
) {
|
) {
|
||||||
ensureSSEConnected();
|
ensureSSEInitialized();
|
||||||
const force = opts?.force ?? false;
|
const force = opts?.force ?? false;
|
||||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
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
|
// Transport controls
|
||||||
async function play(id: string) {
|
async function play(id: string) {
|
||||||
await api.play(id);
|
await api.play(id);
|
||||||
@@ -438,7 +477,7 @@ async function addAfterCurrent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRenderers() {
|
export function useRenderers() {
|
||||||
ensureSSEConnected();
|
ensureSSEInitialized();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
@@ -454,11 +493,13 @@ export function useRenderers() {
|
|||||||
getQueueById,
|
getQueueById,
|
||||||
getBindingById,
|
getBindingById,
|
||||||
isSnapshotLoading,
|
isSnapshotLoading,
|
||||||
|
isQueueRefreshing,
|
||||||
selectRenderer,
|
selectRenderer,
|
||||||
snapshotState,
|
snapshotState,
|
||||||
// Fetchers
|
// Fetchers
|
||||||
fetchRenderers,
|
fetchRenderers,
|
||||||
fetchRendererSnapshot,
|
fetchRendererSnapshot,
|
||||||
|
fetchBatchSnapshots,
|
||||||
// Transport controls
|
// Transport controls
|
||||||
play,
|
play,
|
||||||
resumeOrPlayFromQueue,
|
resumeOrPlayFromQueue,
|
||||||
@@ -482,16 +523,16 @@ export function useRenderers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRenderer(rendererId: Ref<string>) {
|
export function useRenderer(rendererId: Ref<string>) {
|
||||||
ensureSSEConnected();
|
ensureSSEInitialized();
|
||||||
|
|
||||||
const renderer = computed(() => renderersCache.value.get(rendererId.value));
|
// Delegates to useRenderers functions - no duplication
|
||||||
const snapshot = computed(
|
const renderer = computed(() => getRendererById(rendererId.value));
|
||||||
() => snapshotState.snapshots.get(rendererId.value) ?? null,
|
const snapshot = computed(() => getSnapshotById(rendererId.value));
|
||||||
);
|
const state = computed(() => getStateById(rendererId.value));
|
||||||
const state = computed(() => snapshot.value?.state ?? null);
|
const queue = computed(() => getQueueById(rendererId.value));
|
||||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
const binding = computed(() => getBindingById(rendererId.value));
|
||||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
|
||||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||||
|
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
||||||
|
|
||||||
async function refresh(force = true) {
|
async function refresh(force = true) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -507,6 +548,7 @@ export function useRenderer(rendererId: Ref<string>) {
|
|||||||
queue,
|
queue,
|
||||||
binding,
|
binding,
|
||||||
isStream,
|
isStream,
|
||||||
|
queueRefreshing,
|
||||||
refresh,
|
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 { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||||
import { sse } from "../services/pmocontrol/sse";
|
import { useSSE } from "./useSSE";
|
||||||
|
|
||||||
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
||||||
|
|
||||||
@@ -200,10 +200,11 @@ export function useWebRenderer() {
|
|||||||
onConnectedCallback?.();
|
onConnectedCallback?.();
|
||||||
|
|
||||||
// S'abonner aux événements SSE du renderer pour piloter la lecture
|
// S'abonner aux événements SSE du renderer pour piloter la lecture
|
||||||
sse.connect();
|
const { connect, onRendererEvent } = useSSE();
|
||||||
|
connect();
|
||||||
const udn = data.udn;
|
const udn = data.udn;
|
||||||
sseUnsubscribe?.();
|
sseUnsubscribe?.();
|
||||||
sseUnsubscribe = sse.onRendererEvent((event) => {
|
sseUnsubscribe = onRendererEvent((event) => {
|
||||||
if (event.renderer_id !== udn) return;
|
if (event.renderer_id !== udn) return;
|
||||||
if (event.type !== "state_changed") 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
|
// SLEEP TIMER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ export type RendererEventPayload =
|
|||||||
queue_length: number;
|
queue_length: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "queue_refreshing";
|
||||||
|
renderer_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "binding_changed";
|
type: "binding_changed";
|
||||||
renderer_id: string;
|
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">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRenderers } from '@/composables/useRenderers'
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
import { useMediaServers } from '@/composables/useMediaServers'
|
import { useMediaServers } from '@/composables/useMediaServers'
|
||||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||||
import { Radio, Server } from 'lucide-vue-next'
|
import { Radio, Server, Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
allRenderers: renderers,
|
allRenderers: renderers,
|
||||||
onlineRenderers,
|
onlineRenderers,
|
||||||
getStateById,
|
getStateById,
|
||||||
fetchRenderers,
|
fetchRenderers,
|
||||||
fetchRendererSnapshot
|
fetchBatchSnapshots
|
||||||
} = useRenderers()
|
} = useRenderers()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -20,15 +20,34 @@ const {
|
|||||||
fetchServers
|
fetchServers
|
||||||
} = useMediaServers()
|
} = useMediaServers()
|
||||||
|
|
||||||
|
// État de chargement pour l'UI
|
||||||
|
const isLoadingSnapshots = ref(false)
|
||||||
|
|
||||||
// Charger les données au montage
|
// Charger les données au montage
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchRenderers()
|
// Charger les listes de renderers et servers en parallèle
|
||||||
await fetchServers()
|
await Promise.all([
|
||||||
|
fetchRenderers(),
|
||||||
|
fetchServers()
|
||||||
|
])
|
||||||
|
|
||||||
for (const renderer of renderers.value) {
|
// Charger les snapshots en batch controlé
|
||||||
fetchRendererSnapshot(renderer.id, { force: true })
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -55,6 +74,10 @@ onMounted(async () => {
|
|||||||
<Radio :size="24" />
|
<Radio :size="24" />
|
||||||
<span>Renderers Audio</span>
|
<span>Renderers Audio</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
<span v-if="isLoadingSnapshots" class="loading-indicator">
|
||||||
|
<Loader2 :size="16" class="spin" />
|
||||||
|
Chargement...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="renderers.length" class="renderers-grid">
|
<div v-if="renderers.length" class="renderers-grid">
|
||||||
@@ -175,6 +198,24 @@ onMounted(async () => {
|
|||||||
color: var(--color-primary);
|
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 */
|
/* Grids */
|
||||||
.renderers-grid,
|
.renderers-grid,
|
||||||
.servers-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
|
// 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!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"play_next_from_queue: queue is empty"
|
"play_next_from_queue: queue is empty"
|
||||||
@@ -1151,6 +1157,46 @@ impl ControlPoint {
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
auto_play: bool,
|
auto_play: bool,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> 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
|
// 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
|
// clear the RENDERER queue first (but NOT the local queue cache, which will be
|
||||||
// replaced by refresh_attached_queue_for() using replace_entire_playlist()).
|
// replaced by refresh_attached_queue_for() using replace_entire_playlist()).
|
||||||
@@ -1164,10 +1210,6 @@ impl ControlPoint {
|
|||||||
"Attaching new playlist: clearing renderer queue"
|
"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()?;
|
renderer.clear_for_playlist_attach()?;
|
||||||
|
|
||||||
// Sync backend state to local cache (backend-agnostic)
|
// 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
|
// Reset the pending_refresh flag and consume auto_play
|
||||||
renderer.reset_pending_refresh();
|
renderer.reset_pending_refresh();
|
||||||
let auto_play = renderer.consume_auto_play();
|
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
|
// Step 2: Get server from registry
|
||||||
let music_server = {
|
let music_server = {
|
||||||
@@ -1538,10 +1585,9 @@ fn refresh_attached_queue_for(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Notify UI that the renderer is loading (Transitioning state)
|
// Step 3: Notify UI that the queue is being refreshed (dedicated event, no state change)
|
||||||
event_bus.broadcast(RendererEvent::StateChanged {
|
event_bus.broadcast(RendererEvent::QueueRefreshing {
|
||||||
id: renderer_id.clone(),
|
id: renderer_id.clone(),
|
||||||
state: PlaybackState::Transitioning,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Browse container (renamed from Step 3 for clarity)
|
// 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_RETRY_DELAY_MS: u64 = 200;
|
||||||
const BROWSE_PAGE_SIZE: u32 = 64;
|
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
|
// Paginated browse — une playlist peut dépasser BROWSE_PAGE_SIZE items
|
||||||
let entries = {
|
let entries = {
|
||||||
let mut all_entries = Vec::new();
|
let mut all_entries = Vec::new();
|
||||||
@@ -1644,10 +1701,18 @@ fn refresh_attached_queue_for(
|
|||||||
|
|
||||||
renderer.sync_queue(new_items)?;
|
renderer.sync_queue(new_items)?;
|
||||||
|
|
||||||
let final_queue_len = {
|
// DIAGNOSTIC: Log the queue state after sync_queue
|
||||||
let snapshot = renderer.queue_snapshot()?;
|
let post_snapshot = renderer.queue_snapshot()?;
|
||||||
snapshot.items.len()
|
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
|
// Emit QueueUpdated event
|
||||||
event_bus.broadcast(RendererEvent::QueueUpdated {
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||||
|
|||||||
@@ -434,6 +434,9 @@ pub enum RendererEvent {
|
|||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
},
|
},
|
||||||
|
QueueRefreshing {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
binding: Option<PlaylistBinding>,
|
binding: Option<PlaylistBinding>,
|
||||||
|
|||||||
@@ -16,28 +16,28 @@ use std::thread::JoinHandle;
|
|||||||
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
|
||||||
use crate::discovery::chromecast_discovery::{
|
use crate::discovery::chromecast_discovery::{
|
||||||
extract_host_from_location, extract_port_from_location,
|
extract_host_from_location, extract_port_from_location,
|
||||||
};
|
};
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
TransportControl, VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
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::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
use crate::DeviceIdentity;
|
||||||
|
|
||||||
use rust_cast::{
|
use rust_cast::{
|
||||||
CastDevice, ChannelMessage,
|
|
||||||
channels::{
|
channels::{
|
||||||
heartbeat::HeartbeatResponse,
|
heartbeat::HeartbeatResponse,
|
||||||
media::{Media, PlayerState as CastPlayerState, StreamType},
|
media::{Media, PlayerState as CastPlayerState, StreamType},
|
||||||
receiver::CastDeviceApp,
|
receiver::CastDeviceApp,
|
||||||
},
|
},
|
||||||
|
CastDevice, ChannelMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
|
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
|
||||||
@@ -172,6 +172,29 @@ impl ChromecastRenderer {
|
|||||||
pub fn is_continuous_stream(&self) -> bool {
|
pub fn is_continuous_stream(&self) -> bool {
|
||||||
*self.continuous_stream.lock().unwrap()
|
*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 {
|
impl TransportControl for ChromecastRenderer {
|
||||||
@@ -231,7 +254,27 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
let handle = std::thread::spawn(move || {
|
let handle = std::thread::spawn(move || {
|
||||||
tracing::info!("Play thread starting for URI: {}", uri);
|
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,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to connect in play thread: {}", e);
|
tracing::error!("Failed to connect in play thread: {}", e);
|
||||||
@@ -334,7 +377,7 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
fn play(&self) -> Result<(), ControlPointError> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: play()");
|
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
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status().map_err(|e| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
@@ -379,7 +422,7 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
fn pause(&self) -> Result<(), ControlPointError> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: pause()");
|
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| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", 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.
|
// If a new play_uri() is called, it will properly wait for this thread.
|
||||||
|
|
||||||
// Also send stop command to the device
|
// 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| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", 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 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| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
@@ -519,17 +562,28 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
|
|
||||||
impl PlaybackStatus for ChromecastRenderer {
|
impl PlaybackStatus for ChromecastRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
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
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status().map_err(|e| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", 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
|
// If no app is running, return NoMedia
|
||||||
let app = match status.applications.first() {
|
let app = match status.applications.first() {
|
||||||
Some(app) => app,
|
Some(app) => {
|
||||||
None => return Ok(PlaybackState::NoMedia),
|
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
|
// Connect to the app
|
||||||
@@ -548,10 +602,25 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
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
|
// If no media entry, return NoMedia
|
||||||
let media_entry = match media_status.entries.first() {
|
let media_entry = match media_status.entries.first() {
|
||||||
Some(entry) => entry,
|
Some(entry) => {
|
||||||
None => return Ok(PlaybackState::NoMedia),
|
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))
|
Ok(map_player_state(&media_entry.player_state))
|
||||||
@@ -560,7 +629,7 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
|
|
||||||
impl PlaybackPosition for ChromecastRenderer {
|
impl PlaybackPosition for ChromecastRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
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
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status().map_err(|e| {
|
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 {
|
impl VolumeControl for ChromecastRenderer {
|
||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
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| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", 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> {
|
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: set_volume({})", volume);
|
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;
|
let level = (volume as f32) / 100.0;
|
||||||
device.receiver.set_volume(level).map_err(|e| {
|
device.receiver.set_volume(level).map_err(|e| {
|
||||||
@@ -707,7 +776,7 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
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| {
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", 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> {
|
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: set_mute({})", mute);
|
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| {
|
device.receiver.set_volume(mute).map_err(|e| {
|
||||||
ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))
|
ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ use crate::errors::ControlPointError;
|
|||||||
use crate::events::RendererEventBus;
|
use crate::events::RendererEventBus;
|
||||||
use crate::model::RendererEvent;
|
use crate::model::RendererEvent;
|
||||||
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
||||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
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::sleep_timer::SleepTimer;
|
||||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||||
use crate::music_renderer::watcher::{
|
use crate::music_renderer::watcher::{
|
||||||
WatchStrategy, WatchedState, extract_track_metadata, playback_position_equal,
|
extract_track_metadata, playback_position_equal, playback_state_equal, WatchStrategy,
|
||||||
playback_state_equal,
|
WatchedState,
|
||||||
};
|
};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::online::DeviceConnectionState;
|
use crate::online::DeviceConnectionState;
|
||||||
use crate::queue::{
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
|
|
||||||
};
|
|
||||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||||
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -552,6 +550,12 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
// Emit event for all state changes including Transitioning
|
// Emit event for all state changes including Transitioning
|
||||||
if changed {
|
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();
|
let state_clone = raw_state.clone();
|
||||||
drop(watched);
|
drop(watched);
|
||||||
self.emit_event(RendererEvent::StateChanged {
|
self.emit_event(RendererEvent::StateChanged {
|
||||||
@@ -631,6 +635,16 @@ impl MusicRenderer {
|
|||||||
fn handle_state_change(&self, state: &PlaybackState) {
|
fn handle_state_change(&self, state: &PlaybackState) {
|
||||||
match state {
|
match state {
|
||||||
PlaybackState::Stopped => {
|
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)
|
// Check if user requested stop (via Stop button in UI)
|
||||||
if self.check_and_clear_user_stop_requested() {
|
if self.check_and_clear_user_stop_requested() {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -690,11 +704,84 @@ impl MusicRenderer {
|
|||||||
self.clear_has_played_flag();
|
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 => {
|
PlaybackState::Playing => {
|
||||||
self.mark_external_if_idle();
|
self.mark_external_if_idle();
|
||||||
// Mark that we have seen a PLAYING state - auto-advance is now allowed
|
// Mark that we have seen a PLAYING state - auto-advance is now allowed
|
||||||
self.set_has_played_flag();
|
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)
|
// 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| {
|
backend.stop().or_else(|err| {
|
||||||
warn!(
|
warn!(
|
||||||
renderer = self.id().0.as_str(),
|
renderer = self.id().0.as_str(),
|
||||||
@@ -940,6 +1031,7 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Transport control: stop
|
/// Transport control: stop
|
||||||
|
#[track_caller]
|
||||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
// Reset the has_played flag when stopping playback.
|
// Reset the has_played flag when stopping playback.
|
||||||
// This ensures that if we start a new track, the flag will be false
|
// This ensures that if we start a new track, the flag will be false
|
||||||
@@ -947,6 +1039,12 @@ impl MusicRenderer {
|
|||||||
// transient STOPPED states during track initialization.
|
// transient STOPPED states during track initialization.
|
||||||
self.clear_has_played_flag();
|
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()
|
self.lock_backend_for("stop").stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,8 +1668,8 @@ pub(crate) fn build_didl_lite_metadata(
|
|||||||
uri: &str,
|
uri: &str,
|
||||||
protocol_info: &str,
|
protocol_info: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
use pmodidl::{DIDLLite, Item, Resource};
|
|
||||||
use pmodidl::ToXmlElement;
|
use pmodidl::ToXmlElement;
|
||||||
|
use pmodidl::{DIDLLite, Item, Resource};
|
||||||
|
|
||||||
// Construire l'Item DIDL avec toutes les métadonnées
|
// Construire l'Item DIDL avec toutes les métadonnées
|
||||||
let item = Item {
|
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))
|
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||||
/// the request, returning a standardized error if the backend lacks support.
|
/// the request, returning a standardized error if the backend lacks support.
|
||||||
impl TransportControl for MusicRendererBackend {
|
impl TransportControl for MusicRendererBackend {
|
||||||
|
|||||||
@@ -342,8 +342,26 @@ impl VolumeControl for OpenHomeRenderer {
|
|||||||
impl PlaybackStatus for OpenHomeRenderer {
|
impl PlaybackStatus for OpenHomeRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
let client = self.playlist_client_for("playback_state")?;
|
let client = self.playlist_client_for("playback_state")?;
|
||||||
let state = client.transport_state()?;
|
let raw = client.transport_state().map_err(|err| {
|
||||||
Ok(map_openhome_state(&state))
|
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
|
.queue
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
|
.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()? {
|
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()));
|
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
|
// ROUTER & TRAIT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2405,6 +2531,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
"/servers/{server_id}/containers/{container_id}",
|
"/servers/{server_id}/containers/{container_id}",
|
||||||
get(browse_container),
|
get(browse_container),
|
||||||
)
|
)
|
||||||
|
.route("/servers/{server_id}/search", get(search_server))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
// SSE events - merge the SSE router
|
// SSE events - merge the SSE router
|
||||||
.merge(crate::sse::create_sse_router(control_point))
|
.merge(crate::sse::create_sse_router(control_point))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
//! - never starts playback (transport control is handled elsewhere).
|
//! - never starts playback (transport control is handled elsewhere).
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DeviceId, DeviceIdentity, RendererInfo,
|
|
||||||
errors::ControlPointError,
|
errors::ControlPointError,
|
||||||
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||||
|
DeviceId, DeviceIdentity, RendererInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Internal/local queue implementation.
|
/// Internal/local queue implementation.
|
||||||
@@ -374,6 +374,16 @@ impl QueueBackend for InternalQueue {
|
|||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
use crate::queue::EnqueueMode;
|
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
|
// Protéger les durées des streams contre la diminution
|
||||||
let protected_items = self.protect_stream_durations(items);
|
let protected_items = self.protect_stream_durations(items);
|
||||||
|
|
||||||
@@ -398,6 +408,15 @@ impl QueueBackend for InternalQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.ensure_current_index_invariant();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use tracing::{debug, trace, warn};
|
|||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::upnp_clients::{
|
use crate::upnp_clients::{
|
||||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack,
|
OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry,
|
||||||
OhTrackEntry,
|
OPENHOME_PLAYLIST_HEAD_ID,
|
||||||
};
|
};
|
||||||
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::queue::{
|
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
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CurrentTrackIdCache {
|
struct CurrentTrackIdCache {
|
||||||
@@ -130,6 +174,8 @@ pub struct OpenHomeQueue {
|
|||||||
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
||||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||||
current_track_id_cache: Arc<Mutex<CurrentTrackIdCache>>,
|
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 {
|
impl OpenHomeQueue {
|
||||||
@@ -147,6 +193,7 @@ impl OpenHomeQueue {
|
|||||||
metadata_cache: Mutex::new(HashMap::new()),
|
metadata_cache: Mutex::new(HashMap::new()),
|
||||||
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
||||||
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::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())
|
fresh.as_ref().and_then(|m| m.duration.as_ref())
|
||||||
);
|
);
|
||||||
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
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());
|
self.cache_metadata(entry.id, fresh.clone());
|
||||||
fresh
|
fresh
|
||||||
}
|
}
|
||||||
@@ -406,6 +453,7 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -584,6 +632,7 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -598,11 +647,22 @@ impl OpenHomeQueue {
|
|||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let current_track_ids = self.track_ids()?;
|
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 (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
|
||||||
|
|
||||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||||
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
|
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
|
||||||
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
|
let items_to_add = keep_desired.iter().filter(|&k| !k).count();
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -612,16 +672,39 @@ impl OpenHomeQueue {
|
|||||||
"LCS computed: minimizing OpenHome playlist operations"
|
"LCS computed: minimizing OpenHome playlist operations"
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we're replacing everything (keep=0), use delete_all() instead of
|
// Get current playing track ID BEFORE any modifications
|
||||||
// individual delete_id() calls. This is much more robust for live playlists
|
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
||||||
// where track IDs can become invalid between refresh and deletion.
|
|
||||||
|
// 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 {
|
if items_to_keep == 0 && items_to_delete > 0 {
|
||||||
debug!(
|
if current_track_in_new_playlist.is_some() {
|
||||||
renderer = self.renderer_id.0.as_str(),
|
// Current track is in new playlist - use insert/delete instead of delete_all
|
||||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
// to preserve playback
|
||||||
);
|
debug!(
|
||||||
self.playlist_client.delete_all()?;
|
renderer = self.renderer_id.0.as_str(),
|
||||||
self.metadata_cache.lock().unwrap().clear();
|
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 {
|
} else {
|
||||||
// Selective deletion when keeping some items
|
// Selective deletion when keeping some items
|
||||||
for idx in (0..current_track_ids.len()).rev() {
|
for idx in (0..current_track_ids.len()).rev() {
|
||||||
@@ -686,6 +769,7 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -819,6 +903,13 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
|
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
|
||||||
let ids = self.playlist_client.id_array()?;
|
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
|
// Update cache before releasing lock
|
||||||
cache.set(ids.clone());
|
cache.set(ids.clone());
|
||||||
|
|
||||||
@@ -890,13 +981,28 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata for all tracks (batched)
|
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
||||||
// playback_item_from_entry() will prioritize cached metadata over entry metadata
|
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
|
||||||
const MAX_BATCH: usize = 64;
|
const MAX_BATCH: usize = 64;
|
||||||
let mut entries = Vec::with_capacity(ids.len());
|
let mut entries = Vec::with_capacity(ids.len());
|
||||||
for chunk in ids.chunks(MAX_BATCH) {
|
for chunk in ids.chunks(MAX_BATCH) {
|
||||||
|
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||||
|
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) {
|
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) => {
|
Err(err) => {
|
||||||
// If batch fails, try one by one
|
// If batch fails, try one by one
|
||||||
if chunk.len() > 1 {
|
if chunk.len() > 1 {
|
||||||
@@ -943,10 +1049,15 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.playlist_client.seek_id(track_id)?;
|
self.playlist_client.seek_id(track_id)?;
|
||||||
} else {
|
} else {
|
||||||
self.ensure_playlist_source_selected()?;
|
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()?;
|
self.playlist_client.stop()?;
|
||||||
}
|
}
|
||||||
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -972,6 +1083,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
@@ -994,18 +1106,42 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after insertions
|
// Invalidate cache after insertions
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
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() {
|
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.playlist_client.delete_all()?;
|
||||||
self.metadata_cache.lock().unwrap().clear();
|
self.metadata_cache.lock().unwrap().clear();
|
||||||
// Invalidate caches after delete_all (clears queue and current track)
|
// Invalidate caches after delete_all (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,6 +1149,15 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
// differences. Without this, any drift between our cache and the renderer
|
// differences. Without this, any drift between our cache and the renderer
|
||||||
// (e.g., manual edits from another control point) would keep the stale items.
|
// (e.g., manual edits from another control point) would keep the stale items.
|
||||||
let snapshot = self.queue_snapshot()?;
|
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
|
// 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
|
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
|
||||||
// IDs but ReadList returns empty TrackList). We must bounds-check here.
|
// 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)
|
.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 {
|
if let Some(pivot_idx) = new_playing_idx {
|
||||||
// CASE 2: Currently playing item IS in the new playlist
|
// CASE 2: Currently playing item IS in the new playlist
|
||||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
||||||
@@ -1078,6 +1232,20 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No currently playing item or can't determine it - use standard LCS
|
// 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!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
"No currently playing item, using standard LCS sync"
|
"No currently playing item, using standard LCS sync"
|
||||||
@@ -1085,6 +1253,16 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1320,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after playlist modifications
|
// Invalidate cache after playlist modifications
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1187,6 +1366,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1199,6 +1379,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.playlist_client.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
// Invalidate caches after clearing playlist (clears queue and current track)
|
// Invalidate caches after clearing playlist (clears queue and current track)
|
||||||
self.track_ids_cache.lock().unwrap().invalidate();
|
self.track_ids_cache.lock().unwrap().invalidate();
|
||||||
|
self.read_list_cache.lock().unwrap().invalidate();
|
||||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
|
|||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
},
|
},
|
||||||
|
QueueRefreshing {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
renderer_id: String,
|
renderer_id: String,
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
@@ -214,6 +218,10 @@ fn renderer_event_to_payload(
|
|||||||
queue_length,
|
queue_length,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
|
RendererEvent::QueueRefreshing { id } => RendererEventPayload::QueueRefreshing {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
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::model::TrackMetadata;
|
||||||
use crate::soap_client::{
|
use crate::soap_client::{
|
||||||
decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text,
|
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_allow_empty, extract_child_text_any, extract_child_text_local,
|
||||||
extract_child_text_optional_local, find_child_with_suffix, handle_action_response,
|
extract_child_text_optional, extract_child_text_optional_local, find_child_with_suffix,
|
||||||
|
handle_action_response,
|
||||||
invoke_upnp_action, parse_bool, parse_visible_flag,
|
invoke_upnp_action, parse_bool, parse_visible_flag,
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
@@ -270,7 +271,8 @@ impl OhPlaylistClient {
|
|||||||
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
|
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)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +333,10 @@ impl OhPlaylistClient {
|
|||||||
handle_action_response("SeekSecondAbsolute", &call_result)
|
handle_action_response("SeekSecondAbsolute", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
|
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 id_str = id.to_string();
|
||||||
let args = [("Value", id_str.as_str())];
|
let args = [("Value", id_str.as_str())];
|
||||||
|
|
||||||
@@ -379,7 +384,10 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
pub fn delete_all(&self) -> Result<(), ControlPointError> {
|
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 =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
||||||
handle_action_response("DeleteAll", &call_result)
|
handle_action_response("DeleteAll", &call_result)
|
||||||
@@ -409,6 +417,21 @@ impl OhPlaylistClient {
|
|||||||
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
||||||
.ok_or_else(|| ControlPointError::upnp_missing_return_value("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.
|
// Try to extract the array element. If missing, assume empty playlist.
|
||||||
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
|
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
|
|||||||
@@ -2015,7 +2015,10 @@ impl MusicSource for QobuzSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
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
|
let results = self
|
||||||
.inner
|
.inner
|
||||||
.client
|
.client
|
||||||
@@ -2023,22 +2026,72 @@ impl MusicSource for QobuzSource {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
let (albums, tracks) = tokio::join!(
|
debug!(
|
||||||
self.cache_covers(results.albums),
|
albums = results.albums.len(),
|
||||||
self.cache_covers(results.tracks),
|
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()
|
.into_iter()
|
||||||
.filter_map(|album| album.to_didl_container("qobuz").ok())
|
.filter_map(|a| a.to_didl_container("qobuz:search").ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let items: Vec<Item> = tracks
|
// Build containers from artists (manual construction)
|
||||||
|
let artist_containers: Vec<Container> = artists
|
||||||
.into_iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
if !containers.is_empty() || !items.is_empty() {
|
// Build containers from playlists
|
||||||
Ok(BrowseResult::Mixed { containers, items })
|
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 {
|
} else {
|
||||||
Ok(BrowseResult::Items(vec![]))
|
Ok(BrowseResult::Items(vec![]))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.30
|
0.3.32
|
||||||
|
|||||||
Reference in New Issue
Block a user