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

Ce dossier est vide

+

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

@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) { color: var(--color-text-secondary); } +/* Search */ +.search-bar { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.search-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: var(--spacing-sm); + color: var(--color-text-tertiary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + color: var(--color-text); + font-size: var(--text-sm); +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.search-clear { + position: absolute; + right: var(--spacing-xs); + background: none; + border: none; + cursor: pointer; + color: var(--color-text-tertiary); + display: flex; + align-items: center; + padding: 2px; +} + +.search-clear:hover { + color: var(--color-text); +} + +.search-btn { + white-space: nowrap; + padding: var(--spacing-xs) var(--spacing-md); + font-size: var(--text-sm); +} + /* Scrollbar styling */ .browser-content::-webkit-scrollbar { width: 6px; diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue index 6388ce12..49bd8e82 100644 --- a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue +++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue @@ -2,7 +2,7 @@ import { computed, ref, watch, nextTick, toRef } from "vue"; import { useRenderer } from "@/composables/useRenderers"; import QueueItem from "./QueueItem.vue"; -import { Link, Radio } from "lucide-vue-next"; +import { Link, Radio, RefreshCw } from "lucide-vue-next"; import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types"; const props = defineProps<{ @@ -13,7 +13,7 @@ const emit = defineEmits<{ clickItem: [item: QueueItemType]; }>(); -const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId")); +const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId")); const isAttached = computed(() => !!binding.value); @@ -72,6 +72,12 @@ watch( Web Radio + + +
+ + Mise à jour... +
@@ -162,6 +168,33 @@ watch( font-size: var(--text-xs); } +.refresh-indicator { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + background-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 500; + border: 1px solid var(--color-border); + width: fit-content; +} + +.refresh-icon { + animation: spin 1s linear infinite; +} + +.refresh-text { + font-size: var(--text-xs); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .queue-list { flex: 1; overflow-y: auto; diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index b400cfbb..19a7a0df 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -41,8 +41,25 @@ const { currentPath, setPath, clearPath, + searchResults, + searchQuery, + searchServer, + clearSearch, } = useMediaServers(); +const searchInput = ref(''); +const isSearchMode = computed(() => searchQuery.value !== ''); + +async function handleSearch() { + if (!currentServer.value || !searchInput.value.trim()) return; + await searchServer(currentServer.value.id, searchInput.value.trim()); +} + +function handleClearSearch() { + searchInput.value = ''; + clearSearch(); +} + const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } = useRenderers(); @@ -427,6 +444,28 @@ function handleSettingsClick() { + + +
@@ -520,6 +559,53 @@ function handleSettingsClick() {

Chargement...

+ +
+

Aucun résultat

+
    +
  • +
    + +
    + + +
    +
    + +
    + + +
    + + +
    +
    + +
  • +
+
+
  • { + data: T; + timestamp: number; + etag?: string; +} + +export interface ApiCacheOptions { + ttl?: number; + staleWhileRevalidate?: boolean; +} + +interface PendingRequest { + promise: Promise; + subscribers: Set<(data: unknown) => void>; +} + +/** + * Classe principale du cache API + */ +class ApiCacheService { + private cache = new Map>(); + private pendingRequests = new Map(); + private subscriptions = new Map void>>(); + + private options: Required = { + ttl: 2000, + staleWhileRevalidate: true, + }; + + configure(options: Partial) { + this.options = { ...this.options, ...options }; + } + + private makeKey(endpoint: string, params?: Record): 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(endpoint: string, params?: Record): T | null { + const key = this.makeKey(endpoint, params); + const entry = this.cache.get(key) as CacheEntry | undefined; + + if (!entry) return null; + if (!this.isFresh(key)) { + return this.options.staleWhileRevalidate ? entry.data : null; + } + + return entry.data; + } + + set(endpoint: string, data: T, params?: Record, etag?: string): void { + const key = this.makeKey(endpoint, params); + + this.cache.set(key, { + data, + timestamp: Date.now(), + etag, + }); + + this.notifySubscribers(key, data); + } + + async fetch( + endpoint: string, + params: Record | undefined, + fetcher: () => Promise, + options: { force?: boolean; ttl?: number } = {} + ): Promise { + const key = this.makeKey(endpoint, params); + const { force = false, ttl } = options; + + if (!force && this.isFresh(key)) { + const cached = this.get(endpoint, params); + if (cached) return cached; + } + + const existing = this.pendingRequests.get(key); + if (existing) { + return existing.promise as Promise; + } + + let resolvePromise!: (value: unknown) => void; + let rejectPromise!: (reason: unknown) => void; + + const promise = new Promise((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(endpoint: string, params: Record, 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(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( + endpoint: string, + params: Record, + fetcher: () => Promise + ): Promise { + 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( + endpoint: string, + params: Record | undefined, + fetcher: () => Promise, + options?: { force?: boolean; ttl?: number } + ): Promise { + return apiCache.fetch(endpoint, params, fetcher, options); + }, + + subscribe( + endpoint: string, + params: Record, + 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(); + }, + }; +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/imageCache.ts b/pmoapp/webapp/src/composables/imageCache.ts new file mode 100644 index 00000000..e6f95b5f --- /dev/null +++ b/pmoapp/webapp/src/composables/imageCache.ts @@ -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(); + private subscriptions = new Map 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) { + 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) { + const entry = ref(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, + }; +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useCoverImage.ts b/pmoapp/webapp/src/composables/useCoverImage.ts index 74a1755c..e93de6d9 100644 --- a/pmoapp/webapp/src/composables/useCoverImage.ts +++ b/pmoapp/webapp/src/composables/useCoverImage.ts @@ -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, 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(null); - const retryCount = ref(0); - const currentUrl = ref(null); const cacheBustedUrl = ref(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) { + return useCoverImage(url, { forceReload: false, useServerCache: true }); +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useMediaServers.ts b/pmoapp/webapp/src/composables/useMediaServers.ts index ceb22aad..12169055 100644 --- a/pmoapp/webapp/src/composables/useMediaServers.ts +++ b/pmoapp/webapp/src/composables/useMediaServers.ts @@ -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>(new Map()) const browseCache = ref>(new Map()) const currentPath = ref([]) - -// Timestamps -const lastFetch = { - servers: 0 -} +const searchResults = ref(null) +const searchQuery = ref('') 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, diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index a9947164..f9e62ae6 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -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; lastEventAt: Map; loadingIds: Set; + queueRefreshingIds: Set; selectedRendererId: string | null; } const renderersCache = ref>(new Map()); const RENDERERS_CACHE_MS = 2000; -const lastRenderersFetch = ref(0); const snapshotState = reactive({ snapshots: reactive(new Map()), lastSnapshotAt: reactive(new Map()), lastEventAt: reactive(new Map()), loadingIds: reactive(new Set()), + queueRefreshingIds: reactive(new Set()), selectedRendererId: null, }); const loading = ref(false); const error = ref(null); -let sseConnected = false; -function ensureSSEConnected() { - if (sseConnected) return; +// Utiliser le composable SSE centralisé +let sseInitialized = false; +function ensureSSEInitialized() { + if (sseInitialized) return; - sse.onRendererEvent((event) => { + const { onRendererEvent, connect } = useSSE(); + + // Démarrer la connexion SSE + connect(); + + onRendererEvent((event) => { const rendererId = event.renderer_id; const timestamp = Date.parse(event.timestamp ?? "") || Date.now(); @@ -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 { + const { concurrency = 3, batchDelay = 100, force = false } = options; + + // Filtrer les rendererIds valides + const validIds = rendererIds.filter(id => id && typeof id === 'string'); + + if (validIds.length === 0) return; + + // Fonction pour traiter un batch + const processBatch = async (batch: string[]): Promise => { + await Promise.allSettled( + batch.map(id => fetchRendererSnapshot(id, { force })) + ); + }; + + // Exécuter par batches avec controlled concurrency + for (let i = 0; i < validIds.length; i += concurrency) { + const batch = validIds.slice(i, i + concurrency); + await processBatch(batch); + + // Délai entre les batches (sauf pour le dernier) + if (i + concurrency < validIds.length) { + await new Promise(resolve => setTimeout(resolve, batchDelay)); + } + } +} + // Transport controls async function play(id: string) { await api.play(id); @@ -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) { - 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) { queue, binding, isStream, + queueRefreshing, refresh, }; } diff --git a/pmoapp/webapp/src/composables/useSSE.ts b/pmoapp/webapp/src/composables/useSSE.ts new file mode 100644 index 00000000..97bf1a51 --- /dev/null +++ b/pmoapp/webapp/src/composables/useSSE.ts @@ -0,0 +1,244 @@ +/** + * Composable centralisé pour la gestion des Server-Sent Events + * + * Ce composable fournit une interface unifiée pour: + * - Une seule connexion SSE (évite les connexions multiples) + * - Des abonnements typés aux événements (renderers, servers) + * - Le suivi de l'état de connexion + * + * Usage: + * const { connected, onRendererEvent, onMediaServerEvent } = useSSE() + */ +import { ref, readonly, onUnmounted } from 'vue' +import { sse } from '../services/pmocontrol/sse' +import type { + RendererEventPayload, + MediaServerEventPayload +} from '../services/pmocontrol/types' + +// État global partagé +const connected = ref(sse.isConnectedState()) +const connectionCallbacks: Set<(connected: boolean) => void> = new Set() + +// Abonnement à l'état de connexion global +function setupConnectionListener() { + // S'assurer qu'on ne s'abonne qu'une seule fois + if (connectionCallbacks.size === 0) { + sse.onConnectionChange((isConnected) => { + connected.value = isConnected + connectionCallbacks.forEach(cb => cb(isConnected)) + }) + } +} + +/** + * Hook principal pour utiliser SSE + */ +export function useSSE() { + // S'assurer que le listener de connexion est configuré + setupConnectionListener() + + /** + * Abonnement aux événements de type renderer + * Retourne une fonction de cleanup + */ + function onRendererEvent( + callback: (event: RendererEventPayload) => void + ): () => void { + return sse.onRendererEvent(callback) + } + + /** + * Abonnement aux événements de type media server + * Retourne une fonction de cleanup + */ + function onMediaServerEvent( + callback: (event: MediaServerEventPayload) => void + ): () => void { + return sse.onMediaServerEvent(callback) + } + + /** + * Abonnement aux changements de connexion + * Retourne une fonction de cleanup + */ + function onConnectionChange( + callback: (connected: boolean) => void + ): () => void { + connectionCallbacks.add(callback) + // Appeler immédiatement avec l'état actuel + callback(connected.value) + + // Retourner fonction de cleanup + return () => { + connectionCallbacks.delete(callback) + } + } + + /** + * Force la connexion SSE + */ + function connect(): void { + sse.connect() + } + + /** + * Force la déconnexion SSE + */ + function disconnect(): void { + sse.disconnect() + } + + /** + * Vérifie si actuellement connecté + */ + function isConnected(): boolean { + return connected.value + } + + return { + // État (readonly pour éviter les modifications directes) + connected: readonly(connected), + + // Abonnements + onRendererEvent, + onMediaServerEvent, + onConnectionChange, + + // Actions + connect, + disconnect, + isConnected, + } +} + +/** + * Hook pour s'abonner à un type spécifique d'événement renderer + * avec filtrage optionnel par rendererId + */ +export function useRendererEvents( + rendererId: () => string | null, + options?: { + onStateChanged?: (event: RendererEventPayload) => void + onPositionChanged?: (event: RendererEventPayload) => void + onVolumeChanged?: (event: RendererEventPayload) => void + onMetadataChanged?: (event: RendererEventPayload) => void + onQueueUpdated?: (event: RendererEventPayload) => void + onBindingChanged?: (event: RendererEventPayload) => void + onTimerEvent?: (event: RendererEventPayload) => void + } +) { + const { onRendererEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onRendererEvent((event) => { + const currentId = rendererId() + + // Si un rendererId est spécifié, filtrer + if (currentId && event.renderer_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'state_changed': + case 'online': + case 'offline': + options?.onStateChanged?.(event) + break + case 'position_changed': + options?.onPositionChanged?.(event) + break + case 'volume_changed': + case 'mute_changed': + options?.onVolumeChanged?.(event) + break + case 'metadata_changed': + options?.onMetadataChanged?.(event) + break + case 'queue_updated': + case 'queue_refreshing': + options?.onQueueUpdated?.(event) + break + case 'binding_changed': + options?.onBindingChanged?.(event) + break + case 'timer_started': + case 'timer_updated': + case 'timer_tick': + case 'timer_expired': + case 'timer_cancelled': + options?.onTimerEvent?.(event) + break + } + }) + } + + setup() + + // Cleanup automatique au unmount du composant + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, // Permet de recréer l'abonnement si besoin + } +} + +/** + * Hook pour s'abonner aux événements media server + * avec filtrage optionnel par serverId + */ +export function useMediaServerEvents( + serverId: () => string | null, + options?: { + onOnline?: (event: MediaServerEventPayload) => void + onOffline?: (event: MediaServerEventPayload) => void + onGlobalUpdated?: (event: MediaServerEventPayload) => void + onContainersUpdated?: (event: MediaServerEventPayload) => void + } +) { + const { onMediaServerEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onMediaServerEvent((event) => { + const currentId = serverId() + + // Si un serverId est spécifié, filtrer + if (currentId && event.server_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'online': + options?.onOnline?.(event) + break + case 'offline': + options?.onOffline?.(event) + break + case 'global_updated': + options?.onGlobalUpdated?.(event) + break + case 'containers_updated': + options?.onContainersUpdated?.(event) + break + } + }) + } + + setup() + + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, + } +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index 93bb6a2a..6754b3ad 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -14,7 +14,7 @@ */ import { ref, onMounted, onUnmounted, readonly } from "vue"; -import { sse } from "../services/pmocontrol/sse"; +import { useSSE } from "./useSSE"; // ─── Identifiant stable de l'instance navigateur ───────────────────────────── @@ -200,10 +200,11 @@ export function useWebRenderer() { onConnectedCallback?.(); // S'abonner aux événements SSE du renderer pour piloter la lecture - sse.connect(); + const { connect, onRendererEvent } = useSSE(); + connect(); const udn = data.udn; sseUnsubscribe?.(); - sseUnsubscribe = sse.onRendererEvent((event) => { + sseUnsubscribe = onRendererEvent((event) => { if (event.renderer_id !== udn) return; if (event.type !== "state_changed") return; diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index dcb5f5f5..fb846f62 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -426,6 +426,16 @@ class PMOControlAPI { ); } + /** + * Recherche dans un serveur media + * GET /api/control/servers/{serverId}/search?q={query} + */ + async searchServer(serverId: string, query: string): Promise { + return this.request( + `/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`, + ); + } + // ============================================================================ // SLEEP TIMER // ============================================================================ diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts index 527b57ab..3c8b1daf 100644 --- a/pmoapp/webapp/src/services/pmocontrol/types.ts +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -197,6 +197,11 @@ export type RendererEventPayload = queue_length: number; timestamp: string; } + | { + type: "queue_refreshing"; + renderer_id: string; + timestamp: string; + } | { type: "binding_changed"; renderer_id: string; diff --git a/pmoapp/webapp/src/utils/string.ts b/pmoapp/webapp/src/utils/string.ts new file mode 100644 index 00000000..1a825678 --- /dev/null +++ b/pmoapp/webapp/src/utils/string.ts @@ -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; +} \ No newline at end of file diff --git a/pmoapp/webapp/src/utils/time.ts b/pmoapp/webapp/src/utils/time.ts new file mode 100644 index 00000000..98edbe81 --- /dev/null +++ b/pmoapp/webapp/src/utils/time.ts @@ -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); +} \ No newline at end of file diff --git a/pmoapp/webapp/src/views/DashboardView.vue b/pmoapp/webapp/src/views/DashboardView.vue index 52a77a40..e4a1b8c3 100644 --- a/pmoapp/webapp/src/views/DashboardView.vue +++ b/pmoapp/webapp/src/views/DashboardView.vue @@ -1,17 +1,17 @@