push-kvzwrolnknzx #86
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ RF.json
|
||||
RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
|
||||
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Évaluation du plan : centraliser_base_url_axum_middleware
|
||||
|
||||
## Résumé de l'audit
|
||||
|
||||
Le plan est **bien pensé et cohérent**. Il identifie correctement le problème et la solution. Cependant, j'ai identifié plusieurs points nécessitant des amendements.
|
||||
|
||||
---
|
||||
|
||||
## Points validés (conformes au code actuel)
|
||||
|
||||
1. **Problème bien identifié** : URLs hardcodées avec IP locale (`PMO_SERVER_URL`) retournées au frontend via reverse proxy.
|
||||
|
||||
2. **`get_request_base_url` existe déjà** à `pmoserver/src/lib.rs:199` — pas besoin de la recréer.
|
||||
|
||||
3. **`covers_route_for` existe déjà** dans `pmocache/src/lib.rs:149`.
|
||||
|
||||
4. **`covers_absolute_url_for` utilisée dans les contextes UPnP** :
|
||||
- `pmoupnp/src/cache_registry.rs:57`
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263`
|
||||
- `pmoparadise/src/source.rs:216`
|
||||
- `pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs:91`
|
||||
|
||||
5. **Route audio correcte** : `/audio/tracks/{pk}` (pas `/audio/flac/{pk}`).
|
||||
|
||||
6. **Architecture du Server** : Les routes sont construites dynamiquement via `Arc<RwLock<Router>>`. Le layer devra être ajouté dans la construction du router, pas après.
|
||||
|
||||
---
|
||||
|
||||
## Points à amender
|
||||
|
||||
### 1. Ajout du layer dans le Server
|
||||
|
||||
Le plan suggère d'ajouter le layer "dans `server.rs`" mais la structure du router est complexe :
|
||||
- Les routes sont dynamiques (`RwLock<Router>`)
|
||||
- Le router final est un fallback qui délègue
|
||||
|
||||
**Correction** : Ajouter le layer directement lors de la création du `registry_route` initial (ligne 120-122) :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← ici
|
||||
```
|
||||
|
||||
### 2. Comportement requis pour LAN vs WAN
|
||||
|
||||
Le middleware doit supporter les deux cas d'usage :
|
||||
|
||||
- **LAN (sans reverse proxy)** : Pas de headers `X-Forwarded-*` → utiliser l'adresse IP locale du serveur (`PMO_SERVER_URL`)
|
||||
- **WAN (via reverse proxy)** : Headers `X-Forwarded-*` présents → utiliser l'URL publique du reverse proxy
|
||||
|
||||
**Important** : `get_request_base_url` dans `pmoserver/src/lib.rs:199` lit déjà ces headers. Le fallback doit être `PMO_SERVER_URL` qui est configuré au démarrage avec l'IP locale.
|
||||
|
||||
### 3. Chemin du middleware dans la pile
|
||||
|
||||
Le plan dit d'appliquer le layer "avant" les autres. En réalité, Tower/Acorn applique les couches dans l'ordre où elles sont ajoutées — le premier layer ajouté est le plus extérieur (exécuté en premier). Le `base_url_layer` doit donc être ajouté en **premier** (le plus intérieur) pour voir les headers nettoyés.
|
||||
|
||||
### 3. Les handlers n'ont PAS besoin de BaseUrl
|
||||
|
||||
Après analyse, **aucun handler** dans le codebase actuel n'appelle `covers_absolute_url_for()` directement pour le frontend. Les `album_art_uri` sont :
|
||||
- Soit **propagés** depuis les réponses UPnP des media servers (pas des URLs pmomusic)
|
||||
- Soit **construits en tâche de fond** dans les caches (RadioFrance, RadioParadise)
|
||||
|
||||
**Correction** : Le plan surestime le nombre de handlers à modifier. La vraie question est : d'où viennent les URLs incorrectes ?
|
||||
|
||||
### 4. Source du problème à clarifier
|
||||
|
||||
Les URLs incorrectes ne viennent pas des handlers REST classiques. Elles viennent probablement de :
|
||||
|
||||
**a) Tâches de fond** (background tasks) qui stockent des URLs complètes :
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263` — construit `covers_absolute_url_for()` dans le cache
|
||||
- `pmoparadise/src/source.rs:216` — même problème
|
||||
|
||||
**b) API Qobuz** (`pmoqobuz/src/api_rest.rs:302`) — utilise `covers_route_for` (route relative, OK)
|
||||
|
||||
**c) Playlist** (`pmoplaylist/src/handle/read.rs:204,271,355`) — utilise `covers_route_for` (OK)
|
||||
|
||||
### 5. Correction du fallback
|
||||
|
||||
Le plan suggère `localhost:8080` ou `0.0.0.0:8080` comme fallback. Le port doit provenir de la configuration du serveur (`get_server_base_url()` existe déjà dans `pmoserver/src/lib.rs`).
|
||||
|
||||
**Correction** : Le fallback utilise `get_server_base_url()` (disponible via `GLOBAL_SERVER`) :
|
||||
- En LAN : pas de `X-Forwarded-*` → `get_server_base_url()` → URLs en IP locale
|
||||
- En WAN : `X-Forwarded-*` présents → URLs en URL publique du reverse proxy
|
||||
|
||||
### 6. Fonction `audio_route_for` pas nécessaire maintenant
|
||||
|
||||
Le plan propose d'ajouter `audio_route_for` dans `pmoaudiocache`. Mais :
|
||||
- Les fichiers audio sont servis par `pmoaudiocache` lui-même (routes internes)
|
||||
-Aucune URL audio n'est retournée au frontend via JSON
|
||||
|
||||
**Supprimer** cette étape du plan.
|
||||
|
||||
---
|
||||
|
||||
## Plan amendé
|
||||
|
||||
### Étape 0 — Audit spécifique (à faire avant implémentation)
|
||||
|
||||
```bash
|
||||
# Trouver les constructions d'URLs dans les tâches de fond (caches, sources)
|
||||
grep -rn "covers_absolute_url_for\|PMO_SERVER_URL" --include="*.rs" | grep -v "pmocontrol\|pmoplaylist\|pmoqobuz"
|
||||
|
||||
# Vérifier les URLs dans les réponses JSON des handlers
|
||||
grep -rn "album_art_uri" --include="*.rs" | grep -E "fn |->"
|
||||
```
|
||||
|
||||
Identifier spécifiquement quels endpoints REST retournent des URLs au frontend.
|
||||
|
||||
### Étape 1 — `pmoserver/src/lib.rs` : Ajouter `BaseUrl` + middleware
|
||||
|
||||
```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 {
|
||||
// Priorité : 1) X-Forwarded-* (reverse proxy), 2) get_server_base_url() (adresse configurée)
|
||||
let base = get_request_base_url(request.headers())
|
||||
.or_else(|| get_server_base_url())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"BaseUrl: impossible de déterminer l'URL de base.\n\
|
||||
Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP."
|
||||
);
|
||||
});
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Accès LAN (pas de proxy) : `get_server_base_url()` → URLs en IP locale configurée
|
||||
- Accès WAN (reverse proxy) : `X-Forwarded-*` → URLs en URL publique
|
||||
|
||||
**Note** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic (fail-fast) car c'est une erreur de configuration.
|
||||
|
||||
### Étape 2 — `pmoserver/src/server.rs` : Appliquer le layer
|
||||
|
||||
Dans `Server::new()`, ligne ~120-122 :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← Ajouter ici (couche la plus intérieure)
|
||||
```
|
||||
|
||||
### Étape 3 — `pmocache/src/lib.rs` : Renommer sans déprecation
|
||||
|
||||
```rust
|
||||
// Rename direct - pas de déprecation (soft en cours de dev, pas une library)
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
|
||||
// PMO_SERVER_URL contient l'IP locale (LAN) - utilisé uniquement pour UPnP
|
||||
// Fallback sur get_server_base_url() si dispo, sinon erreur
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.or_else(|_| pmoserver::get_server_base_url().ok_or("PMO_SERVER_URL not set"))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("covers_absolute_url_for_upnp: {}", e);
|
||||
panic!("BaseUrl non disponible pour UPnP");
|
||||
});
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — Mettre à jour les appels UPnP
|
||||
|
||||
```bash
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs"
|
||||
```
|
||||
|
||||
Modifier `pmoupnp/src/cache_registry.rs:57` → `covers_absolute_url_for_upnp`
|
||||
|
||||
### Étape 5 — Tâches de fond : stocker la route, pas l'URL
|
||||
|
||||
**pmoradiofrance/src/metadata_cache.rs:263** :
|
||||
```rust
|
||||
// Avant :
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
// Après : stocker la route relative
|
||||
let album_art_route = pmocache::covers_route_for(&pk, None);
|
||||
```
|
||||
|
||||
Le handler REST qui retourne ces métadonnées devra extraire `Extension<BaseUrl>` et appliquer `base_url.url_for()`.
|
||||
|
||||
**pmoparadise/src/source.rs:216** : Même traitement.
|
||||
|
||||
### Étape 6 — Vérification et tests
|
||||
|
||||
```bash
|
||||
# Plus d'appels à covers_absolute_url_for dans les contextes HTTP
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs" | grep -v "pmocache\|pmoupnp"
|
||||
|
||||
# Tests du middleware
|
||||
cargo test base_url
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions en suspens
|
||||
|
||||
1. **Fallback avec panic** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic au démarrage avec un message clair (ex: "BaseUrl: configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP").
|
||||
→ **Décision utilisateur** : OK, panic avec message clair.
|
||||
|
||||
2. **Reverse proxy avec Authelia** : NPM ajoutera les headers `X-Forwarded-*`. Authelia gère l'authentification separately. Pas de vérification de header supplémentaire nécessaire pour le middleware BaseUrl.
|
||||
→ **Décision** : Pas de vérification supplémentaire.
|
||||
|
||||
---
|
||||
|
||||
## Problème complémentaire : URLs de covers des media servers externes
|
||||
|
||||
### Contexte
|
||||
|
||||
Quand le control point accède à un media server externe sur le LAN (autre que pmomusic), les URLs d'articles (`album_art_uri`) retournées par ce media server externe contiennent des IPs locales du LAN externe (ex: `http://192.168.1.100:8080/covers/...`).
|
||||
|
||||
Ces URLs ne passent pas par notre système de caching et ne peuvent pas être rewritées par le middleware `BaseUrl` car elles sont :
|
||||
1. Recues depuis le réseau UPnP (pas via HTTP)
|
||||
2. Propagées directement dans les réponses REST/SSE sans transformation
|
||||
|
||||
### Solution proposée : Proxy de covers avec cache
|
||||
|
||||
Créer un nouveau endpoint HTTP qui agit comme un proxy transparent :
|
||||
1. **Détection** : Si l'URL demandée est une URL LAN externe (pas une URL locale de pmomusic)
|
||||
2. **Caching** : Utiliser `cache.add_from_url()` qui gère déjà la déduplication (pas de double-cache)
|
||||
3. **Rewriting** : Retourner l'URL locale du cache (`/covers/image/{pk}`)
|
||||
|
||||
**Note importante** : `pmocache::add_from_url()` gère déjà :
|
||||
- La vérification si l'URL est déjà en cache (ligne 673-683)
|
||||
- Le calcul du pk basé sur le contenu (pas sur l'URL)
|
||||
- La déduplication automatique pour les mêmes contenus
|
||||
|
||||
### Implémentation
|
||||
|
||||
**Nouvel endpoint dans `pmocovers/src/lib.rs` ou nouveau fichier `pmocovers/src/proxy.rs`** :
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverProxyParams {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CoverProxyResponse {
|
||||
cached_url: String,
|
||||
pk: String,
|
||||
}
|
||||
|
||||
/// GET /covers/proxy?url=<encoded_url>
|
||||
/// Proxy transparent qui :
|
||||
/// 1. Détecte si l'URL est une URL LAN externe (pas déjà locale)
|
||||
/// 2. Ajoute à cache via add_from_url (déduplication automatique)
|
||||
/// 3. Retourne l'URL locale du cache
|
||||
pub async fn cover_proxy_handler(
|
||||
Query(params): Query<CoverProxyParams>,
|
||||
State(cache): State<CoverCache>,
|
||||
Extension(base_url): Extension<pmoserver::BaseUrl>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let external_url = ¶ms.url;
|
||||
|
||||
// Ignorer si déjà une URL locale (ne pas se cacher soi-même)
|
||||
if is_local_cover_url(external_url, &base_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is already a local cover"));
|
||||
}
|
||||
|
||||
// Vérifier si c'est une URL LAN à proxyfier
|
||||
if !should_proxy_url(external_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is not a LAN URL requiring proxy"));
|
||||
}
|
||||
|
||||
// Ajouter au cache (add_from_url gère la déduplication)
|
||||
let pk = cache.add_from_url(external_url, Some("external-covers"))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
// Retourner l'URL locale
|
||||
let local_url = base_url.url_for(&pmocache::covers_route_for(&pk, None));
|
||||
Ok(Json(CoverProxyResponse { cached_url: local_url, pk }))
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic
|
||||
/// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées
|
||||
/// et mises en cache localement - c'est le comportement desired!
|
||||
fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool {
|
||||
// Only skip if it's OUR instance's base URL
|
||||
// Covers from other pmomusic instances on LAN should be proxied and cached
|
||||
url.starts_with(&base_url.0)
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL doit être proxyfiée (URL LAN externe)
|
||||
fn should_proxy_url(url: &str) -> bool {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
// Proxy uniquement les URLs LAN (pas les URLs publiques)
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return ip.is_private() || ip.is_loopback();
|
||||
}
|
||||
// aussi les .local
|
||||
return host.ends_with(".local") || host == "localhost";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- Utiliser `add_from_url()` pour bénéficier de la déduplication automatique
|
||||
- Vérifier `is_local_cover_url()` avec uniquement la comparaison de base_url pour éviter que notre instance ne se cache elle-même
|
||||
- Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées (comportement souhaité!)
|
||||
- Le TTL sera celui par défaut du cache (configurable)
|
||||
|
||||
**Mise à jour des handlers REST** :
|
||||
|
||||
Dans `pmocontrol/src/pmoserver_ext.rs` et `pmocontrol/src/sse.rs`, transformer les `album_art_uri` LAN :
|
||||
|
||||
```rust
|
||||
fn transform_external_cover_url(url: &str) -> String {
|
||||
if is_lan_url(url) {
|
||||
// Remplacer par l'URL du proxy
|
||||
let encoded = urlencoding::encode(url);
|
||||
return format!("/covers/proxy?url={}", encoded);
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
```
|
||||
|
||||
**Appels dans les handlers** :
|
||||
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2173` : `browse_container` → transformer `album_art_uri`
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2424` : autre endpoint → même transformation
|
||||
- `pmocontrol/src/sse.rs:213` : `MetadataChanged` events → même transformation
|
||||
|
||||
### TTL
|
||||
|
||||
- Le TTL sera celui par défaut du cache `pmocovers`
|
||||
- C'est configurable via `pmoconfig` si besoin
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Limiter aux URLs LAN uniquement (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `localhost`)
|
||||
- Vérifier que l'URL n'est pas déjà une cover locale de pmomusic (éviter le cacheception)
|
||||
- Ajouter un rate limiting pour éviter le flood de téléchargement
|
||||
- Timeout de téléchargement : 10 secondes max
|
||||
|
||||
### Résumé des fichiers à modifier
|
||||
|
||||
1. **Nouveau** : `pmocovers/src/proxy.rs` - Endpoint de proxy
|
||||
2. **Modifier** : `pmocontrol/src/pmoserver_ext.rs` - Transformer les album_art_uri
|
||||
3. **Modifier** : `pmocontrol/src/sse.rs` - Transformer les album_art_uri dans les événements
|
||||
|
||||
---
|
||||
|
||||
## Plan: Passer le SSE en mode Async
|
||||
|
||||
### Contexte
|
||||
|
||||
Le SSE de PMO Control est **déjà async** (fonctions `pub async fn`), mais le traitement des événements utilise des fonctions **synchrones** (`fn renderer_event_to_payload` → `transform_cover_url_sync`). Cela nécessite des workarounds (threads avec runtime tokio séparés).
|
||||
|
||||
### Problèmes actuels
|
||||
|
||||
1. **Nested runtime**: `std::thread::spawn` avec `tokio::runtime::Runtime::new()` dans chaque appel
|
||||
2. **Performance dégradée**: Création d'un thread par URL de cover
|
||||
3. **Code complexe**: Workarounds pour exécuter de l'async dans du sync
|
||||
|
||||
### Solution
|
||||
|
||||
Rendre le traitement des événements **entièrement async** :
|
||||
|
||||
1. **Modifier** `renderer_event_to_payload` → `async fn renderer_event_to_payload`
|
||||
2. **Modifier** `transform_cover_url_sync` → `transform_cover_url` (async) avec `.await` direct
|
||||
3. **Supprimer** le workaround `proxy_cover_url_sync` dans `pmocovers` (quand les .await fonctionnent)
|
||||
|
||||
### Avantages attendus
|
||||
|
||||
1. **Fluidité accrue**: Pas de thread par cover,真正的 async/await
|
||||
2. **Meilleure réactivité**: Pas de blocking sur les événements SSE
|
||||
3. **Code plus propre**: Plus de workarounds, plus de runtime imbriqué
|
||||
4. **Meilleure scalabilité**: Plus de création de thread
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
1. `pmocontrol/src/sse.rs`:
|
||||
- `renderer_event_to_payload` → `async fn`
|
||||
- `media_server_event_to_payload` → `async fn`
|
||||
- `all_events_sse`: utiliser les versions async
|
||||
|
||||
2. `pmocontrol/src/pmoserver_ext.rs`:
|
||||
- Utiliser `transform_cover_url` (async) avec `.await` au lieu de `transform_cover_url_sync`
|
||||
|
||||
3. `pmocontrol/src/control_point.rs` (si nécessaire):
|
||||
- Adapter les appels aux fonctions async
|
||||
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -3848,6 +3848,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"pmoserver",
|
||||
"rust-embed",
|
||||
]
|
||||
@@ -3995,6 +3996,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"mdns",
|
||||
"percent-encoding",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
@@ -4015,6 +4017,8 @@ dependencies = [
|
||||
"tracing-log 0.1.4",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
@@ -4037,6 +4041,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"utoipa",
|
||||
"webp",
|
||||
]
|
||||
@@ -6549,6 +6554,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.11.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -10,6 +10,10 @@ rust-embed = "8.5.0"
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[dependencies.async-trait]
|
||||
optional = true
|
||||
version = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
pmoserver = ["dep:pmoserver", "dep:async-trait"]
|
||||
|
||||
@@ -237,6 +237,8 @@
|
||||
//! - [Vue.js Documentation](https://vuejs.org/)
|
||||
//! - [Vite Documentation](https://vitejs.dev/)
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Structure représentant l'application web embarquée.
|
||||
@@ -285,6 +287,8 @@ pub struct Webapp;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
pub trait WebAppExt {
|
||||
/// Ajoute une Single Page Application au serveur.
|
||||
///
|
||||
|
||||
@@ -27,10 +27,14 @@
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use crate::WebAppExt;
|
||||
use pmoserver::Server;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
impl WebAppExt for Server {
|
||||
async fn add_webapp<W>(&mut self, path: &str)
|
||||
where
|
||||
|
||||
39
pmoapp/webapp/package-lock.json
generated
39
pmoapp/webapp/package-lock.json
generated
@@ -620,9 +620,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -637,9 +634,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -654,9 +648,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,9 +662,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -688,9 +676,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -705,9 +690,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -722,9 +704,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -739,9 +718,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -756,9 +732,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -773,9 +746,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -790,9 +760,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -807,9 +774,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -824,9 +788,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -38,6 +38,7 @@ impl BroadcastPacer {
|
||||
}
|
||||
|
||||
/// Reset the pacer clock (call when audio timestamp resets to 0).
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.start_time = Instant::now();
|
||||
trace!("{} broadcaster: pacer reset", self.label);
|
||||
|
||||
@@ -296,6 +296,7 @@ pub(crate) fn validate_frame_header_crc(data: &[u8], offset: usize) -> bool {
|
||||
/// keeping the data from the last sync code onward for the next iteration.
|
||||
///
|
||||
/// We need at least 2 validated sync codes to identify one complete frame.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn find_complete_frames_boundary(data: &[u8]) -> usize {
|
||||
if data.len() < 4 {
|
||||
return 0;
|
||||
|
||||
@@ -88,7 +88,7 @@ impl IcyClientStream {
|
||||
|
||||
// Add cover URL if we have a cover_pk
|
||||
if let Some(pk) = &meta.cover_pk {
|
||||
let cover_url = pmocache::covers_absolute_url_for(pk, None);
|
||||
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
|
||||
} else if let Some(url) = &meta.cover_url {
|
||||
// Fallback to external cover URL if no local pk
|
||||
|
||||
@@ -323,37 +323,42 @@ impl AudioSegment {
|
||||
|
||||
/// Convertit l'AudioChunk vers F32 si c'est un chunk audio
|
||||
pub fn to_f32_chunk(&self) -> Option<AudioChunk> {
|
||||
self.as_chunk().map(|chunk| chunk.to_f32())
|
||||
self.as_chunk()
|
||||
.map(|chunk: &Arc<AudioChunk>| chunk.to_f32())
|
||||
}
|
||||
|
||||
/// Convertit l'AudioChunk vers I32 si c'est un chunk audio
|
||||
pub fn to_i32_chunk(&self) -> Option<AudioChunk> {
|
||||
self.as_chunk().map(|chunk| chunk.to_i32())
|
||||
self.as_chunk()
|
||||
.map(|chunk: &Arc<AudioChunk>| chunk.to_i32())
|
||||
}
|
||||
|
||||
/// Récupère le sample rate du chunk audio
|
||||
pub fn sample_rate(&self) -> Option<u32> {
|
||||
self.as_chunk().map(|chunk| chunk.sample_rate())
|
||||
self.as_chunk()
|
||||
.map(|chunk: &Arc<AudioChunk>| chunk.sample_rate())
|
||||
}
|
||||
|
||||
/// Récupère le nombre de frames du chunk audio
|
||||
pub fn frame_count(&self) -> Option<usize> {
|
||||
self.as_chunk().map(|chunk| chunk.len())
|
||||
self.as_chunk().map(|chunk: &Arc<AudioChunk>| chunk.len())
|
||||
}
|
||||
|
||||
/// Récupère le gain en dB du chunk audio
|
||||
pub fn gain_db(&self) -> Option<f64> {
|
||||
self.as_chunk().map(|chunk| chunk.gain_db())
|
||||
self.as_chunk()
|
||||
.map(|chunk: &Arc<AudioChunk>| chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Récupère le type du chunk audio (nom du type: "i32", "f32", etc.)
|
||||
pub fn chunk_type_name(&self) -> Option<&'static str> {
|
||||
self.as_chunk().map(|chunk| chunk.type_name())
|
||||
self.as_chunk()
|
||||
.map(|chunk: &Arc<AudioChunk>| chunk.type_name())
|
||||
}
|
||||
|
||||
/// Crée un nouveau segment avec le gain modifié (si c'est un chunk audio)
|
||||
pub fn with_gain_db(&self, gain_db: f64) -> Option<Arc<Self>> {
|
||||
self.as_chunk().map(|chunk| {
|
||||
self.as_chunk().map(|chunk: &Arc<AudioChunk>| {
|
||||
let new_chunk = chunk.set_gain_db(gain_db);
|
||||
Arc::new(Self {
|
||||
order: self.order,
|
||||
@@ -365,7 +370,7 @@ impl AudioSegment {
|
||||
|
||||
/// Crée un nouveau segment avec le gain ajusté (relatif, si c'est un chunk audio)
|
||||
pub fn adjust_gain_db(&self, delta_db: f64) -> Option<Arc<Self>> {
|
||||
self.as_chunk().map(|chunk| {
|
||||
self.as_chunk().map(|chunk: &Arc<AudioChunk>| {
|
||||
let new_gain = chunk.gain_db() + delta_db;
|
||||
let new_chunk = chunk.set_gain_db(new_gain);
|
||||
Arc::new(Self {
|
||||
|
||||
@@ -77,8 +77,7 @@ async fn main() {
|
||||
- **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages
|
||||
- **RwLock** : Pour partage concurrent du compteur [`TimerNode`]
|
||||
"#]
|
||||
#[cfg(feature = "simd")]
|
||||
use std::simd::*;
|
||||
// use std::simd::*; // Not actually used in this file, modules import their own simd
|
||||
|
||||
mod audio_chunk;
|
||||
mod audio_segment;
|
||||
|
||||
@@ -32,6 +32,7 @@ use tokio_util::sync::CancellationToken;
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté.
|
||||
#[allow(dead_code)]
|
||||
enum StopReason {
|
||||
TrackBoundary(Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>),
|
||||
EndOfStream,
|
||||
@@ -418,6 +419,7 @@ async fn wait_for_first_audio_chunk_with_metadata(
|
||||
}
|
||||
|
||||
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||
#[allow(dead_code)]
|
||||
async fn pump_track_segments(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
@@ -155,9 +155,15 @@ pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
|
||||
}
|
||||
|
||||
/// Retourne l'URL absolue pour une cover via `PMO_SERVER_URL`
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
|
||||
/// Utilisée uniquement pour les contextes UPnP (LAN)
|
||||
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());
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"covers_absolute_url_for_upnp: PMO_SERVER_URL non configuré.\n\
|
||||
Le serveur doit être initialisé avant toute utilisation UPnP."
|
||||
);
|
||||
});
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
pub use db::{CacheEntry, DB};
|
||||
|
||||
@@ -30,6 +30,7 @@ rand = { workspace = true }
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmocovers = { path = "../pmocovers", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"], optional = true }
|
||||
@@ -38,6 +39,8 @@ async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
url = { version = "2", optional = true }
|
||||
urlencoding = { version = "2", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
percent-encoding = "2.3"
|
||||
@@ -45,4 +48,4 @@ percent-encoding = "2.3"
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream"]
|
||||
pmoserver = ["dep:pmoserver", "dep:pmocovers", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:url", "dep:urlencoding"]
|
||||
|
||||
@@ -17,11 +17,13 @@ pub trait RendererBackend {
|
||||
///
|
||||
/// These operations combine queue management with transport control,
|
||||
/// allowing navigation (next/previous) and track selection from the queue.
|
||||
#[allow(dead_code)]
|
||||
pub trait QueueTransportControl {
|
||||
/// Play the next track from the queue.
|
||||
fn play_next(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play the previous track from the queue.
|
||||
#[allow(dead_code)]
|
||||
fn play_previous(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play from the queue at the current index (or initialize to 0 if not set).
|
||||
|
||||
@@ -810,6 +810,7 @@ impl MusicRenderer {
|
||||
/// Acquires the backend mutex with a default context message.
|
||||
///
|
||||
/// Convenience wrapper around `lock_backend_for` for simple cases.
|
||||
#[allow(dead_code)]
|
||||
fn lock_backend(&self) -> std::sync::MutexGuard<'_, MusicRendererBackend> {
|
||||
self.lock_backend_for("unknown operation")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum OhServiceKind {
|
||||
}
|
||||
|
||||
impl OhServiceKind {
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OhServiceKind::Playlist => "playlist",
|
||||
@@ -60,10 +61,12 @@ pub fn control_url_for(info: &RendererInfo, kind: OhServiceKind) -> Option<Strin
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.control_url)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn service_type_for(info: &RendererInfo, kind: OhServiceKind) -> Option<String> {
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.service_type)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Playlist)?;
|
||||
Some(OhPlaylistClient::new(
|
||||
@@ -114,8 +117,8 @@ pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
DeviceId,
|
||||
model::{RendererCapabilities, RendererInfo, RendererProtocol},
|
||||
DeviceId,
|
||||
};
|
||||
|
||||
fn sample_renderer_info() -> RendererInfo {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::openhome::{
|
||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||
build_time_client, build_volume_client,
|
||||
};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||
OhTimeClient, OhVolumeClient,
|
||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
@@ -500,6 +500,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
}
|
||||
|
||||
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
|
||||
#[allow(dead_code)]
|
||||
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
|
||||
// Search for duration attribute in <res> element
|
||||
let res_start = didl.find("<res ")?;
|
||||
|
||||
@@ -120,6 +120,7 @@ pub fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInf
|
||||
/// Parse "HH:MM:SS" style time strings to seconds.
|
||||
///
|
||||
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
||||
#[allow(dead_code)]
|
||||
fn parse_hms_to_secs(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::openapi::{
|
||||
use crate::queue::PlaybackItem;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocovers;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
@@ -28,7 +30,7 @@ use async_trait::async_trait;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
http::{StatusCode, header::HeaderMap},
|
||||
routing::{get, post},
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -170,13 +172,14 @@ async fn get_renderer_state(
|
||||
async fn get_renderer_full_snapshot(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
|
||||
// Use spawn_blocking because renderer_full_snapshot does sync UPnP calls
|
||||
let control_point = state.control_point.clone();
|
||||
let rid_clone = rid.clone();
|
||||
let snapshot =
|
||||
let mut snapshot =
|
||||
tokio::task::spawn_blocking(move || control_point.renderer_full_snapshot(&rid_clone))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -189,6 +192,28 @@ async fn get_renderer_full_snapshot(
|
||||
})?
|
||||
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||
|
||||
// Get base_url from request headers for transforming cover URLs
|
||||
let base_url_str = pmoserver::get_base_url_from_request(&headers);
|
||||
let base_url = pmoserver::BaseUrl(base_url_str);
|
||||
|
||||
// Transform cover URLs in current_track
|
||||
if let Some(ref mut current_track) = snapshot.state.current_track {
|
||||
if let Some(ref album_art) = current_track.album_art_uri {
|
||||
if let Some(transformed) = transform_cover_url(Some(album_art), &base_url).await {
|
||||
current_track.album_art_uri = Some(transformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform cover URLs in queue items
|
||||
for item in &mut snapshot.queue.items {
|
||||
if let Some(ref album_art) = item.album_art_uri {
|
||||
if let Some(transformed) = transform_cover_url(Some(album_art), &base_url).await {
|
||||
item.album_art_uri = Some(transformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(snapshot))
|
||||
}
|
||||
|
||||
@@ -213,13 +238,27 @@ async fn get_renderer_full_snapshot(
|
||||
async fn get_renderer_queue(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<crate::openapi::QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
let mut snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||
|
||||
// Get base_url from request headers
|
||||
let base_url_str = pmoserver::get_base_url_from_request(&headers);
|
||||
let base_url = pmoserver::BaseUrl(base_url_str.clone());
|
||||
|
||||
// Transform cover URLs in all queue items using async version
|
||||
for item in &mut snapshot.queue.items {
|
||||
if let Some(ref album_art) = item.album_art_uri {
|
||||
if let Some(transformed) = transform_cover_url(Some(album_art), &base_url).await {
|
||||
item.album_art_uri = Some(transformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(snapshot.queue))
|
||||
}
|
||||
|
||||
@@ -2081,7 +2120,10 @@ async fn browse_container(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((server_id, container_id)): Path<(String, String)>,
|
||||
Query(params): Query<BrowseParams>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let base_url_str = pmoserver::get_base_url_from_request(&headers);
|
||||
let base_url = pmoserver::BaseUrl(base_url_str);
|
||||
let sid = DeviceId(server_id.clone());
|
||||
|
||||
let server = state.control_point.media_server(&sid).ok_or_else(|| {
|
||||
@@ -2160,9 +2202,11 @@ async fn browse_container(
|
||||
)
|
||||
})?;
|
||||
|
||||
let container_entries: Vec<ContainerEntry> = page.entries
|
||||
.into_iter()
|
||||
.map(|e| ContainerEntry {
|
||||
// Transform cover URLs
|
||||
let mut container_entries = Vec::with_capacity(page.entries.len());
|
||||
for e in page.entries {
|
||||
let album_art_uri = transform_cover_url(e.album_art_uri.as_deref(), &base_url).await;
|
||||
container_entries.push(ContainerEntry {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
@@ -2170,9 +2214,9 @@ async fn browse_container(
|
||||
child_count: None,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
})
|
||||
.collect();
|
||||
album_art_uri,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(BrowseResponse {
|
||||
container_id,
|
||||
@@ -2204,6 +2248,40 @@ fn map_snapshot_error(
|
||||
)
|
||||
}
|
||||
|
||||
/// Transforme une URL de cover pour qu'elle soit accessible depuis le client
|
||||
///
|
||||
/// Si l'URL est une route locale de notre cache (/covers/...), on la transforme en URL absolue.
|
||||
/// Sinon, on utilise pmocovers::proxy_cover_url() pour mettre en cache et retourner notre URL.
|
||||
/// C'est le même mécanisme que PMO Cache utilise déjà pour Qobuz.
|
||||
async fn transform_cover_url(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option<String> {
|
||||
let url = url?;
|
||||
|
||||
// Si c'est déjà une route locale de notre cache, la transformer en URL absolue
|
||||
if url.starts_with("/covers/") {
|
||||
debug!(url = %url, "Already local cover route");
|
||||
return Some(base_url.url_for(url));
|
||||
}
|
||||
|
||||
// Si c'est une URL de notre instance, la retourner directement
|
||||
if url.starts_with(&base_url.0) {
|
||||
debug!(url = %url, "Already our instance URL");
|
||||
return Some(url.to_string());
|
||||
}
|
||||
|
||||
// Pour les autres URLs, utiliser le mechanisme de proxy standard (comme Qobuz)
|
||||
debug!(url = %url, "Proxyfying cover URL via pmocovers");
|
||||
match pmocovers::proxy_cover_url(url, base_url).await {
|
||||
Ok(local_url) => {
|
||||
debug!(result = %local_url, "Proxified successfully");
|
||||
Some(local_url)
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to proxy cover URL {}: {}", url, e);
|
||||
Some(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to fetch playback items from a media server object (container or item).
|
||||
///
|
||||
/// This function browses the server to get the entries and converts them to PlaybackItem.
|
||||
|
||||
@@ -145,6 +145,7 @@ impl InternalQueue {
|
||||
/// Fusionne les métadonnées en protégeant les streams contre la diminution de durée.
|
||||
/// Pour les streams continus, si c'est la même chanson (même titre ET même artiste ET même URI),
|
||||
/// la durée ne peut jamais diminuer.
|
||||
#[allow(dead_code)]
|
||||
fn merge_metadata_protecting_streams(
|
||||
old_metadata: &Option<crate::model::TrackMetadata>,
|
||||
new_metadata: &Option<crate::model::TrackMetadata>,
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
|
||||
const DEFAULT_MAX_AGE: u32 = 1800;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceItem {
|
||||
music_renderer: Option<Arc<MusicRenderer>>,
|
||||
|
||||
@@ -24,6 +24,7 @@ use async_stream::stream;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::header::HeaderMap,
|
||||
response::IntoResponse,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
};
|
||||
@@ -32,9 +33,78 @@ use serde::Serialize;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocovers;
|
||||
|
||||
use crate::{DeviceIdentity, DeviceOnline};
|
||||
use tracing::error;
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS - Transformation des URLs de covers LAN externes
|
||||
// ============================================================================
|
||||
|
||||
/// Transforme une URL de cover pour qu'elle soit accessible depuis le client
|
||||
///
|
||||
/// Si l'URL est une route locale de notre cache (/covers/...), on la transforme en URL absolue.
|
||||
/// Sinon, on utilise pmocovers::proxy_cover_url() pour mettre en cache et retourner notre URL.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn transform_cover_url(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option<String> {
|
||||
let url = url?;
|
||||
|
||||
// Si c'est déjà une route locale de notre cache, la transformer en URL absolue
|
||||
if url.starts_with("/covers/") {
|
||||
return Some(base_url.url_for(url));
|
||||
}
|
||||
|
||||
// Si c'est une URL de notre instance, la retourner directement
|
||||
if url.starts_with(&base_url.0) {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
|
||||
// Pour les autres URLs, utiliser le mechanisme de proxy standard
|
||||
match pmocovers::proxy_cover_url(url, base_url).await {
|
||||
Ok(local_url) => Some(local_url),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to proxy cover URL {}: {}", url, e);
|
||||
Some(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforme une URL de cover pour qu'elle soit accessible depuis le client (version synchrone)
|
||||
///
|
||||
/// Utilise un thread séparé avec son propre runtime tokio.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn transform_cover_url_sync(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option<String> {
|
||||
let url = url?;
|
||||
|
||||
// Si c'est déjà une route locale de notre cache, la transformer en URL absolue
|
||||
if url.starts_with("/covers/") {
|
||||
return Some(base_url.url_for(url));
|
||||
}
|
||||
|
||||
// Si c'est une URL de notre instance, la retourner directement
|
||||
if url.starts_with(&base_url.0) {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
|
||||
// Pour les autres URLs, utiliser un thread avec runtime
|
||||
let url_owned = url.to_string();
|
||||
let base_url_owned = base_url.0.clone();
|
||||
|
||||
let result = std::thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
runtime.block_on(async move {
|
||||
pmocovers::proxy_cover_url(&url_owned, &pmoserver::BaseUrl(base_url_owned)).await
|
||||
})
|
||||
}).join();
|
||||
|
||||
match result {
|
||||
Ok(Ok(local_url)) => Some(local_url),
|
||||
_ => Some(url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAYLOADS SSE
|
||||
// ============================================================================
|
||||
@@ -178,9 +248,10 @@ pub enum UnifiedEventPayload {
|
||||
/// Cette fonction centralise la conversion pour éviter la duplication de code
|
||||
/// entre les différents streams SSE (renderers-only et all-events).
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn renderer_event_to_payload(
|
||||
async fn renderer_event_to_payload(
|
||||
event: RendererEvent,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
base_url: &pmoserver::BaseUrl,
|
||||
) -> RendererEventPayload {
|
||||
match event {
|
||||
RendererEvent::StateChanged { id, state } => RendererEventPayload::StateChanged {
|
||||
@@ -210,7 +281,7 @@ fn renderer_event_to_payload(
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
album_art_uri: transform_cover_url(metadata.album_art_uri.as_deref(), base_url).await,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::QueueUpdated { id, queue_length } => RendererEventPayload::QueueUpdated {
|
||||
@@ -290,7 +361,7 @@ fn renderer_event_to_payload(
|
||||
/// Cette fonction centralise la conversion pour éviter la duplication de code
|
||||
/// entre les différents streams SSE (servers-only et all-events).
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn media_server_event_to_payload(
|
||||
async fn media_server_event_to_payload(
|
||||
event: MediaServerEvent,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
) -> MediaServerEventPayload {
|
||||
@@ -346,7 +417,10 @@ fn media_server_event_to_payload(
|
||||
)]
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let base_url_str = pmoserver::get_base_url_from_request(&headers);
|
||||
let base_url = pmoserver::BaseUrl(base_url_str);
|
||||
// Convert crossbeam channel to tokio channel for async compatibility
|
||||
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let rx = control_point.subscribe_events();
|
||||
@@ -406,7 +480,7 @@ pub async fn renderer_events_sse(
|
||||
// Regular events from the control point
|
||||
Some(event) = rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let payload = renderer_event_to_payload(event, timestamp);
|
||||
let payload = renderer_event_to_payload(event, timestamp, &base_url).await;
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json));
|
||||
@@ -468,7 +542,9 @@ pub async fn renderer_events_sse(
|
||||
)]
|
||||
pub async fn media_server_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
_headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// Note: les événements media server n'ont pas de album_art_uri à transformer
|
||||
// Convert crossbeam channel to tokio channel for async compatibility
|
||||
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let rx = control_point.subscribe_media_server_events();
|
||||
@@ -526,7 +602,7 @@ pub async fn media_server_events_sse(
|
||||
// Regular events from the control point
|
||||
Some(event) = rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let payload = media_server_event_to_payload(event, timestamp);
|
||||
let payload = media_server_event_to_payload(event, timestamp).await;
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json));
|
||||
@@ -586,7 +662,12 @@ pub async fn media_server_events_sse(
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> impl IntoResponse {
|
||||
pub async fn all_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let base_url_str = pmoserver::get_base_url_from_request(&headers);
|
||||
let base_url = pmoserver::BaseUrl(base_url_str);
|
||||
// Convert crossbeam channels to tokio channels for async compatibility
|
||||
let (renderer_tx, mut renderer_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (server_tx, mut server_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -677,7 +758,7 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
tokio::select! {
|
||||
Some(event) = renderer_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let renderer_payload = renderer_event_to_payload(event, timestamp);
|
||||
let renderer_payload = renderer_event_to_payload(event, timestamp, &base_url).await;
|
||||
|
||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||
|
||||
@@ -687,7 +768,7 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
}
|
||||
Some(event) = server_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let server_payload = media_server_event_to_payload(event, timestamp);
|
||||
let server_payload = media_server_event_to_payload(event, timestamp).await;
|
||||
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
|
||||
@@ -2,12 +2,11 @@ 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_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,
|
||||
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};
|
||||
use anyhow::{anyhow, Result};
|
||||
use pmodidl::DIDLLite;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
@@ -419,7 +418,9 @@ impl OhPlaylistClient {
|
||||
|
||||
// Log the raw IdArrayResponse XML for debugging
|
||||
{
|
||||
let raw_children: Vec<String> = response.children.iter()
|
||||
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(),
|
||||
@@ -767,7 +768,11 @@ impl SourceIndexCache {
|
||||
}
|
||||
|
||||
fn get(&self) -> Option<u32> {
|
||||
if self.is_valid() { self.index } else { None }
|
||||
if self.is_valid() {
|
||||
self.index
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, index: u32) {
|
||||
@@ -996,6 +1001,8 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Extracts the ID from DIDL-Lite XML metadata.
|
||||
#[allow(dead_code)]
|
||||
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
||||
if xml.trim().is_empty() {
|
||||
return None;
|
||||
@@ -1110,6 +1117,8 @@ fn parse_product_source_list(xml: &str) -> Result<Vec<OhProductSource>> {
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Check if error is an invalid OpenHome entry ID error.
|
||||
#[allow(dead_code)]
|
||||
fn is_invalid_entry_id_error(err: &ControlPointError) -> bool {
|
||||
let msg = format!("{err}");
|
||||
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
|
||||
|
||||
@@ -18,6 +18,7 @@ reqwest = { version = "0.12", features = ["blocking"] }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = "2"
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
use crate::cache;
|
||||
use crate::Cache;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use pmocache::api::{AddItemRequest, AddItemResponse, ErrorResponse};
|
||||
use pmocache::covers_route_for;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -84,3 +91,92 @@ pub async fn add_cover_item(
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Proxy pour covers LAN externes
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CoverProxyParams {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CoverProxyResponse {
|
||||
pub cached_url: String,
|
||||
pub pk: String,
|
||||
}
|
||||
|
||||
/// GET /covers/proxy?url=<encoded_url>
|
||||
/// Proxy transparent qui :
|
||||
/// 1. Ajoute l'URL au cache (add_from_url gère déduplication)
|
||||
/// 2. Retourne l'URL locale du cache
|
||||
/// Note: Si l'URL est déjà une cover locale de notre instance, on retourne directement l'URL
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub async fn cover_proxy_handler(
|
||||
Query(params): Query<CoverProxyParams>,
|
||||
State(cache): State<Arc<Cache>>,
|
||||
Extension(base_url): Extension<pmoserver::BaseUrl>,
|
||||
) -> impl IntoResponse {
|
||||
let external_url = ¶ms.url;
|
||||
|
||||
// Si c'est déjà une URL locale de NOTRE instance pmomusic, la retourner directement
|
||||
if is_local_cover_url(external_url, &base_url) {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(CoverProxyResponse {
|
||||
cached_url: external_url.clone(),
|
||||
pk: String::new(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Ajouter au cache (add_from_url gère la déduplication et le download)
|
||||
match cache.add_from_url(external_url, Some("external-covers")).await {
|
||||
Ok(pk) => {
|
||||
// Retourner l'URL locale
|
||||
let local_url = base_url.url_for(&covers_route_for(&pk, None));
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CoverProxyResponse {
|
||||
cached_url: local_url,
|
||||
pk,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ErrorResponse {
|
||||
error: "CACHE_ERROR".to_string(),
|
||||
message: format!("Failed to cache external cover: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic
|
||||
/// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées
|
||||
fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool {
|
||||
url.starts_with(&base_url.0)
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL doit être proxyfiée (URL LAN externe)
|
||||
fn should_proxy_url(url: &str) -> bool {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
// Proxy uniquement les URLs LAN (pas les URLs publiques)
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_loopback(),
|
||||
std::net::IpAddr::V6(ipv6) => ipv6.is_loopback(),
|
||||
};
|
||||
}
|
||||
// aussi les .local
|
||||
return host.ends_with(".local") || host == "localhost";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -135,6 +135,77 @@ pub fn get_cover_cache() -> Option<Arc<Cache>> {
|
||||
COVER_CACHE.get().cloned()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper pour proxyfier les URLs de covers externes
|
||||
// ============================================================================
|
||||
|
||||
/// Transforme une URL de cover externe en URL locale du cache.
|
||||
///
|
||||
/// Si l'URL est déjà une route locale de notre cache, la retourne directement.
|
||||
/// Sinon, ajoute l'URL au cache (download si nécessaire) et retourne l'URL locale.
|
||||
///
|
||||
/// Usage :
|
||||
/// ```rust
|
||||
/// let local_url = pmocovers::proxy_cover_url("https://example.com/cover.jpg").await?;
|
||||
/// ```
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub async fn proxy_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result<String> {
|
||||
proxy_cover_url_sync_impl(url, base_url).await
|
||||
}
|
||||
|
||||
/// Version synchrone de proxy_cover_url.
|
||||
/// Utilise un runtime tokio temporaire pour exécuter add_from_url.
|
||||
///
|
||||
/// Usage :
|
||||
/// ```rust
|
||||
/// let local_url = pmocovers::proxy_cover_url_sync("https://example.com/cover.jpg", base_url);
|
||||
/// ```
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub fn proxy_cover_url_sync(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result<String> {
|
||||
// Si c'est déjà une route locale de notre cache, retourner directement
|
||||
if url.starts_with("/covers/") {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Si c'est déjà une URL de notre instance, la retourner directement
|
||||
if url.starts_with(&base_url.0) {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Ajouter au cache en utilisant un runtime temporaire
|
||||
let cache = get_cover_cache()
|
||||
.ok_or_else(|| anyhow::anyhow!("Cover cache not initialized"))?;
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let pk = runtime.block_on(async move {
|
||||
cache.add_from_url(url, Some("external-covers")).await
|
||||
})?;
|
||||
|
||||
let route = pmocache::covers_route_for(&pk, None);
|
||||
Ok(base_url.url_for(&route))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn proxy_cover_url_sync_impl(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result<String> {
|
||||
// Si c'est déjà une route locale de notre cache, retourner directement
|
||||
if url.starts_with("/covers/") {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Si c'est déjà une URL de notre instance, la retourner directement
|
||||
if url.starts_with(&base_url.0) {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
// Ajouter au cache (add_from_url gère déduplication et download)
|
||||
let cache = get_cover_cache()
|
||||
.ok_or_else(|| anyhow::anyhow!("Cover cache not initialized"))?;
|
||||
|
||||
let pk = cache.add_from_url(url, Some("external-covers")).await?;
|
||||
let route = pmocache::covers_route_for(&pk, None);
|
||||
Ok(base_url.url_for(&route))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension pmoserver
|
||||
// ============================================================================
|
||||
@@ -382,6 +453,10 @@ impl CoverCacheExt for pmoserver::Server {
|
||||
"/consolidate",
|
||||
axum::routing::post(pmocache::api::consolidate_cache::<CoversConfig>),
|
||||
)
|
||||
.route(
|
||||
"/proxy",
|
||||
axum::routing::get(crate::api::cover_proxy_handler),
|
||||
)
|
||||
.with_state(cache.clone());
|
||||
|
||||
let openapi = crate::ApiDoc::openapi();
|
||||
|
||||
@@ -211,9 +211,10 @@ impl RadioParadiseSource {
|
||||
let year = json["year"].as_u64().map(|y| y as u32);
|
||||
// Préférer l'URL de cache si cover_pk est fourni par le pipeline
|
||||
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
|
||||
// Stocker la route relative (le handler REST appliquera base_url.url_for())
|
||||
let cover_url = cover_pk
|
||||
.as_ref()
|
||||
.map(|pk| pmocache::covers_absolute_url_for(pk, None))
|
||||
.map(|pk| pmocache::covers_route_for(pk, None))
|
||||
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
||||
.or_else(|| Some(self.default_cover_url()));
|
||||
|
||||
|
||||
@@ -333,6 +333,7 @@ impl PersistenceManager {
|
||||
}
|
||||
|
||||
/// Supprime tous les tracks contenant un cache_pk donné
|
||||
#[allow(dead_code)]
|
||||
pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM tracks WHERE cache_pk = ?1", params![cache_pk])
|
||||
|
||||
@@ -18,11 +18,11 @@ struct UserInfo {
|
||||
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
_email: Option<String>,
|
||||
#[serde(default)]
|
||||
firstname: Option<String>,
|
||||
_firstname: Option<String>,
|
||||
#[serde(default)]
|
||||
lastname: Option<String>,
|
||||
_lastname: Option<String>,
|
||||
credential: CredentialInfo,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ struct PaginatedResponse<T> {
|
||||
#[serde(default)]
|
||||
total: Option<u32>,
|
||||
#[serde(default)]
|
||||
limit: Option<u32>,
|
||||
_limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
offset: Option<u32>,
|
||||
_offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /album/get
|
||||
@@ -91,7 +91,7 @@ struct ImageResponse {
|
||||
|
||||
/// Réponse genre
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenreResponse {
|
||||
pub(crate) struct GenreResponse {
|
||||
#[serde(default)]
|
||||
id: Option<u32>,
|
||||
name: String,
|
||||
|
||||
@@ -15,7 +15,7 @@ struct PaginatedResponse<T> {
|
||||
|
||||
/// Réponse de l'endpoint /favorite/getUserFavorites
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct FavoritesResponse {
|
||||
pub struct FavoritesResponse {
|
||||
#[serde(default)]
|
||||
albums: Option<PaginatedResponse<AlbumResponse>>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -260,7 +260,7 @@ impl QobuzClient {
|
||||
|
||||
// Optimization: Login once with first secret to get auth token
|
||||
// Then test all secrets using the same token
|
||||
if let Some((first_timezone, first_secret)) = secrets.first() {
|
||||
if let Some((_first_timezone, first_secret)) = secrets.first() {
|
||||
if let Ok(temp_api) =
|
||||
QobuzApi::with_raw_secret(&app_id, first_secret)
|
||||
{
|
||||
|
||||
1009
pmoqobuz/src/client.rs-e
Normal file
1009
pmoqobuz/src/client.rs-e
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,34 +253,28 @@ impl CachedMetadata {
|
||||
}
|
||||
};
|
||||
|
||||
// Tenter de cacher la cover
|
||||
// Tenter de catcher la cover
|
||||
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
|
||||
Ok(pk) => {
|
||||
// Construire l'URL publique
|
||||
// Note: add_from_url() lance le téléchargement complet en arrière-plan.
|
||||
// L'URL est valide immédiatement — si le fichier n'est pas encore prêt,
|
||||
// le client web doit réessayer (retry avec backoff).
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
// Stocker la route relative (le handler REST appliquera base_url.url_for())
|
||||
let album_art_route = pmocache::covers_route_for(&pk, None);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
"Cached cover - url: {}, PK: {}, public_url: {}",
|
||||
"Cached cover - url: {}, PK: {}, route: {}",
|
||||
cover_url,
|
||||
pk,
|
||||
public_url
|
||||
album_art_route
|
||||
);
|
||||
|
||||
(Some(public_url), Some(pk))
|
||||
(Some(album_art_route), Some(pk))
|
||||
}
|
||||
Err(e) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to cache Radio France cover {}: {}", cover_url, e);
|
||||
// Fallback sur le logo par défaut en cas d'erreur
|
||||
let logo_url = format!(
|
||||
"{}/api/radiofrance/default-logo",
|
||||
server_base_url.trim_end_matches('/')
|
||||
);
|
||||
(Some(logo_url), None)
|
||||
// Fallback sur le logo par défaut - route relative
|
||||
let logo_route = "/api/radiofrance/default-logo".to_string();
|
||||
(Some(logo_route), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,3 +220,36 @@ pub fn get_server_base_url() -> Option<String> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BaseUrl pour les handlers
|
||||
// ============================================================================
|
||||
|
||||
/// URL de base effective pour la requête.
|
||||
/// Calculée depuis X-Forwarded-Proto/Host ou Host header.
|
||||
/// Les handlers peuvent appeler get_base_url_from_request(headers) pour l'obtenir.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
|
||||
impl BaseUrl {
|
||||
/// Construit une URL absolue en combinant la base URL de la requête avec une route relative.
|
||||
/// Usage : BaseUrl::url_for(&pmocache::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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la BaseUrl depuis les headers de la requête.
|
||||
/// Calcule la BaseUrl depuis X-Forwarded-*/Host ou utilise le serveur global.
|
||||
/// Cette fonction peut être appelée par les handlers qui ont besoin de construire des URLs.
|
||||
pub fn get_base_url_from_request(headers: &axum::http::HeaderMap) -> String {
|
||||
get_request_base_url(headers)
|
||||
.or_else(|| get_server_base_url())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"BaseUrl: impossible de déterminer l'URL de base.\n\
|
||||
Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP."
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ impl Server {
|
||||
let base_url = base_url.into();
|
||||
|
||||
// Créer le router initial avec l'endpoint de registre
|
||||
// Note: le base_url_layer est appliqué plus tard via le fallback dynamique
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone());
|
||||
|
||||
@@ -443,7 +443,7 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
/// let item = source.get_item("track-123").await?;
|
||||
/// println!("Now playing: {} by {}", item.title, item.artist.unwrap_or_default());
|
||||
/// ```
|
||||
async fn get_item(&self, object_id: &str) -> Result<Item> {
|
||||
async fn get_item(&self, _object_id: &str) -> Result<Item> {
|
||||
// Default implementation: try to find it in parent's browse result
|
||||
// This is inefficient and should be overridden by implementations
|
||||
Err(MusicSourceError::NotSupported(
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
/// ```
|
||||
///
|
||||
/// # Notes d'implémentation
|
||||
///
|
||||
/// - Les `Arc<StateVariable>` sont clonés (shallow copy du pointeur)
|
||||
/// - Chaque `Argument` est wrappé dans un `Arc`
|
||||
/// - L'`Action` finale est wrappée dans un `Arc`
|
||||
@@ -112,6 +111,7 @@ macro_rules! define_action {
|
||||
}
|
||||
$(with handler $handler:expr)?
|
||||
) => {
|
||||
#[allow(unused_must_use)]
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
@@ -135,6 +135,7 @@ macro_rules! define_action {
|
||||
(pub static $name:ident = $action_name:literal stateless
|
||||
$(with handler $handler:expr)?
|
||||
) => {
|
||||
#[allow(unused_must_use)]
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
@@ -156,6 +157,7 @@ macro_rules! define_action {
|
||||
}
|
||||
$(with handler $handler:expr)?
|
||||
) => {
|
||||
#[allow(unused_must_use)]
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
@@ -178,6 +180,7 @@ macro_rules! define_action {
|
||||
(pub static $name:ident = $action_name:literal
|
||||
$(with handler $handler:expr)?
|
||||
) => {
|
||||
#[allow(unused_must_use)]
|
||||
pub static $name: once_cell::sync::Lazy<std::sync::Arc<$crate::actions::Action>> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
let mut ac = $crate::actions::Action::new($action_name.to_string());
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
|
||||
/// // url = "http://localhost:8080/covers/images/abc123/300"
|
||||
/// ```
|
||||
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
|
||||
Ok(pmocache::covers_absolute_url_for(
|
||||
Ok(pmocache::covers_absolute_url_for_upnp(
|
||||
pk,
|
||||
size.map(|s| s.to_string()).as_deref(),
|
||||
))
|
||||
|
||||
@@ -17,6 +17,7 @@ use axum::{
|
||||
response::{IntoResponse, Json},
|
||||
routing::get,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use pmoserver::Server;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
@@ -218,6 +219,7 @@ async fn get_service_variables(
|
||||
/// Trait d'extension pour enregistrer l'API UPnP sur un serveur.
|
||||
///
|
||||
/// Similaire à `WebAppExt` et `CoverCacheExt`.
|
||||
#[async_trait]
|
||||
pub trait UpnpApiExt {
|
||||
/// Enregistre l'API REST d'introspection UPnP.
|
||||
///
|
||||
@@ -229,6 +231,7 @@ pub trait UpnpApiExt {
|
||||
async fn register_upnp_api(&mut self);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UpnpApiExt for Server {
|
||||
async fn register_upnp_api(&mut self) {
|
||||
info!("📡 Registering UPnP introspection API...");
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// Messages envoyés du Backend → Navigateur
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum ServerMessage {
|
||||
SessionCreated {
|
||||
token: String,
|
||||
@@ -38,6 +39,7 @@ pub enum ServerMessage {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum TransportAction {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -60,12 +62,25 @@ pub struct CommandParams {
|
||||
/// Messages envoyés du Navigateur → Backend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum ClientMessage {
|
||||
Init { capabilities: BrowserCapabilities },
|
||||
StateUpdate { state: PlaybackState },
|
||||
PositionUpdate { position: String, duration: String },
|
||||
MetadataUpdate { metadata: TrackMetadata },
|
||||
VolumeUpdate { volume: u16, mute: bool },
|
||||
Init {
|
||||
capabilities: BrowserCapabilities,
|
||||
},
|
||||
StateUpdate {
|
||||
state: PlaybackState,
|
||||
},
|
||||
PositionUpdate {
|
||||
position: String,
|
||||
duration: String,
|
||||
},
|
||||
MetadataUpdate {
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
VolumeUpdate {
|
||||
volume: u16,
|
||||
mute: bool,
|
||||
},
|
||||
/// Envoyé quand la piste courante se termine naturellement (gapless).
|
||||
/// Le backend fait avancer current → next dans l'état partagé.
|
||||
TrackEnded,
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.32
|
||||
0.3.33
|
||||
|
||||
Reference in New Issue
Block a user