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

Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
2026-04-04 00:04:09 +02:00
37 changed files with 2721 additions and 331 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

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

View File

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

@@ -47,3 +47,5 @@ pmo*_[0_9]*.txt
webapp_[0_9]*.txt
RF.json
RF_old.json
.claude/
.claude.old

View 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
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.30"
version = "0.3.32"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

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

6
package-lock.json generated
View File

@@ -2,5 +2,9 @@
"name": "pmomusic",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"name": "pmomusic"
}
}
}

View File

@@ -1458,9 +1458,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -6,7 +6,7 @@ import { useUIStore } from "@/stores/ui";
import Breadcrumb from "./Breadcrumb.vue";
import ContainerItem from "./ContainerItem.vue";
import MediaItem from "./MediaItem.vue";
import { Loader2 } from "lucide-vue-next";
import { Loader2, Search, X } from "lucide-vue-next";
const props = defineProps<{
serverId: string;
@@ -22,8 +22,27 @@ const {
loading,
loadingMore,
error,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
if (searchInput.value.trim()) {
await searchServer(props.serverId, searchInput.value.trim());
}
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const isSearchMode = computed(() => searchQuery.value !== '');
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
@@ -33,7 +52,9 @@ const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId),
isSearchMode.value
? searchResults.value
: getBrowseCached(props.serverId, props.containerId),
);
const containers = computed(
@@ -44,7 +65,7 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
@@ -165,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
@navigate="handleNavigate"
/>
<!-- Search bar -->
<div class="search-bar">
<div class="search-input-wrapper">
<Search :size="16" class="search-icon" />
<input
v-model="searchInput"
type="text"
class="search-input"
placeholder="Rechercher..."
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
class="search-clear"
@click="handleClearSearch"
title="Effacer"
>
<X :size="14" />
</button>
</div>
<button class="btn btn-primary search-btn" @click="handleSearch">
Rechercher
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="browser-loading">
<Loader2 :size="32" class="spinner" />
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
v-if="!containers.length && !items.length"
class="browser-empty"
>
<p>Ce dossier est vide</p>
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
</div>
<!-- Sentinel infinite scroll -->
@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) {
color: var(--color-text-secondary);
}
/* Search */
.search-bar {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.search-input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: var(--spacing-sm);
color: var(--color-text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: var(--text-sm);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-clear {
position: absolute;
right: var(--spacing-xs);
background: none;
border: none;
cursor: pointer;
color: var(--color-text-tertiary);
display: flex;
align-items: center;
padding: 2px;
}
.search-clear:hover {
color: var(--color-text);
}
.search-btn {
white-space: nowrap;
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--text-sm);
}
/* Scrollbar styling */
.browser-content::-webkit-scrollbar {
width: 6px;

View File

@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, toRef } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import QueueItem from "./QueueItem.vue";
import { Link, Radio } from "lucide-vue-next";
import { Link, Radio, RefreshCw } from "lucide-vue-next";
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
const props = defineProps<{
@@ -13,7 +13,7 @@ const emit = defineEmits<{
clickItem: [item: QueueItemType];
}>();
const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId"));
const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId"));
const isAttached = computed(() => !!binding.value);
@@ -72,6 +72,12 @@ watch(
<Radio :size="16" />
<span class="stream-text"> Web Radio </span>
</div>
<!-- Indicateur de mise à jour de queue -->
<div v-if="queueRefreshing" class="refresh-indicator">
<RefreshCw :size="14" class="refresh-icon" />
<span class="refresh-text"> Mise à jour... </span>
</div>
</div>
</div>
@@ -162,6 +168,33 @@ watch(
font-size: var(--text-xs);
}
.refresh-indicator {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid var(--color-border);
width: fit-content;
}
.refresh-icon {
animation: spin 1s linear infinite;
}
.refresh-text {
font-size: var(--text-xs);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.queue-list {
flex: 1;
overflow-y: auto;

View File

@@ -41,8 +41,25 @@ const {
currentPath,
setPath,
clearPath,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
const isSearchMode = computed(() => searchQuery.value !== '');
async function handleSearch() {
if (!currentServer.value || !searchInput.value.trim()) return;
await searchServer(currentServer.value.id, searchInput.value.trim());
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
useRenderers();
@@ -427,6 +444,28 @@ function handleSettingsClick() {
</button>
</nav>
<!-- Search bar (visible en mode navigation) -->
<div v-if="isNavigating" class="search-bar">
<input
v-model="searchInput"
type="text"
class="search-input"
placeholder="Rechercher..."
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
class="search-clear-btn"
@click="handleClearSearch"
title="Effacer"
>
<X :size="14" />
</button>
<button class="search-btn" @click="handleSearch" title="Rechercher">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
</div>
<!-- Contenu -->
<div ref="drawerContentRef" class="drawer-content">
<!-- Liste des serveurs -->
@@ -520,6 +559,53 @@ function handleSettingsClick() {
<p>Chargement...</p>
</div>
<!-- Résultats de recherche -->
<div v-else-if="isSearchMode && searchResults">
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
<ul v-else class="content-list">
<li
v-for="item in searchResults.entries"
:key="item.id"
class="content-item"
:class="{ navigable: item.is_container }"
@click="handleItemClick(item)"
>
<div class="content-cover">
<img
v-if="item.album_art_uri && !getImageState(item.id).error"
:src="item.album_art_uri"
:alt="item.title"
class="cover-img"
:class="{ loaded: getImageState(item.id).loaded }"
@load="handleImageLoad(item.id)"
@error="handleImageError(item.id)"
/>
<div v-else class="cover-placeholder">
<Folder v-if="item.is_container" :size="24" />
<Music v-else :size="24" />
</div>
</div>
<div class="content-info">
<p class="content-title">{{ item.title }}</p>
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
</div>
<div class="item-actions" @click.stop>
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
<Play :size="14" />
</button>
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
<MoreVertical :size="14" />
</button>
<div v-if="openMenuId === item.id" class="item-menu">
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
</div>
</div>
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
</li>
</ul>
</div>
<!-- Contenu du serveur -->
<ul v-else-if="browseData" class="content-list">
<li
@@ -735,6 +821,62 @@ function handleSettingsClick() {
}
/* Breadcrumb */
.search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.07);
color: var(--color-text);
font-size: var(--text-sm);
min-width: 0;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-tertiary);
}
.search-clear-btn,
.search-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
background: rgba(255, 255, 255, 0.07);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.search-clear-btn:hover,
.search-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--color-text);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.breadcrumb {
display: flex;
align-items: center;

View 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();
},
};
}

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

View File

@@ -1,171 +1,182 @@
import { ref, watch, onMounted, nextTick, type Ref } from "vue";
/**
* Composable pour gérer les images de couvertures
*
* Version optimisée avec:
* - Cache centralisé pour partager l'état entre composants
* - Intégration optionnelle avec le cache serveur
* - Retry automatique avec backoff exponentiel
* - Cache-busting pour éviter les problèmes de cache navigateur
*/
import { ref, watch, computed, type Ref } from "vue";
import { imageCache, useImageCache } from "./imageCache";
import { simpleHash } from "../utils/string";
export interface CoverImageOptions {
maxRetries?: number;
retryDelay?: number;
forceReload?: boolean;
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
}
export function useCoverImage(
imageUrl: Ref<string | null | undefined>,
options: CoverImageOptions = {},
) {
const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
const {
maxRetries = 5,
retryDelay = 500,
forceReload = true,
useServerCache = true
} = options;
// Configurer le cache global
imageCache.configure({ maxRetries, retryDelay });
// État local
const imageLoaded = ref(false);
const imageError = ref(false);
const coverImageRef = ref<HTMLImageElement | null>(null);
const retryCount = ref(0);
const currentUrl = ref<string | null>(null);
const cacheBustedUrl = ref<string | null>(null);
const isLoadingNewImage = ref(false);
// Function to check if the image is already loaded (cached)
function checkImageComplete() {
nextTick(() => {
if (
coverImageRef.value?.complete &&
coverImageRef.value?.naturalWidth > 0
) {
imageLoaded.value = true;
imageError.value = false;
retryCount.value = 0;
}
});
}
// Simple hash function for URL
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
// Function to add cache-busting parameter
// Utiliser le cache centralisé pour l'état de chargement
const cacheEntry = useImageCache(imageUrl);
// Computed: synchroniser avec le cache centralisé
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
// Génère une URL avec cache-busting
function getCacheBustedUrl(url: string, retry: number): string {
if (!forceReload && retry === 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
// Use URL hash for stable cache-busting, timestamp only for retries
const cacheBuster =
retry > 0
// Si on utilise le cache serveur, transformer l'URL
if (useServerCache && url.startsWith('http')) {
// L'URL sera transformée côté serveur via le cache
const separator = url.includes("?") ? "&" : "?";
const cacheBuster = retry > 0
? `${simpleHash(url)}_r${retry}_${Date.now()}`
: simpleHash(url);
return `${url}${separator}_cb=${cacheBuster}`;
}
// Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting
const separator = url.includes("?") ? "&" : "?";
const cacheBuster = retry > 0
? `${simpleHash(url)}_r${retry}_${Date.now()}`
: simpleHash(url);
return `${url}${separator}_cb=${cacheBuster}`;
}
// Retry loading the image
function retryLoad() {
if (!currentUrl.value) return;
if (retryCount.value < maxRetries) {
retryCount.value++;
// Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers
// en cours de téléchargement, plus espacé ensuite pour les erreurs réseau
const delay = retryDelay * Math.pow(2, retryCount.value - 1);
setTimeout(() => {
if (!currentUrl.value) return;
// Update cache-busted URL with new retry count
cacheBustedUrl.value = getCacheBustedUrl(
currentUrl.value,
retryCount.value,
);
}, delay);
} else {
console.error(
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
);
imageError.value = true;
}
}
// Handle successful image load
// Gère le chargement réussi
function handleImageLoad() {
const url = imageUrl.value;
if (url) {
imageCache.markLoaded(url);
}
imageLoaded.value = true;
imageError.value = false;
retryCount.value = 0;
isLoadingNewImage.value = false;
}
// Handle image load error
// Gère l'erreur de chargement
function handleImageError(event: Event) {
const url = imageUrl.value;
const img = event.target as HTMLImageElement;
console.warn(
`[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`,
);
console.warn(`[useCoverImage] Image load error: ${img.src}`);
imageLoaded.value = false;
// Retry if we haven't reached max retries
if (retryCount.value < maxRetries) {
const delay = retryDelay * Math.pow(2, retryCount.value);
console.log(
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
);
retryLoad();
if (url) {
// Demander au cache si on doit réessayer
if (imageCache.shouldRetry(url)) {
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
setTimeout(() => {
// Générer une nouvelle URL avec retry count
const retry = cacheEntry.retryCount.value;
cacheBustedUrl.value = getCacheBustedUrl(url, retry);
// Forcer le rechargement de l'image
if (coverImageRef.value) {
coverImageRef.value.src = cacheBustedUrl.value;
}
}, delay);
} else {
imageError.value = true;
imageCache.markError(url, "Max retries reached");
}
} else {
console.error(
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
);
imageError.value = true;
}
}
// Reset image state when URL changes
// Watch sur l'URL pour générer la cache-busted URL
watch(
imageUrl,
(newUri, oldUri) => {
// Reset de l'état d'erreur
imageError.value = false;
retryCount.value = 0;
// Si c'est un changement d'URL (pas l'initialisation)
// Gestion des transitions
if (oldUri && newUri && oldUri !== newUri) {
isLoadingNewImage.value = true;
// On garde imageLoaded à true pour garder l'ancienne image visible
// Garder l'image précédente visible pendant le chargement
} else if (!newUri) {
// Pas d'URL, on cache tout
imageLoaded.value = false;
isLoadingNewImage.value = false;
cacheBustedUrl.value = null;
} else if (!oldUri && newUri) {
// Initialisation, on part de zéro
imageLoaded.value = false;
isLoadingNewImage.value = true;
}
currentUrl.value = newUri || null;
if (newUri) {
// Generate cache-busted URL
// Indiquer au cache qu'on commence à charger
imageCache.startLoading(newUri);
// Générer l'URL avec cache-busting
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
} else {
cacheBustedUrl.value = null;
}
},
{ immediate: true },
{ immediate: true }
);
// Check on mount
onMounted(() => {
currentUrl.value = imageUrl.value || null;
if (currentUrl.value) {
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
// Callback pour le ref de l'image
function setImageRef(el: HTMLImageElement | null) {
coverImageRef.value = el;
// Si on a une URL et une référence, initiate le chargement
if (el && cacheBustedUrl.value && !imageLoaded.value) {
// L'image va commencer à charger naturellement via le src
// Le handler handleImageLoad sera appelé quand terminé
}
checkImageComplete();
});
}
return {
imageLoaded,
imageError,
coverImageRef,
// État
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
imageError: computed(() => imageError.value || cacheEntry.error.value),
coverImageRef: ref(coverImageRef),
cacheBustedUrl,
isLoadingNewImage,
// Méthodes
handleImageLoad,
handleImageError,
setImageRef,
};
}
/**
* Version simplifiée de useCoverImage pour les cas où on n'a pas besoin
* de tous les options. Utilise le cache centralisé par défaut.
*/
export function useCover(url: Ref<string | null | undefined>) {
return useCoverImage(url, { forceReload: false, useServerCache: true });
}

View File

@@ -4,7 +4,8 @@
*/
import { ref, computed } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import { useSSE } from './useSSE'
import { apiCache } from './apiCache'
import type {
MediaServerSummary,
ContainerEntry,
@@ -25,20 +26,22 @@ export interface BrowseState {
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
// Timestamps
const lastFetch = {
servers: 0
}
const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
const CACHE_DURATION_MS = 2000
// Connecter SSE une seule fois
let sseConnected = false
function ensureSSEConnected() {
if (sseConnected) return
// Initialiser SSE une seule fois via le composable centralisé
let sseInitialized = false
function ensureSSEInitialized() {
if (sseInitialized) return
sse.onMediaServerEvent((event) => {
const { onMediaServerEvent, connect } = useSSE()
// Démarrer la connexion SSE
connect()
onMediaServerEvent((event) => {
const serverId = event.server_id
switch (event.type) {
@@ -96,14 +99,14 @@ function ensureSSEConnected() {
}
})
sseConnected = true
sseInitialized = true
}
/**
* Composable principal pour gérer les media servers
*/
export function useMediaServers() {
ensureSSEConnected()
ensureSSEInitialized()
const loading = ref(false)
const loadingMore = ref(false)
@@ -115,19 +118,20 @@ export function useMediaServers() {
// Fetch servers list
async function fetchServers(force = false) {
const now = Date.now()
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
return
}
try {
loading.value = true
error.value = null
const data = await api.getServers()
// Utiliser le cache API centralisé
const data = await apiCache.fetch(
'/servers',
undefined,
() => api.getServers(),
{ force, ttl: CACHE_DURATION_MS }
)
serversCache.value.clear()
data.forEach(s => serversCache.value.set(s.id, s))
lastFetch.servers = now
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
console.error('[useMediaServers] Erreur fetch:', e)
@@ -193,6 +197,39 @@ export function useMediaServers() {
}
}
// Recherche dans un serveur
async function searchServer(serverId: string, query: string) {
if (!query.trim()) {
searchResults.value = null
searchQuery.value = ''
return
}
try {
loading.value = true
error.value = null
searchQuery.value = query
const data = await api.searchServer(serverId, query)
searchResults.value = {
container_id: 'search',
entries: data.entries,
total_count: data.total_count,
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur recherche'
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
throw e
} finally {
loading.value = false
}
}
function clearSearch() {
searchResults.value = null
searchQuery.value = ''
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -247,6 +284,11 @@ export function useMediaServers() {
getServerById,
getBrowseCached,
hasMore,
// Search
searchResults,
searchQuery,
searchServer,
clearSearch,
// Actions
fetchServers,
browseContainer,

View File

@@ -6,7 +6,9 @@
*/
import { ref, reactive, computed, toRaw, type Ref } from "vue";
import { api } from "../services/pmocontrol/api";
import { sse } from "../services/pmocontrol/sse";
import { useSSE } from "./useSSE";
import { apiCache } from "./apiCache";
import { parseTimeToMs } from "../utils/time";
import type {
RendererSummary,
RendererState,
@@ -20,29 +22,36 @@ interface RendererSnapshotState {
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
queueRefreshingIds: Set<string>;
selectedRendererId: string | null;
}
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
const lastRenderersFetch = ref(0);
const snapshotState = reactive<RendererSnapshotState>({
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
lastSnapshotAt: reactive(new Map<string, number>()),
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
queueRefreshingIds: reactive(new Set<string>()),
selectedRendererId: null,
});
const loading = ref(false);
const error = ref<string | null>(null);
let sseConnected = false;
function ensureSSEConnected() {
if (sseConnected) return;
// Utiliser le composable SSE centralisé
let sseInitialized = false;
function ensureSSEInitialized() {
if (sseInitialized) return;
sse.onRendererEvent((event) => {
const { onRendererEvent, connect } = useSSE();
// Démarrer la connexion SSE
connect();
onRendererEvent((event) => {
const rendererId = event.renderer_id;
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
@@ -117,35 +126,14 @@ function ensureSSEConnected() {
// Le backend envoie TOUJOURS les deux valeurs (même si null)
// Convertir rel_time (HH:MM:SS) en millisecondes
if (event.rel_time) {
const parts = event.rel_time.split(":").map(Number);
if (parts.length === 3) {
snapshot.state.position_ms =
((parts[0] ?? 0) * 3600 +
(parts[1] ?? 0) * 60 +
(parts[2] ?? 0)) *
1000;
}
} else {
// Si rel_time est null/undefined, mettre position à 0
snapshot.state.position_ms = 0;
}
const positionMs = parseTimeToMs(event.rel_time ?? null);
snapshot.state.position_ms = positionMs ?? 0;
// Convertir track_duration (HH:MM:SS) en millisecondes
if (event.track_duration) {
const parts = event.track_duration.split(":").map(Number);
if (parts.length === 3) {
snapshot.state.duration_ms =
((parts[0] ?? 0) * 3600 +
(parts[1] ?? 0) * 60 +
(parts[2] ?? 0)) *
1000;
}
} else {
// Si track_duration est null/undefined (flux continu sans durée),
// mettre duration_ms à null pour afficher "--:--"
snapshot.state.duration_ms = null;
}
const durationMs = parseTimeToMs(event.track_duration ?? null);
// Si track_duration est null/undefined (flux continu sans durée),
// mettre duration_ms à null pour afficher "--:--"
snapshot.state.duration_ms = durationMs;
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
@@ -190,8 +178,13 @@ function ensureSSEConnected() {
});
break;
case "queue_refreshing":
snapshotState.queueRefreshingIds.add(rendererId);
break;
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
snapshotState.queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
void fetchRendererSnapshot(rendererId, { force: true });
break;
@@ -228,7 +221,7 @@ function ensureSSEConnected() {
snapshotState.snapshots.set(rendererId, snapshot);
});
sseConnected = true;
sseInitialized = true;
}
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
@@ -268,26 +261,32 @@ function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id);
}
function isQueueRefreshing(id: string) {
return snapshotState.queueRefreshingIds.has(id);
}
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id;
}
async function fetchRenderers(force = false) {
ensureSSEConnected();
const now = Date.now();
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
return;
}
ensureSSEInitialized();
try {
loading.value = true;
error.value = null;
const data = await api.getRenderers();
// Utiliser le cache API centralisé
const data = await apiCache.fetch(
'/renderers',
undefined,
() => api.getRenderers(),
{ force, ttl: RENDERERS_CACHE_MS }
);
renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]),
);
lastRenderersFetch.value = now;
} catch (err) {
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
console.error("[useRenderers] Erreur fetch:", err);
@@ -300,7 +299,7 @@ async function fetchRendererSnapshot(
rendererId: string,
opts?: { force?: boolean },
) {
ensureSSEConnected();
ensureSSEInitialized();
const force = opts?.force ?? false;
const hasSnapshot = snapshotState.snapshots.has(rendererId);
@@ -328,6 +327,46 @@ async function fetchRendererSnapshot(
}
}
/**
* Fetch les snapshots de plusieurs renderers en parallèle controlée.
* - Limite le nombre de requêtes simultanées (concurrency)
* - Ajoute un délai entre chaque batch pour ne pas saturer le réseau
* - Continue même si certaines requêtes échouent
*/
async function fetchBatchSnapshots(
rendererIds: string[],
options: {
concurrency?: number; // Nombre max de requêtes parallèles (défaut: 3)
batchDelay?: number; // Délai entre les batches en ms (défaut: 100ms)
force?: boolean; // Forcer le refetch même en cache
} = {}
): Promise<void> {
const { concurrency = 3, batchDelay = 100, force = false } = options;
// Filtrer les rendererIds valides
const validIds = rendererIds.filter(id => id && typeof id === 'string');
if (validIds.length === 0) return;
// Fonction pour traiter un batch
const processBatch = async (batch: string[]): Promise<void> => {
await Promise.allSettled(
batch.map(id => fetchRendererSnapshot(id, { force }))
);
};
// Exécuter par batches avec controlled concurrency
for (let i = 0; i < validIds.length; i += concurrency) {
const batch = validIds.slice(i, i + concurrency);
await processBatch(batch);
// Délai entre les batches (sauf pour le dernier)
if (i + concurrency < validIds.length) {
await new Promise(resolve => setTimeout(resolve, batchDelay));
}
}
}
// Transport controls
async function play(id: string) {
await api.play(id);
@@ -438,7 +477,7 @@ async function addAfterCurrent(
}
export function useRenderers() {
ensureSSEConnected();
ensureSSEInitialized();
return {
loading,
@@ -454,11 +493,13 @@ export function useRenderers() {
getQueueById,
getBindingById,
isSnapshotLoading,
isQueueRefreshing,
selectRenderer,
snapshotState,
// Fetchers
fetchRenderers,
fetchRendererSnapshot,
fetchBatchSnapshots,
// Transport controls
play,
resumeOrPlayFromQueue,
@@ -482,16 +523,16 @@ export function useRenderers() {
}
export function useRenderer(rendererId: Ref<string>) {
ensureSSEConnected();
ensureSSEInitialized();
const renderer = computed(() => renderersCache.value.get(rendererId.value));
const snapshot = computed(
() => snapshotState.snapshots.get(rendererId.value) ?? null,
);
const state = computed(() => snapshot.value?.state ?? null);
const queue = computed(() => snapshot.value?.queue ?? null);
const binding = computed(() => snapshot.value?.binding ?? null);
// Delegates to useRenderers functions - no duplication
const renderer = computed(() => getRendererById(rendererId.value));
const snapshot = computed(() => getSnapshotById(rendererId.value));
const state = computed(() => getStateById(rendererId.value));
const queue = computed(() => getQueueById(rendererId.value));
const binding = computed(() => getBindingById(rendererId.value));
const isStream = computed(() => snapshot.value?.is_stream ?? false);
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
async function refresh(force = true) {
await Promise.all([
@@ -507,6 +548,7 @@ export function useRenderer(rendererId: Ref<string>) {
queue,
binding,
isStream,
queueRefreshing,
refresh,
};
}

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

View File

@@ -14,7 +14,7 @@
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
import { sse } from "../services/pmocontrol/sse";
import { useSSE } from "./useSSE";
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
@@ -200,10 +200,11 @@ export function useWebRenderer() {
onConnectedCallback?.();
// S'abonner aux événements SSE du renderer pour piloter la lecture
sse.connect();
const { connect, onRendererEvent } = useSSE();
connect();
const udn = data.udn;
sseUnsubscribe?.();
sseUnsubscribe = sse.onRendererEvent((event) => {
sseUnsubscribe = onRendererEvent((event) => {
if (event.renderer_id !== udn) return;
if (event.type !== "state_changed") return;

View File

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

View File

@@ -197,6 +197,11 @@ export type RendererEventPayload =
queue_length: number;
timestamp: string;
}
| {
type: "queue_refreshing";
renderer_id: string;
timestamp: string;
}
| {
type: "binding_changed";
renderer_id: string;

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

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

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useRenderers } from '@/composables/useRenderers'
import { useMediaServers } from '@/composables/useMediaServers'
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
import { Radio, Server } from 'lucide-vue-next'
import { Radio, Server, Loader2 } from 'lucide-vue-next'
const {
allRenderers: renderers,
onlineRenderers,
getStateById,
fetchRenderers,
fetchRendererSnapshot
fetchBatchSnapshots
} = useRenderers()
const {
@@ -20,15 +20,34 @@ const {
fetchServers
} = useMediaServers()
// État de chargement pour l'UI
const isLoadingSnapshots = ref(false)
// Charger les données au montage
onMounted(async () => {
await fetchRenderers()
await fetchServers()
// Charger les listes de renderers et servers en parallèle
await Promise.all([
fetchRenderers(),
fetchServers()
])
for (const renderer of renderers.value) {
fetchRendererSnapshot(renderer.id, { force: true })
// Charger les snapshots en batch controlé
// On récupère les IDs des renderers découverts
const rendererIds = renderers.value.map(r => r.id)
if (rendererIds.length > 0) {
isLoadingSnapshots.value = true
// Appel batch avec concurrency controlée
await fetchBatchSnapshots(rendererIds, {
concurrency: 3, // Max 3 requêtes simultanées
batchDelay: 100, // 100ms entre chaque batch
force: true
})
isLoadingSnapshots.value = false
}
})
});
</script>
<template>
@@ -55,6 +74,10 @@ onMounted(async () => {
<Radio :size="24" />
<span>Renderers Audio</span>
</h2>
<span v-if="isLoadingSnapshots" class="loading-indicator">
<Loader2 :size="16" class="spin" />
Chargement...
</span>
</div>
<div v-if="renderers.length" class="renderers-grid">
@@ -175,6 +198,24 @@ onMounted(async () => {
color: var(--color-primary);
}
/* Loading indicator */
.loading-indicator {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.loading-indicator .spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Grids */
.renderers-grid,
.servers-grid {

View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"}

View File

@@ -892,7 +892,13 @@ impl ControlPoint {
})?;
// Check if queue is empty before trying to play next
if renderer.len()? == 0 {
let queue_len = renderer.len()?;
tracing::trace!(
renderer = renderer_id.0.as_str(),
queue_len,
"play_next_from_queue: checking queue length"
);
if queue_len == 0 {
debug!(
renderer = renderer_id.0.as_str(),
"play_next_from_queue: queue is empty"
@@ -1151,6 +1157,46 @@ impl ControlPoint {
container_id: &str,
auto_play: bool,
) -> Result<(), ControlPointError> {
// If already bound to the same container on the same server, don't clear the
// renderer queue — that would interrupt active playback. Instead, just trigger
// a gentle refresh (which uses LCS and preserves the currently playing track).
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
let already_bound = renderer
.get_playlist_binding()
.map(|b| b.server_id == *server_id && b.container_id == container_id)
.unwrap_or(false);
if already_bound {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id,
auto_play,
"Re-attach to same container: skipping clear, triggering gentle refresh"
);
let mut binding = renderer.get_playlist_binding().unwrap();
binding.pending_refresh = true;
binding.auto_play_on_refresh = auto_play;
renderer.set_playlist_binding(Some(binding));
let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid);
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
if auto_play {
Some(&mut auto_start_cb)
} else {
None
};
return refresh_attached_queue_for(
&self.registry,
renderer_id,
&self.event_bus,
callback,
);
}
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
// clear the RENDERER queue first (but NOT the local queue cache, which will be
// replaced by refresh_attached_queue_for() using replace_entire_playlist()).
@@ -1164,10 +1210,6 @@ impl ControlPoint {
"Attaching new playlist: clearing renderer queue"
);
// Prepare the renderer for the new playlist (backend-agnostic)
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.clear_for_playlist_attach()?;
// Sync backend state to local cache (backend-agnostic)
@@ -1510,6 +1552,11 @@ fn refresh_attached_queue_for(
// Reset the pending_refresh flag and consume auto_play
renderer.reset_pending_refresh();
let auto_play = renderer.consume_auto_play();
tracing::trace!(
renderer = renderer_id.0.as_str(),
auto_play,
"refresh_attached_queue_for: auto_play flag consumed"
);
// Step 2: Get server from registry
let music_server = {
@@ -1538,10 +1585,9 @@ fn refresh_attached_queue_for(
return Ok(());
}
// Step 3: Notify UI that the renderer is loading (Transitioning state)
event_bus.broadcast(RendererEvent::StateChanged {
// Step 3: Notify UI that the queue is being refreshed (dedicated event, no state change)
event_bus.broadcast(RendererEvent::QueueRefreshing {
id: renderer_id.clone(),
state: PlaybackState::Transitioning,
});
// Step 4: Browse container (renamed from Step 3 for clarity)
@@ -1550,6 +1596,17 @@ fn refresh_attached_queue_for(
const BROWSE_RETRY_DELAY_MS: u64 = 200;
const BROWSE_PAGE_SIZE: u32 = 64;
// DIAGNOSTIC: Log the queue state before browsing
let pre_snapshot = renderer.queue_snapshot()?;
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
pre_queue_items = pre_snapshot.items.len(),
pre_current_index = pre_snapshot.current_index,
"refresh_attached_queue_for: queue state BEFORE browse"
);
// Paginated browse — une playlist peut dépasser BROWSE_PAGE_SIZE items
let entries = {
let mut all_entries = Vec::new();
@@ -1644,10 +1701,18 @@ fn refresh_attached_queue_for(
renderer.sync_queue(new_items)?;
let final_queue_len = {
let snapshot = renderer.queue_snapshot()?;
snapshot.items.len()
};
// DIAGNOSTIC: Log the queue state after sync_queue
let post_snapshot = renderer.queue_snapshot()?;
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
post_queue_items = post_snapshot.items.len(),
post_current_index = post_snapshot.current_index,
"refresh_attached_queue_for: queue state AFTER sync_queue"
);
let final_queue_len = post_snapshot.items.len();
// Emit QueueUpdated event
event_bus.broadcast(RendererEvent::QueueUpdated {

View File

@@ -434,6 +434,9 @@ pub enum RendererEvent {
id: DeviceId,
queue_length: usize,
},
QueueRefreshing {
id: DeviceId,
},
BindingChanged {
id: DeviceId,
binding: Option<PlaylistBinding>,

View File

@@ -16,28 +16,28 @@ use std::thread::JoinHandle;
use tracing::debug;
use crate::DeviceIdentity;
use crate::discovery::chromecast_discovery::{
extract_host_from_location, extract_port_from_location,
};
use crate::errors::ControlPointError;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::DeviceIdentity;
use rust_cast::{
CastDevice, ChannelMessage,
channels::{
heartbeat::HeartbeatResponse,
media::{Media, PlayerState as CastPlayerState, StreamType},
receiver::CastDeviceApp,
},
CastDevice, ChannelMessage,
};
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
@@ -172,6 +172,29 @@ impl ChromecastRenderer {
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
}
/// Connect to the device with retry on connection failures.
/// Uses exponential backoff: 200ms, 400ms, 800ms
fn connect_with_retry(&self) -> Result<CastDevice<'_>, ControlPointError> {
// Try up to 3 times with exponential backoff
for attempt in 0..3 {
match connect_to_device(&self.host, self.port) {
Ok(device) => return Ok(device),
Err(e) if attempt < 2 => {
let delay = 200 * 2u64.pow(attempt);
tracing::warn!(
"Chromecast connection failed (attempt {}/3), retrying in {}ms: {}",
attempt + 1,
delay,
e
);
std::thread::sleep(std::time::Duration::from_millis(delay));
}
Err(e) => return Err(e),
}
}
unreachable!()
}
}
impl TransportControl for ChromecastRenderer {
@@ -231,7 +254,27 @@ impl TransportControl for ChromecastRenderer {
let handle = std::thread::spawn(move || {
tracing::info!("Play thread starting for URI: {}", uri);
let device = match connect_to_device(&host, port) {
// Connect with retry (inlined for thread context)
let device =
(|| {
for attempt in 0..3 {
match connect_to_device(&host, port) {
Ok(d) => return Ok(d),
Err(e) if attempt < 2 => {
let delay = 200 * 2u64.pow(attempt);
tracing::warn!(
"Chromecast connection failed (attempt {}/3), retrying in {}ms: {}",
attempt + 1, delay, e
);
std::thread::sleep(std::time::Duration::from_millis(delay));
}
Err(e) => return Err(e),
}
}
unreachable!()
})();
let device = match device {
Ok(d) => d,
Err(e) => {
tracing::error!("Failed to connect in play thread: {}", e);
@@ -334,7 +377,7 @@ impl TransportControl for ChromecastRenderer {
fn play(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: play()");
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
// Get receiver status to find the active app
let status = device.receiver.get_status().map_err(|e| {
@@ -379,7 +422,7 @@ impl TransportControl for ChromecastRenderer {
fn pause(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: pause()");
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
@@ -430,7 +473,7 @@ impl TransportControl for ChromecastRenderer {
// If a new play_uri() is called, it will properly wait for this thread.
// Also send stop command to the device
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
@@ -473,7 +516,7 @@ impl TransportControl for ChromecastRenderer {
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
@@ -519,17 +562,28 @@ impl TransportControl for ChromecastRenderer {
impl PlaybackStatus for ChromecastRenderer {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
// Get receiver status to find the active app
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
tracing::debug!(
"Chromecast playback_state: {} apps running",
status.applications.len()
);
// If no app is running, return NoMedia
let app = match status.applications.first() {
Some(app) => app,
None => return Ok(PlaybackState::NoMedia),
Some(app) => {
tracing::debug!("Chromecast playback_state: app={}", app.display_name);
app
}
None => {
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
return Ok(PlaybackState::NoMedia);
}
};
// Connect to the app
@@ -548,10 +602,25 @@ impl PlaybackStatus for ChromecastRenderer {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
tracing::debug!(
"Chromecast playback_state: {} media entries",
media_status.entries.len()
);
// If no media entry, return NoMedia
let media_entry = match media_status.entries.first() {
Some(entry) => entry,
None => return Ok(PlaybackState::NoMedia),
Some(entry) => {
tracing::debug!(
"Chromecast playback_state: player_state={:?}, current_time={:?}",
entry.player_state,
entry.current_time
);
entry
}
None => {
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
return Ok(PlaybackState::NoMedia);
}
};
Ok(map_player_state(&media_entry.player_state))
@@ -560,7 +629,7 @@ impl PlaybackStatus for ChromecastRenderer {
impl PlaybackPosition for ChromecastRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
// Get receiver status to find the active app
let status = device.receiver.get_status().map_err(|e| {
@@ -680,7 +749,7 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String {
impl VolumeControl for ChromecastRenderer {
fn volume(&self) -> Result<u16, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
@@ -696,7 +765,7 @@ impl VolumeControl for ChromecastRenderer {
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: set_volume({})", volume);
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let level = (volume as f32) / 100.0;
device.receiver.set_volume(level).map_err(|e| {
@@ -707,7 +776,7 @@ impl VolumeControl for ChromecastRenderer {
}
fn mute(&self) -> Result<bool, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
@@ -719,7 +788,7 @@ impl VolumeControl for ChromecastRenderer {
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: set_mute({})", mute);
let device = connect_to_device(&self.host, self.port)?;
let device = self.connect_with_retry()?;
device.receiver.set_volume(mute).map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))

View File

@@ -18,7 +18,6 @@ use crate::errors::ControlPointError;
use crate::events::RendererEventBus;
use crate::model::RendererEvent;
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
@@ -30,13 +29,12 @@ use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
use crate::music_renderer::sleep_timer::SleepTimer;
use crate::music_renderer::upnp_renderer::UpnpRenderer;
use crate::music_renderer::watcher::{
WatchStrategy, WatchedState, extract_track_metadata, playback_position_equal,
playback_state_equal,
extract_track_metadata, playback_position_equal, playback_state_equal, WatchStrategy,
WatchedState,
};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::online::DeviceConnectionState;
use crate::queue::{
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
};
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
use tracing::warn;
@@ -552,6 +550,12 @@ impl MusicRenderer {
// Emit event for all state changes including Transitioning
if changed {
tracing::trace!(
renderer = self.info.friendly_name(),
prev_state = ?watched.state,
new_state = ?raw_state,
"Playback state changed"
);
let state_clone = raw_state.clone();
drop(watched);
self.emit_event(RendererEvent::StateChanged {
@@ -631,6 +635,16 @@ impl MusicRenderer {
fn handle_state_change(&self, state: &PlaybackState) {
match state {
PlaybackState::Stopped => {
{
let s = self.state.lock().unwrap();
tracing::debug!(
renderer = self.info.friendly_name(),
has_played = s.has_played_since_track_start,
playback_source = ?s.playback_source,
user_stop_requested = s.user_stop_requested,
"STOPPED detected — evaluating auto-advance"
);
}
// Check if user requested stop (via Stop button in UI)
if self.check_and_clear_user_stop_requested() {
debug!(
@@ -690,11 +704,84 @@ impl MusicRenderer {
self.clear_has_played_flag();
}
}
PlaybackState::NoMedia => {
// Handle end of track (Chromecast returns NoMedia when track ends)
// This is equivalent to Stopped for auto-advance purposes
let s = self.state.lock().unwrap();
let playback_source = s.playback_source;
let has_played = s.has_played_since_track_start;
let user_stop = s.user_stop_requested;
drop(s);
tracing::debug!(
renderer = self.info.friendly_name(),
has_played = has_played,
playback_source = ?playback_source,
user_stop_requested = user_stop,
"NoMedia detected — evaluating auto-advance"
);
// Check if user requested stop (via Stop button in UI)
if self.check_and_clear_user_stop_requested() {
debug!(
renderer = self.info.friendly_name(),
"NoMedia after user request; not auto-advancing"
);
self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag();
} else if matches!(playback_source, PlaybackSource::FromQueue) {
// Auto-advance if we have seen a PLAYING state
if self.check_and_clear_has_played_flag() {
debug!(
renderer = self.info.friendly_name(),
"NoMedia after queue-driven playback; advancing to next track"
);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.play_next_from_queue()
}));
match result {
Ok(Ok(())) => {}
Ok(Err(err)) => {
error!(
renderer = self.info.friendly_name(),
error = %err,
"Auto-advance from NoMedia failed; clearing queue playback state"
);
self.set_playback_source(PlaybackSource::None);
}
Err(_panic) => {
error!(
renderer = self.info.friendly_name(),
"Auto-advance from NoMedia panicked; clearing queue playback state"
);
self.set_playback_source(PlaybackSource::None);
}
}
} else {
tracing::debug!(
renderer = self.info.friendly_name(),
"NoMedia but no PLAYING state seen yet; ignoring"
);
}
} else {
self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag();
}
}
PlaybackState::Playing => {
self.mark_external_if_idle();
// Mark that we have seen a PLAYING state - auto-advance is now allowed
self.set_has_played_flag();
}
PlaybackState::Transitioning => {
let s = self.state.lock().unwrap();
tracing::trace!(
renderer = self.info.friendly_name(),
has_played = s.has_played_since_track_start,
playback_source = ?s.playback_source,
"TRANSITIONING detected"
);
}
_ => {}
}
}
@@ -825,6 +912,10 @@ impl MusicRenderer {
}
// Then stop playback (ignore errors if already stopped)
tracing::trace!(
renderer = self.id().0.as_str(),
"STOP command via clear_for_playlist_attach"
);
backend.stop().or_else(|err| {
warn!(
renderer = self.id().0.as_str(),
@@ -940,6 +1031,7 @@ impl MusicRenderer {
}
/// Transport control: stop
#[track_caller]
pub fn stop(&self) -> Result<(), ControlPointError> {
// Reset the has_played flag when stopping playback.
// This ensures that if we start a new track, the flag will be false
@@ -947,6 +1039,12 @@ impl MusicRenderer {
// transient STOPPED states during track initialization.
self.clear_has_played_flag();
let caller = std::panic::Location::caller();
tracing::trace!(
renderer = self.info.friendly_name(),
caller = %caller,
"STOP command sent to renderer"
);
self.lock_backend_for("stop").stop()
}
@@ -1570,8 +1668,8 @@ pub(crate) fn build_didl_lite_metadata(
uri: &str,
protocol_info: &str,
) -> String {
use pmodidl::{DIDLLite, Item, Resource};
use pmodidl::ToXmlElement;
use pmodidl::{DIDLLite, Item, Resource};
// Construire l'Item DIDL avec toutes les métadonnées
let item = Item {
@@ -1807,7 +1905,6 @@ fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
}
/// Transport control façade that dispatches to whichever backend can fulfill
/// the request, returning a standardized error if the backend lacks support.
impl TransportControl for MusicRendererBackend {

View File

@@ -342,8 +342,26 @@ impl VolumeControl for OpenHomeRenderer {
impl PlaybackStatus for OpenHomeRenderer {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
let client = self.playlist_client_for("playback_state")?;
let state = client.transport_state()?;
Ok(map_openhome_state(&state))
let raw = client.transport_state().map_err(|err| {
tracing::warn!(
error = %err,
"OpenHome transport_state() failed — state change detection disabled"
);
err
})?;
let mapped = if raw.is_empty() {
tracing::trace!("OpenHome TransportState: empty (device initializing)");
PlaybackState::Transitioning
} else {
let mapped = map_openhome_state(&raw);
tracing::trace!(
raw_state = raw.as_str(),
mapped_state = ?mapped,
"OpenHome TransportState"
);
mapped
};
Ok(mapped)
}
}
@@ -542,7 +560,23 @@ impl QueueTransportControl for OpenHomeRenderer {
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
let len = queue.len().unwrap_or(0);
let current = queue.current_index().ok().flatten();
let current_track_id = queue.current_track().ok().flatten();
let all_ids = queue.track_ids().ok().unwrap_or_default();
tracing::trace!(
queue_len = len,
current_index = ?current,
current_track_id = ?current_track_id,
all_track_ids = ?all_ids,
"OpenHome play_next: advancing queue"
);
if !queue.advance()? {
tracing::trace!(
queue_len = len,
current_index = ?current,
"OpenHome play_next: advance() returned false — no next track"
);
return Err(ControlPointError::QueueError("No next track".into()));
}
}

View File

@@ -2307,6 +2307,132 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar
}
}
/// Paramètres de recherche
#[cfg(feature = "pmoserver")]
#[derive(Debug, serde::Deserialize)]
struct SearchQuery {
q: String,
}
/// GET /control/servers/{server_id}/search?q=<query> - Recherche dans un serveur
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/servers/{server_id}/search",
params(
("server_id" = String, Path, description = "ID unique du serveur"),
("q" = String, Query, description = "Requête de recherche"),
),
responses(
(status = 200, description = "Résultats de recherche", body = BrowseResponse),
(status = 404, description = "Serveur non trouvé", body = ErrorResponse),
(status = 500, description = "Erreur lors de la recherche", body = ErrorResponse)
),
tag = "control"
)]
async fn search_server(
State(state): State<ControlPointState>,
Path(server_id): Path<String>,
Query(params): Query<SearchQuery>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = DeviceId(server_id.clone());
let server = state.control_point.media_server(&sid).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Server {} not found", server_id),
}),
)
})?;
if !server.is_online() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: format!("Server {} is offline", server_id),
}),
));
}
if !server.has_content_directory() {
return Err((
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
error: format!("Server {} does not support ContentDirectory", server_id),
}),
));
}
debug!(server_id = %server_id, query = %params.q, "Search request");
let query = params.q.clone();
let server_clone = server.clone();
let search_task = tokio::task::spawn_blocking(move || {
server_clone.search("0", &query, 0, 200)
});
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, search_task)
.await
.map_err(|_| {
warn!(
"Search request on server {} exceeded {:?}",
server_id, BROWSE_REQUEST_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Search request timed out after {}s",
BROWSE_REQUEST_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!("Task join error during search: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!("Failed to search on server {}: {}", server_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to search: {}", e),
}),
)
})?;
let total_count = entries.len() as u32;
debug!(server_id = %server_id, count = total_count, "Search results");
let container_entries: Vec<ContainerEntry> = entries
.into_iter()
.map(|e| ContainerEntry {
id: e.id,
title: e.title,
class: e.class,
is_container: e.is_container,
child_count: None,
artist: e.artist,
album: e.album,
album_art_uri: e.album_art_uri,
})
.collect();
Ok(Json(BrowseResponse {
container_id: "search".to_string(),
entries: container_entries,
total_count,
offset: 0,
}))
}
// ============================================================================
// ROUTER & TRAIT
// ============================================================================
@@ -2405,6 +2531,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/servers/{server_id}/containers/{container_id}",
get(browse_container),
)
.route("/servers/{server_id}/search", get(search_server))
.with_state(state)
// SSE events - merge the SSE router
.merge(crate::sse::create_sse_router(control_point))

View File

@@ -16,9 +16,9 @@
//! - never starts playback (transport control is handled elsewhere).
use crate::{
DeviceId, DeviceIdentity, RendererInfo,
errors::ControlPointError,
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
DeviceId, DeviceIdentity, RendererInfo,
};
/// Internal/local queue implementation.
@@ -374,6 +374,16 @@ impl QueueBackend for InternalQueue {
) -> Result<(), ControlPointError> {
use crate::queue::EnqueueMode;
// DIAGNOSTIC: Log current queue state before enqueue
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
current_index = self.current_index,
items_count_before = self.items.len(),
mode = ?mode,
items_to_enqueue = items.len(),
"enqueue_items: START"
);
// Protéger les durées des streams contre la diminution
let protected_items = self.protect_stream_durations(items);
@@ -398,6 +408,15 @@ impl QueueBackend for InternalQueue {
}
self.ensure_current_index_invariant();
// DIAGNOSTIC: Log queue state after enqueue
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
current_index = self.current_index,
items_count_after = self.items.len(),
"enqueue_items: END"
);
Ok(())
}

View File

@@ -8,8 +8,8 @@ use tracing::{debug, trace, warn};
use crate::errors::ControlPointError;
use crate::upnp_clients::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack,
OhTrackEntry,
OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry,
OPENHOME_PLAYLIST_HEAD_ID,
};
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
use crate::queue::{
@@ -66,6 +66,50 @@ impl TrackIdsCache {
}
}
/// Cache for ReadList results to avoid redundant SOAP calls within a short window.
/// Key: sorted list of requested IDs. TTL: 500ms.
#[derive(Debug)]
struct ReadListCache {
ids: Option<Vec<u32>>,
entries: Option<Vec<OhTrackEntry>>,
last_update: Option<SystemTime>,
}
impl ReadListCache {
fn new() -> Self {
Self {
ids: None,
entries: None,
last_update: None,
}
}
fn get(&self, id_list: &[u32]) -> Option<Vec<OhTrackEntry>> {
if let (Some(cached_ids), Some(entries), Some(last_update)) =
(&self.ids, &self.entries, self.last_update)
{
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
if elapsed.as_millis() < 500 && cached_ids.as_slice() == id_list {
return Some(entries.clone());
}
}
}
None
}
fn set(&mut self, ids: Vec<u32>, entries: Vec<OhTrackEntry>) {
self.ids = Some(ids);
self.entries = Some(entries);
self.last_update = Some(SystemTime::now());
}
fn invalidate(&mut self) {
self.ids = None;
self.entries = None;
self.last_update = None;
}
}
/// Cache for current track ID to avoid redundant Id SOAP calls
#[derive(Debug)]
struct CurrentTrackIdCache {
@@ -130,6 +174,8 @@ pub struct OpenHomeQueue {
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
/// Cache for current track ID to avoid redundant Id SOAP calls
current_track_id_cache: Arc<Mutex<CurrentTrackIdCache>>,
/// Cache for ReadList results (TTL 500ms) to avoid redundant SOAP calls
read_list_cache: Arc<Mutex<ReadListCache>>,
}
impl OpenHomeQueue {
@@ -147,6 +193,7 @@ impl OpenHomeQueue {
metadata_cache: Mutex::new(HashMap::new()),
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
}
}
@@ -327,7 +374,7 @@ impl OpenHomeQueue {
fresh.as_ref().and_then(|m| m.duration.as_ref())
);
drop(cache); // Libérer le lock avant d'appeler cache_metadata
// Mettre en cache pour éviter les oscillations sur les flux radio
// Mettre en cache pour éviter les oscillations sur les flux radio
self.cache_metadata(entry.id, fresh.clone());
fresh
}
@@ -406,6 +453,7 @@ impl OpenHomeQueue {
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -584,6 +632,7 @@ impl OpenHomeQueue {
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -598,11 +647,22 @@ impl OpenHomeQueue {
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
debug!(
renderer = self.renderer_id.0.as_str(),
current_count = snapshot.items.len(),
desired_count = items.len(),
current_uris = ?snapshot.items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
"LCS input: current vs desired items"
);
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
let items_to_add = keep_desired.iter().filter(|&k| !k).count();
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -612,16 +672,39 @@ impl OpenHomeQueue {
"LCS computed: minimizing OpenHome playlist operations"
);
// If we're replacing everything (keep=0), use delete_all() instead of
// individual delete_id() calls. This is much more robust for live playlists
// where track IDs can become invalid between refresh and deletion.
// Get current playing track ID BEFORE any modifications
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
// Check if the currently playing track is in the new playlist
// If so, we should NOT use delete_all() - we must preserve it
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
items
.iter()
.position(|item| item.backend_id as u32 == current_id)
});
// If we're replacing everything (keep=0), use delete_all() BUT only if
// there's no currently playing track, OR if the current track is not in the new playlist.
// If current track IS in new playlist, we must preserve it using insert/delete operations.
if items_to_keep == 0 && items_to_delete > 0 {
debug!(
renderer = self.renderer_id.0.as_str(),
"Using delete_all() for complete replacement (more robust for live playlists)"
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
if current_track_in_new_playlist.is_some() {
// Current track is in new playlist - use insert/delete instead of delete_all
// to preserve playback
debug!(
renderer = self.renderer_id.0.as_str(),
current_track_in_playlist = true,
"Preserving currently playing track - using insert/delete instead of delete_all"
);
// Fall through to selective deletion below
} else {
// No current track or not in new playlist - safe to use delete_all
debug!(
renderer = self.renderer_id.0.as_str(),
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
}
} else {
// Selective deletion when keeping some items
for idx in (0..current_track_ids.len()).rev() {
@@ -686,6 +769,7 @@ impl OpenHomeQueue {
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -819,6 +903,13 @@ impl QueueBackend for OpenHomeQueue {
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
let ids = self.playlist_client.id_array()?;
tracing::trace!(
renderer = self.renderer_id.0.as_str(),
ids_count = ids.len(),
ids = ?ids,
"track_ids: cache miss, fetched from Pizzicato"
);
// Update cache before releasing lock
cache.set(ids.clone());
@@ -890,13 +981,28 @@ impl QueueBackend for OpenHomeQueue {
});
}
// Read metadata for all tracks (batched)
// playback_item_from_entry() will prioritize cached metadata over entry metadata
// Read metadata for all tracks (batched), with 500ms cache to avoid
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
const MAX_BATCH: usize = 64;
let mut entries = Vec::with_capacity(ids.len());
for chunk in ids.chunks(MAX_BATCH) {
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
trace!(
renderer = self.renderer_id.0.as_str(),
"ReadList cache hit for {} IDs",
chunk.len()
);
entries.extend(cached);
continue;
}
match self.playlist_client.read_list(chunk) {
Ok(mut batch) => entries.append(&mut batch),
Ok(batch) => {
self.read_list_cache
.lock()
.unwrap()
.set(chunk.to_vec(), batch.clone());
entries.extend(batch);
}
Err(err) => {
// If batch fails, try one by one
if chunk.len() > 1 {
@@ -943,10 +1049,15 @@ impl QueueBackend for OpenHomeQueue {
self.playlist_client.seek_id(track_id)?;
} else {
self.ensure_playlist_source_selected()?;
tracing::trace!(
renderer = self.renderer_id.0.as_str(),
"STOP command via set_index(None) on OpenHome playlist"
);
self.playlist_client.stop()?;
}
// Invalidate caches (seek_id/stop modifies playlist state and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -972,6 +1083,7 @@ impl QueueBackend for OpenHomeQueue {
// Invalidate caches after delete_all (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
if items.is_empty() {
@@ -994,18 +1106,42 @@ impl QueueBackend for OpenHomeQueue {
// Invalidate cache after insertions
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.ensure_playlist_source_selected()?;
// DIAGNOSTIC: Log current track state before any modifications
let pre_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
pre_current_track_id = pre_current_track,
pre_items_count = items.len(),
"sync_queue: START - current track before modification"
);
if items.is_empty() {
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
"sync_queue: Empty playlist - clearing queue with delete_all"
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
// Invalidate caches after delete_all (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
// DIAGNOSTIC: Log state after delete_all
let post_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
post_current_track_id = post_current_track,
"sync_queue: END - current track after delete_all (should be 0)"
);
return Ok(());
}
@@ -1013,6 +1149,15 @@ impl QueueBackend for OpenHomeQueue {
// differences. Without this, any drift between our cache and the renderer
// (e.g., manual edits from another control point) would keep the stale items.
let snapshot = self.queue_snapshot()?;
debug!(
renderer = self.renderer_id.0.as_str(),
snapshot_items_len = snapshot.items.len(),
snapshot_current_index = snapshot.current_index,
new_items_count = items.len(),
"sync_queue: snapshot vs new items comparison"
);
// Note: current_index may point to an index that doesn't exist in items
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
// IDs but ReadList returns empty TrackList). We must bounds-check here.
@@ -1053,6 +1198,15 @@ impl QueueBackend for OpenHomeQueue {
.position(|item| item.didl_id == playing_didl_id)
});
tracing::trace!(
renderer = self.renderer_id.0.as_str(),
playing_uri = playing_uri.as_str(),
playing_didl_id = ?playing_didl_id,
pivot_found = new_playing_idx.is_some(),
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
"sync_queue: pivot search result"
);
if let Some(pivot_idx) = new_playing_idx {
// CASE 2: Currently playing item IS in the new playlist
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
@@ -1078,6 +1232,20 @@ impl QueueBackend for OpenHomeQueue {
}
} else {
// No currently playing item or can't determine it - use standard LCS
// BUT first check if this is because the OpenHome device returned empty playlist
// This could cause the queue to be cleared incorrectly
if snapshot.items.is_empty() && !items.is_empty() {
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
snapshot_items = snapshot.items.len(),
new_items = items.len(),
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
);
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
// Instead, just add the new items without deleting existing ones
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
}
debug!(
renderer = self.renderer_id.0.as_str(),
"No currently playing item, using standard LCS sync"
@@ -1085,6 +1253,16 @@ impl QueueBackend for OpenHomeQueue {
self.replace_queue_standard_lcs(items, Some(0))?;
}
// DIAGNOSTIC: Log state after sync completes
let post_current_track = self.playlist_client.id().ok();
let post_ids = self.track_ids();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
post_current_track_id = post_current_track,
post_items_count = post_ids.as_ref().map(|v| v.len()).unwrap_or(0),
"sync_queue: END - current track after modifications"
);
Ok(())
}
@@ -1142,6 +1320,7 @@ impl QueueBackend for OpenHomeQueue {
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -1187,6 +1366,7 @@ impl QueueBackend for OpenHomeQueue {
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
Ok(())
}
@@ -1199,6 +1379,7 @@ impl QueueBackend for OpenHomeQueue {
self.playlist_client.delete_all()?;
// Invalidate caches after clearing playlist (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
Ok(())
}

View File

@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
queue_length: usize,
timestamp: chrono::DateTime<chrono::Utc>,
},
QueueRefreshing {
renderer_id: String,
timestamp: chrono::DateTime<chrono::Utc>,
},
BindingChanged {
renderer_id: String,
server_id: Option<String>,
@@ -214,6 +218,10 @@ fn renderer_event_to_payload(
queue_length,
timestamp,
},
RendererEvent::QueueRefreshing { id } => RendererEventPayload::QueueRefreshing {
renderer_id: id.0,
timestamp,
},
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
renderer_id: id.0,
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),

View File

@@ -2,8 +2,9 @@ use crate::errors::ControlPointError;
use crate::model::TrackMetadata;
use crate::soap_client::{
decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text,
extract_child_text_any, extract_child_text_local, extract_child_text_optional,
extract_child_text_optional_local, find_child_with_suffix, handle_action_response,
extract_child_text_allow_empty, extract_child_text_any, extract_child_text_local,
extract_child_text_optional, extract_child_text_optional_local, find_child_with_suffix,
handle_action_response,
invoke_upnp_action, parse_bool, parse_visible_flag,
};
use anyhow::{Result, anyhow};
@@ -270,7 +271,8 @@ impl OhPlaylistClient {
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
})?;
let state = extract_child_text_any(response, &["State", "Value"])?;
// upmpdcli returns <Value>, other implementations may use <State>
let state = extract_child_text_any(response, &["Value", "State"])?;
Ok(state)
}
@@ -331,7 +333,10 @@ impl OhPlaylistClient {
handle_action_response("SeekSecondAbsolute", &call_result)
}
#[track_caller]
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
let caller = std::panic::Location::caller();
tracing::trace!(control_url = self.control_url.as_str(), id, caller = %caller, "OpenHome DeleteId");
let id_str = id.to_string();
let args = [("Value", id_str.as_str())];
@@ -379,7 +384,10 @@ impl OhPlaylistClient {
}
}
#[track_caller]
pub fn delete_all(&self) -> Result<(), ControlPointError> {
let caller = std::panic::Location::caller();
tracing::trace!(control_url = self.control_url.as_str(), caller = %caller, "OpenHome DeleteAll");
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
handle_action_response("DeleteAll", &call_result)
@@ -409,6 +417,21 @@ impl OhPlaylistClient {
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
.ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?;
// Log the raw IdArrayResponse XML for debugging
{
let raw_children: Vec<String> = response.children.iter()
.map(|n: &xmltree::XMLNode| match n {
xmltree::XMLNode::Element(e) => format!("{}={:?}", e.name, e.get_text()),
_ => String::new(),
})
.filter(|s| !s.is_empty())
.collect();
tracing::trace!(
children = ?raw_children,
"id_array: IdArrayResponse children"
);
}
// Try to extract the array element. If missing, assume empty playlist.
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
Ok(text) => text,

View File

@@ -2015,7 +2015,10 @@ impl MusicSource for QobuzSource {
}
async fn search(&self, query: &str) -> Result<BrowseResult> {
// Search across Qobuz catalog
use tracing::debug;
debug!(query = %query, "Qobuz search started");
// Search across Qobuz catalog (albums, tracks, artists, playlists)
let results = self
.inner
.client
@@ -2023,22 +2026,72 @@ impl MusicSource for QobuzSource {
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let (albums, tracks) = tokio::join!(
self.cache_covers(results.albums),
self.cache_covers(results.tracks),
debug!(
albums = results.albums.len(),
artists = results.artists.len(),
tracks = results.tracks.len(),
playlists = results.playlists.len(),
"Qobuz search API results"
);
let containers: Vec<Container> = albums
// Cache covers in parallel for all types
let (albums, tracks, artists, playlists) = tokio::join!(
self.cache_album_covers(results.albums),
self.cache_track_covers(results.tracks),
self.cache_artist_covers(results.artists),
self.cache_playlist_covers(results.playlists),
);
// Build containers from albums
let album_containers: Vec<Container> = albums
.into_iter()
.filter_map(|album| album.to_didl_container("qobuz").ok())
.filter_map(|a| a.to_didl_container("qobuz:search").ok())
.collect();
let items: Vec<Item> = tracks
// Build containers from artists (manual construction)
let artist_containers: Vec<Container> = artists
.into_iter()
.filter_map(|track| track.to_didl_item("qobuz").ok())
.map(|artist| Container {
id: format!("qobuz:artist:{}", artist.id),
parent_id: "qobuz:search".to_string(),
restricted: Some("1".to_string()),
child_count: None,
searchable: Some("1".to_string()),
title: artist.name.clone(),
class: "object.container".to_string(),
artist: Some(artist.name.clone()),
album_art: artist.image_cached,
containers: vec![],
items: vec![],
})
.collect();
if !containers.is_empty() || !items.is_empty() {
Ok(BrowseResult::Mixed { containers, items })
// Build containers from playlists
let playlist_containers: Vec<Container> = playlists
.into_iter()
.filter_map(|p| p.to_didl_container("qobuz:search").ok())
.collect();
// Combine all containers
let mut all_containers = Vec::new();
all_containers.extend(album_containers);
all_containers.extend(artist_containers);
all_containers.extend(playlist_containers);
// Build items from tracks
let track_items: Vec<Item> = tracks
.into_iter()
.filter_map(|t| t.to_didl_item("qobuz:search").ok())
.collect();
debug!(
containers = all_containers.len(),
items = track_items.len(),
"Qobuz search done"
);
if !all_containers.is_empty() || !track_items.is_empty() {
Ok(BrowseResult::Mixed { containers: all_containers, items: track_items })
} else {
Ok(BrowseResult::Items(vec![]))
}

View File

@@ -1 +1 @@
0.3.30
0.3.32