Merge pull request 'push-kvzwrolnknzx' (#86) from push-kvzwrolnknzx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s

Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-04-04 12:59:09 +02:00
46 changed files with 1936 additions and 119 deletions

1
.gitignore vendored
View File

@@ -49,3 +49,4 @@ RF.json
RF_old.json
.claude/
.claude.old
Kilo-session.md

View 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 = &params.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
View File

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

View File

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

View File

@@ -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"]

View File

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

View File

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

View File

@@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

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

View File

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

View File

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

View File

@@ -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 ")?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = &params.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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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."
);
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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...");

View File

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

View File

@@ -1 +1 @@
0.3.32
0.3.33