feat: add UrlSource for arbitrary URL-based media playback
Introduces the pmourlsource crate as a standard MusicSource that resolves HTTP/HTTPS URLs via a priority-ordered UrlHandler registry. Includes specialized handlers for Qobuz and Radio France alongside an SSRF-safe generic scraper supporting playlists, feeds, and HTML audio. Integrates with existing browse flows, REST endpoints, and Android Web Share targets while updating source capability flags to correctly route URL queries.
This commit is contained in:
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Play From URL — source `UrlSource`
|
||||
|
||||
Inspiré par BubbleUPnP : recevoir n'importe quelle URL (lien de partage Qobuz,
|
||||
flux radio, playlist M3U, page web contenant de l'audio…) et la jouer
|
||||
immédiatement sur le renderer actif.
|
||||
|
||||
---
|
||||
|
||||
## Vision architecturale
|
||||
|
||||
`UrlSource` est une **source musicale ordinaire** qui implémente `MusicSource`,
|
||||
exactement comme Qobuz, RadioFrance ou RadioParadise. Elle apparaît dans le
|
||||
drawer gauche au même titre que les autres sources du serveur PMO.
|
||||
|
||||
Sa particularité : sa **barre de recherche est le champ URL**. L'utilisateur
|
||||
colle ou tape une URL, appuie sur Entrée — la source résout l'URL et retourne
|
||||
le contenu jouable comme un `BrowseResult` normal.
|
||||
|
||||
```
|
||||
Drawer gauche
|
||||
└─ PMO Music Server
|
||||
├─ Qobuz
|
||||
├─ Radio Paradise
|
||||
├─ Radio France
|
||||
└─ URL / Partage ← nouvelle source
|
||||
└─ [barre de recherche = champ URL]
|
||||
└─ coller une URL + Entrée
|
||||
└─ résolution → BrowseResult → queue + play
|
||||
```
|
||||
|
||||
Avantages de cette approche :
|
||||
- **Zéro nouvelle UI** : la barre de recherche existante du drawer gère tout
|
||||
- **Zéro nouvel endpoint REST** : browse/search existants suffisent
|
||||
- **Zéro cas particulier** dans le drawer ou le content directory handler
|
||||
- `browse()` du container racine peut afficher un **historique** des URLs jouées
|
||||
|
||||
---
|
||||
|
||||
## Trait `UrlHandler` (dans `pmosource`)
|
||||
|
||||
Chaque source (et un handler générique) peut revendiquer les URLs qu'elle sait
|
||||
résoudre.
|
||||
|
||||
```rust
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante
|
||||
/// → la UrlSource délègue le browse à cette source
|
||||
SourceContainer {
|
||||
source_id: String, // "qobuz", "radiofrance", …
|
||||
container_id: String, // "qobuz:album:l46fxnqnxp5vs"
|
||||
},
|
||||
/// Liste de tracks (M3U, PLS, XSPF, RSS/podcast…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
/// Track unique ou flux continu
|
||||
Track {
|
||||
uri: String,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
Stream {
|
||||
uri: String,
|
||||
metadata: StreamMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier (défaut 50)
|
||||
fn priority(&self) -> u8 { 50 }
|
||||
/// Test rapide sans I/O (regex sur l'URL)
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé)
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handlers spécifiques aux sources
|
||||
|
||||
### `QobuzUrlHandler` (dans `pmoqobuz`) — priorité 90
|
||||
|
||||
URLs reconnues. Les IDs sont potentiellement alphanumériques pour tous les
|
||||
types (pas seulement les albums) :
|
||||
|
||||
| Forme d'URL | Exemple |
|
||||
|---|---|
|
||||
| `open.qobuz.com/album/<id>` | `https://open.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `play.qobuz.com/album/<id>` | `https://play.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `open.qobuz.com/track/<id>` | `https://open.qobuz.com/track/48471123` |
|
||||
| `open.qobuz.com/playlist/<id>` | `https://open.qobuz.com/playlist/63246908` |
|
||||
| `open.qobuz.com/artist/<id>` | `https://open.qobuz.com/artist/125709` |
|
||||
|
||||
Regex d'extraction : `[a-zA-Z0-9]+` pour tous les types sans exception.
|
||||
|
||||
Résolution sans appel API — l'ID est directement mappé sur un container_id :
|
||||
|
||||
```
|
||||
open.qobuz.com/album/l46fxnqnxp5vs
|
||||
→ ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz",
|
||||
container_id: "qobuz:album:l46fxnqnxp5vs",
|
||||
}
|
||||
```
|
||||
|
||||
### `RadioFranceUrlHandler` (dans `pmoradiofrance`) — priorité 90
|
||||
|
||||
URLs `radiofrance.fr/*`, `francemusique.fr/*`, `fip.fr/*`, etc.
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
### `RadioParadiseUrlHandler` (dans `pmoparadise`) — priorité 90
|
||||
|
||||
URLs `radioparadise.com/*`
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
---
|
||||
|
||||
## Handler générique (dans `pmourlresolver`, nouveau crate) — priorité 10
|
||||
|
||||
Dernier recours. Pipeline interne :
|
||||
|
||||
```
|
||||
URL
|
||||
│
|
||||
├─ Garde-fou SSRF : rejeter si IP résolue est privée/locale
|
||||
│ (RFC-1918 : 10/8, 172.16/12, 192.168/16 ; loopback : 127/8, ::1 ;
|
||||
│ link-local : 169.254/16, fe80::/10)
|
||||
│ → aucun cas d'usage légitime pour une URL interne ici
|
||||
│
|
||||
├─ HEAD request → Content-Type audio/* ?
|
||||
│ └─ → ResolvedContent::Stream / Track (URI directe)
|
||||
│
|
||||
├─ Extension ou Content-Type playlist ?
|
||||
│ ├─ .m3u / .m3u8 / application/vnd.apple.mpegurl → parse M3U
|
||||
│ ├─ .pls / audio/x-scpls → parse PLS
|
||||
│ └─ .xspf / application/xspf+xml → parse XSPF
|
||||
│
|
||||
├─ application/rss+xml / application/xml ?
|
||||
│ └─ → parse RSS, extraire les <enclosure> audio → Playlist
|
||||
│
|
||||
└─ text/html ?
|
||||
└─ GET + parse HTML
|
||||
├─ <audio src="...">
|
||||
├─ <link type="application/rss+xml"> → RSS/Podcast
|
||||
├─ og:audio
|
||||
└─ JSON-LD @type MusicRecording / MusicAlbum
|
||||
```
|
||||
|
||||
Pas de yt-dlp ni de dépendance Python externe — hors scope.
|
||||
|
||||
---
|
||||
|
||||
## `UrlSource` — implémentation de `MusicSource`
|
||||
|
||||
```rust
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver, // registre des handlers
|
||||
history: Arc<RwLock<VecDeque<HistoryEntry>>>, // dernières URLs
|
||||
}
|
||||
```
|
||||
|
||||
### `name()` / `id()`
|
||||
|
||||
```rust
|
||||
fn name(&self) -> &str { "URL / Partage" }
|
||||
fn id(&self) -> &str { "url" }
|
||||
```
|
||||
|
||||
### `root_container()`
|
||||
|
||||
Retourne un container dont le contenu (`browse("url")`) est l'historique des
|
||||
dernières URLs résolues avec succès (titre, source résolue, date).
|
||||
|
||||
### `search(query)` — cœur de la fonctionnalité
|
||||
|
||||
`query.text` est l'URL collée par l'utilisateur.
|
||||
|
||||
```
|
||||
search(url)
|
||||
│
|
||||
├─ resolver.resolve(url)
|
||||
│
|
||||
└─ match ResolvedContent
|
||||
├─ SourceContainer { source_id, container_id }
|
||||
│ → get_source(source_id) → source.browse(container_id)
|
||||
│ → retourner le BrowseResult tel quel
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Playlist { items }
|
||||
│ → construire un BrowseResult::Items depuis les tracks
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Track / Stream
|
||||
│ → BrowseResult::Items avec un seul item
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
└─ Err → BrowseResult vide + log
|
||||
```
|
||||
|
||||
Pour la délégation `SourceContainer`, `UrlSource` accède au `SOURCE_REGISTRY`
|
||||
global (déjà disponible dans `pmosource`). Elle est enregistrée après les autres
|
||||
sources donc elles sont toutes présentes au moment de la résolution.
|
||||
|
||||
### `browse(container_id)`
|
||||
|
||||
- `"url"` → liste de l'historique (containers/items)
|
||||
- `"url:history:<n>"` → détail d'une entrée historique (si Playlist)
|
||||
|
||||
---
|
||||
|
||||
## Initialisation dans `pmomediaserver`
|
||||
|
||||
```rust
|
||||
// Après enregistrement de Qobuz, RadioFrance, RadioParadise…
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
resolver.register(Arc::new(QobuzUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioFranceUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioParadiseUrlHandler::new()));
|
||||
resolver.register(Arc::new(GenericUrlHandler::new())); // toujours en dernier
|
||||
|
||||
let url_source = Arc::new(UrlSource::new(resolver));
|
||||
register_source(url_source).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
Le pipeline de résolution (`UrlResolver`) est le même quel que soit le point
|
||||
d'entrée. Deux modes complémentaires :
|
||||
|
||||
### Mode "pull" — le drawer
|
||||
|
||||
1. L'utilisateur ouvre le drawer gauche → voit "URL / Partage" dans la liste
|
||||
2. Il entre dedans → voit l'historique et la barre avec placeholder "Coller une URL…"
|
||||
3. Il colle `https://open.qobuz.com/album/l46fxnqnxp5vs` + Entrée
|
||||
4. Le drawer affiche les tracks de l'album (délégation Qobuz transparente)
|
||||
5. Il clique ▶ sur un track ou l'album entier → lecture normale
|
||||
|
||||
Aucune modification du drawer nécessaire.
|
||||
|
||||
### Mode "push" — endpoint REST + Web Share Target (Android)
|
||||
|
||||
Endpoint REST dans `pmocontrol` :
|
||||
|
||||
```
|
||||
POST /api/play-url
|
||||
{ "url": "https://open.qobuz.com/album/l46fxnqnxp5vs" }
|
||||
```
|
||||
|
||||
Résout l'URL via `UrlResolver` → ajoute au renderer actif → lecture immédiate.
|
||||
Pas de navigation dans le drawer, pas de clic supplémentaire.
|
||||
|
||||
**Web Share Target (PWA)** — intégration dans le share sheet Android :
|
||||
|
||||
```json
|
||||
// manifest.json
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"params": { "url": "url" }
|
||||
}
|
||||
```
|
||||
|
||||
La page `/share?url=...` appelle l'endpoint REST et se ferme. Depuis n'importe
|
||||
quelle application Android (Qobuz, navigateur, Spotify…) : menu "Partager" →
|
||||
choisir PMOMusic → l'album/track joue immédiatement sur le renderer courant,
|
||||
exactement comme BubbleUPnP.
|
||||
|
||||
Le renderer "courant" est celui qui est sélectionné dans la session active.
|
||||
Pour une PWA installée sur Android, c'est la session de l'utilisateur
|
||||
qui a installé l'app. Si plusieurs renderers sont disponibles, l'endpoint
|
||||
peut prendre un paramètre optionnel `renderer_id` pour cibler explicitement.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
### Étape 1 — Trait + QobuzUrlHandler + UrlSource minimale
|
||||
|
||||
- [ ] Ajouter `UrlHandler`, `ResolvedContent`, `UrlResolver` dans `pmosource`
|
||||
- [ ] Implémenter `QobuzUrlHandler` dans `pmoqobuz` (regex + mapping container_id)
|
||||
- [ ] Implémenter `UrlSource` avec `search()` gérant `SourceContainer`
|
||||
- [ ] Enregistrer dans `pmomediaserver`
|
||||
- [ ] Tester : coller un lien Qobuz → album joue
|
||||
|
||||
### Étape 2 — Formats de playlist directs
|
||||
|
||||
- [ ] Nouveau crate `pmourlresolver` avec `GenericUrlHandler`
|
||||
- [ ] Garde-fou SSRF (`is_safe_url()`)
|
||||
- [ ] Détection Content-Type + parse M3U, PLS, XSPF
|
||||
- [ ] `UrlSource::search()` gère `Playlist` et `Track/Stream`
|
||||
|
||||
### Étape 3 — Scraper HTML + historique
|
||||
|
||||
- [ ] Parse HTML : `<audio>`, og:audio, JSON-LD, RSS
|
||||
- [ ] Historique dans `UrlSource::browse()`
|
||||
- [ ] Placeholder adapté dans la barre de recherche du drawer
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
**Q1 — Barre de recherche : placeholder contextuel**
|
||||
Quand l'utilisateur est dans "URL / Partage", le placeholder devrait afficher
|
||||
"Coller une URL…" plutôt que "Rechercher…". Le drawer peut-il adapter le
|
||||
placeholder selon la source active ? À voir si c'est utile en pratique (le
|
||||
titre de la source dans le header est déjà indicatif).
|
||||
|
||||
**Q2 — Redirections**
|
||||
Les liens de partage mobiles Qobuz peuvent être des URLs raccourcies. Suivre
|
||||
les redirections automatiquement (reqwest le fait avec
|
||||
`redirect::Policy::limited(5)`).
|
||||
|
||||
**Q3 — Validation Qobuz**
|
||||
Le `QobuzUrlHandler` retourne un `SourceContainer` sans vérifier que l'album
|
||||
existe ou est accessible. L'erreur éventuelle sera levée au moment du browse
|
||||
délégué à `QobuzSource`. C'est acceptable : l'erreur arrivera rapidement avec
|
||||
un message clair.
|
||||
126
Cargo.lock
generated
126
Cargo.lock
generated
@@ -188,6 +188,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
@@ -963,6 +975,23 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1876,8 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1887,9 +1918,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2217,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2751,6 +2785,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -3969,6 +4009,7 @@ dependencies = [
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmourlsource",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -4230,6 +4271,20 @@ dependencies = [
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"pmodidl",
|
||||
"pmosource",
|
||||
"reqwest",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.2"
|
||||
@@ -4529,6 +4584,61 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
@@ -4748,6 +4858,7 @@ version = "0.12.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
@@ -4770,6 +4881,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4777,6 +4890,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
@@ -4786,6 +4900,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4958,6 +5073,7 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6641,6 +6757,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -23,6 +23,7 @@ members = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmocontrol",
|
||||
"pmourlsource",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -7,7 +7,7 @@ edition = "2024"
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "urlsource", "api"] }
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
|
||||
@@ -60,6 +60,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
|
||||
}
|
||||
|
||||
// Enregistrer la source URL / Partage
|
||||
info!("🔗 Registering URL source...");
|
||||
if let Err(e) = server.write().await.register_urlsource().await {
|
||||
tracing::warn!("⚠️ Failed to register URL source: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
|
||||
@@ -2399,12 +2399,15 @@ fn search_result_container_id(entries: &[ContainerEntry]) -> String {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.is_container)
|
||||
.and_then(|entry| {
|
||||
.map(|entry| {
|
||||
let parts: Vec<&str> = entry.id.splitn(5, ':').collect();
|
||||
if parts.len() == 5 && parts[0] == "qobuz" && parts[1] == "search" {
|
||||
Some(format!("qobuz:search:{}:all:{}", parts[2], parts[4]))
|
||||
// Résultat de recherche Qobuz : reconstruire le container virtuel parent
|
||||
// ex. "qobuz:search:catalog:albums:Beethoven" → "qobuz:search:catalog:all:Beethoven"
|
||||
format!("qobuz:search:{}:all:{}", parts[2], parts[4])
|
||||
} else {
|
||||
None
|
||||
// Résultat d'une autre source (ex. UrlSource) : retourner l'ID tel quel
|
||||
entry.id.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "search".to_string())
|
||||
|
||||
@@ -26,6 +26,7 @@ utoipa = { version = "5.3", optional = true }
|
||||
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
||||
pmoparadise = { path = "../pmoparadise", optional = true }
|
||||
pmoradiofrance = { path = "../pmoradiofrance", optional = true }
|
||||
pmourlsource = { path = "../pmourlsource", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
@@ -61,3 +62,5 @@ radiofrance = [
|
||||
"pmoradiofrance/logging",
|
||||
"dep:pmoconfig"
|
||||
]
|
||||
# Feature pour activer la source URL / Partage
|
||||
urlsource = ["api", "dep:pmourlsource"]
|
||||
|
||||
@@ -273,12 +273,33 @@ impl ContentHandler {
|
||||
}
|
||||
}
|
||||
BrowseResult::Items(items) => {
|
||||
if let Some(item) = items.first() {
|
||||
let didl = to_didl_lite(&[], &[item.clone()])?;
|
||||
// object_id is a container (album, playlist…) whose
|
||||
// browse() returns its children as Items. BrowseMetadata
|
||||
// must return the container itself, not the first child.
|
||||
let title = items
|
||||
.first()
|
||||
.and_then(|i| i.album.as_deref())
|
||||
.unwrap_or(object_id)
|
||||
.to_string();
|
||||
let album_art =
|
||||
items.first().and_then(|i| i.album_art.clone());
|
||||
let container = Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(items.len().to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
let didl = to_didl_lite(&[container], &[])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
}
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
if let Some(container) = containers.first() {
|
||||
let didl = to_didl_lite(&[container.clone()], &[])?;
|
||||
@@ -615,8 +636,19 @@ impl ContentHandler {
|
||||
let mut all_containers = Vec::new();
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
// Si le texte ressemble à une URL, seules les sources qui gèrent les URLs
|
||||
// sont interrogées — les autres (Qobuz, etc.) interpréteraient l'URL comme
|
||||
// du texte libre et renverraient des résultats parasites.
|
||||
let is_url_query = text.starts_with("http://") || text.starts_with("https://");
|
||||
|
||||
for source in list_all_sources().await {
|
||||
if source.capabilities().supports_search {
|
||||
let caps = source.capabilities();
|
||||
if !caps.supports_search {
|
||||
continue;
|
||||
}
|
||||
if is_url_query && !caps.handles_url_input {
|
||||
continue;
|
||||
}
|
||||
if let Ok(result) = source.search(&query).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
@@ -628,7 +660,6 @@ impl ContentHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = (all_containers.len() + all_items.len()) as u32;
|
||||
let didl = to_didl_lite(&all_containers, &all_items)?;
|
||||
|
||||
@@ -23,6 +23,10 @@ pub enum SourceInitError {
|
||||
#[error("Failed to initialize Radio France: {0}")]
|
||||
RadioFranceError(String),
|
||||
|
||||
#[cfg(feature = "urlsource")]
|
||||
#[error("Failed to initialize URL source: {0}")]
|
||||
UrlSourceError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
@@ -141,6 +145,14 @@ pub trait SourcesExt {
|
||||
/// ```
|
||||
#[cfg(feature = "radiofrance")]
|
||||
async fn register_radiofrance(&mut self) -> Result<()>;
|
||||
|
||||
/// Enregistre la source URL / Partage
|
||||
///
|
||||
/// Cette source permet de coller n'importe quelle URL (lien de partage Qobuz,
|
||||
/// flux audio, playlist M3U…) dans la barre de recherche et de lancer la lecture
|
||||
/// directement. Aucune authentification requise.
|
||||
#[cfg(feature = "urlsource")]
|
||||
async fn register_urlsource(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -281,6 +293,33 @@ impl SourcesExt for Server {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "urlsource")]
|
||||
async fn register_urlsource(&mut self) -> Result<()> {
|
||||
use pmourlsource::{GenericUrlHandler, QobuzUrlHandler, RadioFranceUrlHandler, UrlResolver, UrlSource};
|
||||
|
||||
tracing::info!("Initializing URL source...");
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
// Handlers spécialisés (priorité haute) — résolution sans I/O ou API dédiée
|
||||
resolver.register(Box::new(QobuzUrlHandler::new()));
|
||||
match RadioFranceUrlHandler::new() {
|
||||
Ok(h) => resolver.register(Box::new(h)),
|
||||
Err(e) => tracing::warn!("Failed to build RadioFranceUrlHandler HTTP client: {}", e),
|
||||
}
|
||||
// Handler générique (priorité basse) — HTTP GET + scraping HTML/RSS
|
||||
match GenericUrlHandler::new() {
|
||||
Ok(h) => resolver.register(Box::new(h)),
|
||||
Err(e) => tracing::warn!("Failed to build GenericUrlHandler HTTP client: {}", e),
|
||||
}
|
||||
|
||||
let source = Arc::new(UrlSource::new(resolver));
|
||||
self.register_music_source(source).await;
|
||||
|
||||
tracing::info!("✅ URL source registered successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -816,6 +816,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2088,6 +2088,7 @@ impl MusicSource for QobuzSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: true,
|
||||
supports_pagination: true,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ impl MusicSource for RadioFranceSource {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct SourceCapabilities {
|
||||
pub supports_advanced_search: bool,
|
||||
/// Supports pagination in browse operations
|
||||
pub supports_pagination: bool,
|
||||
/// Handles URL input (http/https) instead of plain text search queries.
|
||||
/// When true, this source is called exclusively for URL-like search inputs.
|
||||
pub handles_url_input: bool,
|
||||
}
|
||||
|
||||
/// Audio format information
|
||||
@@ -650,6 +653,7 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
pmourlsource/Cargo.toml
Normal file
14
pmourlsource/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
|
||||
url = "2"
|
||||
BIN
pmourlsource/assets/url-source.webp
Normal file
BIN
pmourlsource/assets/url-source.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
122
pmourlsource/src/handler.rs
Normal file
122
pmourlsource/src/handler.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UrlResolverError {
|
||||
#[error("URL non reconnue : {0}")]
|
||||
NotSupported(String),
|
||||
#[error("Résolution échouée : {0}")]
|
||||
ResolutionFailed(String),
|
||||
#[error("URL bloquée (réseau privé/local)")]
|
||||
SsrfBlocked,
|
||||
}
|
||||
|
||||
/// Un track résolu depuis une source externe (RSS enclosure, audio direct…)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedTrack {
|
||||
/// URL directe de l'audio (jouable par le renderer)
|
||||
pub uri: String,
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<String>, // format "H:MM:SS.mmm" UPnP
|
||||
pub album_art: Option<String>,
|
||||
pub mime_type: String, // ex. "audio/mpeg", "audio/aac"
|
||||
}
|
||||
|
||||
impl ResolvedTrack {
|
||||
pub fn new(uri: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
uri: uri.into(),
|
||||
title: title.into(),
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: None,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contenu résolu depuis une URL externe
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante.
|
||||
/// La UrlSource retourne un stub container avec cet ID ; le content directory
|
||||
/// le route naturellement vers la source propriétaire lors du browse.
|
||||
SourceContainer {
|
||||
source_id: String,
|
||||
container_id: String,
|
||||
},
|
||||
|
||||
/// Liste ordonnée de tracks (RSS/podcast, M3U, PLS, XSPF…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
|
||||
/// Flux continu (radio, stream live)
|
||||
Stream {
|
||||
uri: String,
|
||||
title: String,
|
||||
mime_type: String,
|
||||
},
|
||||
|
||||
/// Track unique identifié directement
|
||||
Track(ResolvedTrack),
|
||||
}
|
||||
|
||||
/// Trait implémenté par chaque handler spécialisé (Qobuz, RadioFrance…)
|
||||
/// et par le handler générique de dernier recours.
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier. Défaut : 50.
|
||||
fn priority(&self) -> u8 {
|
||||
50
|
||||
}
|
||||
/// Filtre rapide sans I/O — simple test regex/contains sur l'URL.
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé).
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
|
||||
/// Registre ordonné de handlers. Les handlers sont triés par priorité décroissante.
|
||||
pub struct UrlResolver {
|
||||
handlers: Vec<Box<dyn UrlHandler>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UrlResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UrlResolver")
|
||||
.field("handlers", &format!("{} handlers", self.handlers.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlResolver {
|
||||
pub fn new() -> Self {
|
||||
Self { handlers: vec![] }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handler: Box<dyn UrlHandler>) {
|
||||
self.handlers.push(handler);
|
||||
self.handlers
|
||||
.sort_by(|a, b| b.priority().cmp(&a.priority()));
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
for handler in &self.handlers {
|
||||
if handler.can_handle(url) {
|
||||
return handler.resolve(url).await;
|
||||
}
|
||||
}
|
||||
Err(UrlResolverError::NotSupported(url.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UrlResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
573
pmourlsource/src/handlers/generic.rs
Normal file
573
pmourlsource/src/handlers/generic.rs
Normal file
@@ -0,0 +1,573 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{redirect, Client};
|
||||
|
||||
/// Handler générique de dernier recours — priorité 10.
|
||||
///
|
||||
/// Pipeline :
|
||||
/// 1. Garde-fou SSRF (rejette les IPs privées/locales)
|
||||
/// 2. GET avec suivi de redirections (max 5)
|
||||
/// 3. Détection par Content-Type :
|
||||
/// - audio/* → Stream direct
|
||||
/// - application/rss+xml, … → parse RSS/Atom → Playlist
|
||||
/// - .m3u / .pls / .xspf → parse playlist → Playlist
|
||||
/// 4. text/html → cherche :
|
||||
/// - <link type="application/rss+xml"> → fetch RSS → Playlist
|
||||
/// - <audio src="…">
|
||||
/// - og:audio / og:url audio
|
||||
pub struct GenericUrlHandler {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GenericUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("PMOMusic/1.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Rejette les URLs ciblant des réseaux privés/locaux (SSRF).
|
||||
fn is_safe_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
// Rejeter loopback, link-local, et RFC-1918
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return false;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return !ip.is_loopback() && !ip.is_unspecified() && is_public_ip(ip);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn fetch_and_resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
let final_url = resp.url().to_string();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
// Audio direct
|
||||
if content_type.starts_with("audio/") {
|
||||
let mime = content_type.split(';').next().unwrap_or("audio/mpeg").trim().to_string();
|
||||
let title = title_from_url(&final_url);
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: final_url,
|
||||
title,
|
||||
mime_type: mime,
|
||||
});
|
||||
}
|
||||
|
||||
// Playlist M3U
|
||||
if content_type.contains("mpegurl") || final_url.ends_with(".m3u") || final_url.ends_with(".m3u8") {
|
||||
return parse_m3u(&body, &final_url);
|
||||
}
|
||||
|
||||
// Playlist PLS
|
||||
if content_type.contains("scpls") || final_url.ends_with(".pls") {
|
||||
return parse_pls(&body, &final_url);
|
||||
}
|
||||
|
||||
// RSS / Atom / podcast
|
||||
if is_rss_content_type(&content_type) || final_url.ends_with(".xml") {
|
||||
return parse_rss(&body, &final_url);
|
||||
}
|
||||
|
||||
// HTML — chercher RSS link puis audio elements
|
||||
if content_type.starts_with("text/html") || content_type.is_empty() {
|
||||
return self.scrape_html(&body, &final_url).await;
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Content-Type non géré : {}",
|
||||
content_type
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scrape_html(&self, html: &str, base_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
// 1. Chercher un lien RSS (<link type="application/rss+xml" href="...">)
|
||||
if let Some(rss_url) = extract_rss_link(html, base_url) {
|
||||
tracing::debug!(rss_url = %rss_url, "HTML scraper found RSS feed");
|
||||
if Self::is_safe_url(&rss_url) {
|
||||
if let Ok(resp) = self.client.get(&rss_url).send().await {
|
||||
if let Ok(body) = resp.text().await {
|
||||
if let Ok(result) = parse_rss(&body, &rss_url) {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Chercher <audio src="...">
|
||||
if let Some(audio_url) = extract_audio_src(html, base_url) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found <audio>");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. og:audio
|
||||
if let Some(audio_url) = extract_og_audio(html) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found og:audio");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun contenu audio trouvé dans la page : {}",
|
||||
base_url
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GenericUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for GenericUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"GenericUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
10
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.starts_with("http://") || url.starts_with("https://")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
if !Self::is_safe_url(url) {
|
||||
return Err(UrlResolverError::SsrfBlocked);
|
||||
}
|
||||
self.fetch_and_resolve(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parseurs ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_date: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
// Parsing XML ligne par ligne — quick_xml non disponible ici,
|
||||
// on utilise une approche par extraction de patterns XML simples.
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_date = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
// Vérifier que c'est bien de l'audio
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<pubDate") {
|
||||
current_date = extract_xml_text(trimmed, "pubDate");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode audio dans le feed RSS : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_m3u(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut pending_title: Option<String> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line == "#EXTM3U" {
|
||||
continue;
|
||||
}
|
||||
if let Some(info) = line.strip_prefix("#EXTINF:") {
|
||||
// #EXTINF:<duration>,<title>
|
||||
let title = info.splitn(2, ',').nth(1).unwrap_or("").trim().to_string();
|
||||
if !title.is_empty() {
|
||||
pending_title = Some(title);
|
||||
}
|
||||
} else if !line.starts_with('#') {
|
||||
let title = pending_title.take().unwrap_or_else(|| title_from_url(line));
|
||||
items.push(ResolvedTrack::new(line, title));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("M3U vide".to_string()));
|
||||
}
|
||||
|
||||
if items.len() == 1 {
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: items.remove(0).uri,
|
||||
title: items.first().map(|t| t.title.clone()).unwrap_or_default(),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
fn parse_pls(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut uris: Vec<String> = Vec::new();
|
||||
let mut titles: Vec<String> = Vec::new();
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.to_lowercase().strip_prefix("file") {
|
||||
if let Some(url) = rest.splitn(2, '=').nth(1) {
|
||||
uris.push(url.trim().to_string());
|
||||
}
|
||||
} else if let Some(rest) = line.to_lowercase().strip_prefix("title") {
|
||||
if let Some(t) = rest.splitn(2, '=').nth(1) {
|
||||
titles.push(t.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uris.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("PLS vide".to_string()));
|
||||
}
|
||||
|
||||
let items: Vec<ResolvedTrack> = uris
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, uri)| {
|
||||
let title = titles.get(i).cloned().unwrap_or_else(|| title_from_url(&uri));
|
||||
ResolvedTrack::new(uri, title)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if items.len() == 1 {
|
||||
let item = items.into_iter().next().unwrap();
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: item.uri,
|
||||
title: item.title,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
// ── Utilitaires d'extraction HTML/XML ───────────────────────────────────────
|
||||
|
||||
fn extract_rss_link(html: &str, base_url: &str) -> Option<String> {
|
||||
// <link ... type="application/rss+xml" ... href="URL" ...>
|
||||
// ou <link ... href="URL" ... type="application/rss+xml" ...>
|
||||
let lower = html.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(start) = lower[pos..].find("<link") {
|
||||
let start = pos + start;
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
let tag_lower = &lower[start..end];
|
||||
|
||||
if tag_lower.contains("application/rss+xml") || tag_lower.contains("application/atom+xml") {
|
||||
if let Some(href) = extract_attr(tag, "href") {
|
||||
return Some(resolve_url(base_url, &href));
|
||||
}
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_audio_src(html: &str, base_url: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
if let Some(start) = lower.find("<audio") {
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
if let Some(src) = extract_attr(tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
// <source src="..."> inside <audio>
|
||||
let after = &html[end..];
|
||||
let lower_after = after.to_lowercase();
|
||||
if let Some(src_start) = lower_after.find("<source") {
|
||||
let src_end = after[src_start..].find('>').map(|e| src_start + e + 1).unwrap_or(after.len());
|
||||
let src_tag = &after[src_start..src_end];
|
||||
if let Some(src) = extract_attr(src_tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_og_audio(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:audio")
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extrait la valeur d'un attribut HTML/XML depuis une balise.
|
||||
/// Gère les guillemets simples, doubles et sans guillemets.
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le contenu texte d'un élément XML simple sur une seule ligne.
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
// Chercher <tag> ou <tag ...>
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
// Avancer jusqu'à la fermeture de la balise ouvrante
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Résout une URL relative par rapport à une base.
|
||||
fn resolve_url(base: &str, target: &str) -> String {
|
||||
if target.starts_with("http://") || target.starts_with("https://") {
|
||||
return target.to_string();
|
||||
}
|
||||
if target.starts_with("//") {
|
||||
let scheme = if base.starts_with("https") { "https" } else { "http" };
|
||||
return format!("{}:{}", scheme, target);
|
||||
}
|
||||
if let Ok(base_url) = url::Url::parse(base) {
|
||||
if let Ok(resolved) = base_url.join(target) {
|
||||
return resolved.to_string();
|
||||
}
|
||||
}
|
||||
target.to_string()
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
fn title_from_url(url: &str) -> String {
|
||||
url.rsplit('/')
|
||||
.find(|s| !s.is_empty())
|
||||
.unwrap_or(url)
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.replace(['-', '_'], " ")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Détermine si le Content-Type est RSS/Atom.
|
||||
fn is_rss_content_type(ct: &str) -> bool {
|
||||
ct.contains("rss") || ct.contains("atom") || ct.contains("xml")
|
||||
}
|
||||
|
||||
/// Convertit une durée iTunes ("HH:MM:SS" ou "MM:SS" ou secondes) en format UPnP ("H:MM:SS.000").
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
// Secondes brutes
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
return format!("0:{}.000", d);
|
||||
}
|
||||
3 => {
|
||||
return format!("{}.000", d);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Vérifie qu'une IP est publique (non privée, non loopback, non link-local).
|
||||
fn is_public_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
!v4.is_private()
|
||||
&& !v4.is_loopback()
|
||||
&& !v4.is_link_local()
|
||||
&& !v4.is_broadcast()
|
||||
&& !v4.is_documentation()
|
||||
&& !v4.is_unspecified()
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
!v6.is_loopback() && !v6.is_unspecified() && !is_v6_link_local(v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_v6_link_local(ip: std::net::Ipv6Addr) -> bool {
|
||||
// fe80::/10
|
||||
ip.segments()[0] & 0xffc0 == 0xfe80
|
||||
}
|
||||
3
pmourlsource/src/handlers/mod.rs
Normal file
3
pmourlsource/src/handlers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod generic;
|
||||
pub mod qobuz;
|
||||
pub mod radiofrance;
|
||||
138
pmourlsource/src/handlers/qobuz.rs
Normal file
138
pmourlsource/src/handlers/qobuz.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::handler::{ResolvedContent, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Résout les URLs de partage Qobuz vers des container_ids natifs.
|
||||
///
|
||||
/// Supporte open.qobuz.com et play.qobuz.com.
|
||||
/// Les IDs peuvent être alphanumériques pour tous les types (album, track, playlist, artist).
|
||||
///
|
||||
/// Exemples :
|
||||
/// https://open.qobuz.com/album/l46fxnqnxp5vs → qobuz:album:l46fxnqnxp5vs
|
||||
/// https://open.qobuz.com/track/48471123 → qobuz:track:48471123
|
||||
/// https://open.qobuz.com/playlist/63246908 → qobuz:playlist:63246908
|
||||
/// https://open.qobuz.com/artist/125709 → qobuz:artist:125709
|
||||
pub struct QobuzUrlHandler;
|
||||
|
||||
impl QobuzUrlHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn parse(&self, url: &str) -> Option<(String, String)> {
|
||||
// Localiser "qobuz.com/" dans l'URL
|
||||
let after_domain = url.find("qobuz.com/").map(|i| &url[i + "qobuz.com".len()..])?;
|
||||
|
||||
// after_domain commence par "/"
|
||||
let path = after_domain.trim_start_matches('/');
|
||||
let mut parts = path.splitn(3, '/');
|
||||
|
||||
let type_ = parts.next().unwrap_or("");
|
||||
let id_raw = parts.next().unwrap_or("");
|
||||
// Supprimer les query params éventuels (#, ?)
|
||||
let id = id_raw.split('?').next().unwrap_or(id_raw);
|
||||
let id = id.split('#').next().unwrap_or(id);
|
||||
|
||||
match type_ {
|
||||
"album" | "track" | "playlist" | "artist" => {
|
||||
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
Some((type_.to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QobuzUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for QobuzUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"QobuzUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
90
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("qobuz.com/")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let (type_, id) = self
|
||||
.parse(url)
|
||||
.ok_or_else(|| UrlResolverError::NotSupported(url.to_string()))?;
|
||||
|
||||
let container_id = format!("qobuz:{}:{}", type_, id);
|
||||
|
||||
tracing::debug!(
|
||||
url = %url,
|
||||
container_id = %container_id,
|
||||
"QobuzUrlHandler resolved"
|
||||
);
|
||||
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz".to_string(),
|
||||
container_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_album_alphanumeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_track_numeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/track/48471123")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:track:48471123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_play_subdomain() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://play.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_type_rejected() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h.resolve("https://open.qobuz.com/label/123").await;
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_handle() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
assert!(h.can_handle("https://open.qobuz.com/album/abc"));
|
||||
assert!(!h.can_handle("https://www.spotify.com/album/abc"));
|
||||
}
|
||||
}
|
||||
384
pmourlsource/src/handlers/radiofrance.rs
Normal file
384
pmourlsource/src/handlers/radiofrance.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{redirect, Client};
|
||||
|
||||
/// Handler dédié aux URLs radiofrance.fr — priorité 80.
|
||||
///
|
||||
/// RadioFrance utilise SvelteKit (SSR). La clé `rssFeed:` est inline dans le JS
|
||||
/// de la page pour les pages podcast standard, mais vide pour les pages série.
|
||||
///
|
||||
/// Stratégie selon le type d'URL :
|
||||
///
|
||||
/// 1. **Page podcast** (`/podcasts/{slug}`, sans sous-chemin épisode)
|
||||
/// → chercher `rssFeed:"https://..."` → fetch + parse RSS
|
||||
///
|
||||
/// 2. **Page série** (`/podcasts/serie-{slug}`)
|
||||
/// → parser le JSON-LD `ItemList` → extraire le slug du podcast sous-jacent
|
||||
/// → fetch page podcast → trouver rssFeed
|
||||
///
|
||||
/// 3. **Page épisode** (`/podcasts/{podcast-slug}/{episode-slug}-{id}`)
|
||||
/// → extraire l'URL MP3 directe
|
||||
pub struct RadioFranceUrlHandler {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl RadioFranceUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn resolve_inner(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let html = self.fetch_html(url).await?;
|
||||
|
||||
// --- Cas 1 : page podcast standard → rssFeed non vide ---
|
||||
if let Some(rss_url) = extract_rss_feed_key(&html) {
|
||||
tracing::debug!(rss_url = %rss_url, "RadioFrance: rssFeed trouvé directement");
|
||||
return self.fetch_rss(&rss_url).await;
|
||||
}
|
||||
|
||||
// --- Cas 2 : page série → JSON-LD ItemList → podcast sous-jacent ---
|
||||
if let Some(podcast_url) = derive_podcast_url_from_series(&html, url) {
|
||||
tracing::debug!(podcast_url = %podcast_url, "RadioFrance: série → page podcast");
|
||||
let podcast_html = self.fetch_html(&podcast_url).await?;
|
||||
if let Some(rss_url) = extract_rss_feed_key(&podcast_html) {
|
||||
tracing::debug!(rss_url = %rss_url, "RadioFrance: rssFeed via série");
|
||||
return self.fetch_rss(&rss_url).await;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cas 3 : page épisode → MP3 direct ---
|
||||
if let Some(mp3_url) = extract_mp3_url(&html) {
|
||||
tracing::debug!(mp3_url = %mp3_url, "RadioFrance: MP3 direct trouvé");
|
||||
let title = extract_og_title(&html)
|
||||
.or_else(|| extract_title_tag(&html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(&html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun podcast/épisode trouvé sur la page RadioFrance : {}",
|
||||
url
|
||||
)))
|
||||
}
|
||||
|
||||
async fn fetch_html(&self, url: &str) -> Result<String, UrlResolverError> {
|
||||
self.client
|
||||
.get(url)
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "fr-FR,fr;q=0.9")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_rss(&self, rss_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let body = self
|
||||
.client
|
||||
.get(rss_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(format!("RSS fetch : {}", e)))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
parse_rss(&body, rss_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RadioFranceUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client for RadioFranceUrlHandler")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for RadioFranceUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"RadioFranceUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("radiofrance.fr")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
self.resolve_inner(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extraction SvelteKit ─────────────────────────────────────────────────────
|
||||
|
||||
/// Cherche `rssFeed:"https://..."` dans le JS SvelteKit inline.
|
||||
/// Retourne None si le champ est absent ou vide.
|
||||
fn extract_rss_feed_key(html: &str) -> Option<String> {
|
||||
let needle = "rssFeed:\"https://";
|
||||
let pos = html.find(needle)?;
|
||||
let start = pos + "rssFeed:\"".len();
|
||||
let end = html[start..].find('"')? + start;
|
||||
let url = html[start..end].replace("\\/", "/");
|
||||
if url.is_empty() || !url.starts_with("http") {
|
||||
None
|
||||
} else {
|
||||
Some(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pour une page série, extrait les URLs d'épisodes du JSON-LD `ItemList`,
|
||||
/// déduit le slug du podcast sous-jacent et construit l'URL de sa page.
|
||||
///
|
||||
/// Exemple :
|
||||
/// épisode : `https://www.radiofrance.fr/franceculture/podcasts/les-contes-des-mille-et-une-sciences/kasparov-7422833`
|
||||
/// → page podcast : `https://www.radiofrance.fr/franceculture/podcasts/les-contes-des-mille-et-une-sciences`
|
||||
fn derive_podcast_url_from_series(html: &str, series_url: &str) -> Option<String> {
|
||||
// Extraire la première URL d'épisode depuis le JSON-LD ItemList
|
||||
let item_marker = "\"@type\":\"ItemList\"";
|
||||
let list_pos = html.find(item_marker)?;
|
||||
let after_list = &html[list_pos..];
|
||||
|
||||
// Chercher "url":"https://www.radiofrance.fr/..."
|
||||
let url_needle = "\"url\":\"https://www.radiofrance.fr/";
|
||||
let pos = after_list.find(url_needle)? + url_needle.len() - "https://www.radiofrance.fr/".len();
|
||||
let from = list_pos + pos + "\"url\":\"".len();
|
||||
let end = html[from..].find('"')? + from;
|
||||
let episode_url = &html[from..end];
|
||||
|
||||
// L'URL épisode : https://www.radiofrance.fr/{station}/podcasts/{podcast-slug}/{episode-slug}-{id}
|
||||
// On veut : https://www.radiofrance.fr/{station}/podcasts/{podcast-slug}
|
||||
// Compter les segments du path (après le domaine)
|
||||
let _after_domain = episode_url.find("/")?..; // find the first /
|
||||
let path = &episode_url[episode_url.find("radiofrance.fr/")? + "radiofrance.fr".len()..];
|
||||
// path = /{station}/podcasts/{podcast-slug}/{episode-slug}
|
||||
let segments: Vec<&str> = path.trim_start_matches('/').splitn(5, '/').collect();
|
||||
// segments = [station, "podcasts", podcast-slug, episode-slug]
|
||||
if segments.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// Éviter de boucler sur la même URL série
|
||||
let podcast_slug = segments[2];
|
||||
if podcast_slug.starts_with("serie-") {
|
||||
return None;
|
||||
}
|
||||
let podcast_url = format!(
|
||||
"https://www.radiofrance.fr/{}/{}/{}",
|
||||
segments[0], segments[1], podcast_slug
|
||||
);
|
||||
// Ne pas retourner l'URL série elle-même
|
||||
if podcast_url == series_url.trim_end_matches('/') {
|
||||
return None;
|
||||
}
|
||||
Some(podcast_url)
|
||||
}
|
||||
|
||||
/// Extrait l'URL du premier fichier MP3 hébergé sur media.radiofrance-podcast.net.
|
||||
fn extract_mp3_url(html: &str) -> Option<String> {
|
||||
let needle = "https://media.radiofrance-podcast.net/";
|
||||
let pos = html.find(needle)?;
|
||||
let end = html[pos..].find(|c: char| c == '"' || c == '\'' || c.is_whitespace())? + pos;
|
||||
let url = html[pos..end].to_string();
|
||||
if url.ends_with(".mp3") || url.contains(".mp3?") || url.contains("ITEMA_") {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── RSS parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode dans le feed RSS RadioFrance : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Utilitaires HTML ─────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => return format!("0:{}.000", d),
|
||||
3 => return format!("{}.000", d),
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
9
pmourlsource/src/lib.rs
Normal file
9
pmourlsource/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod handler;
|
||||
pub mod handlers;
|
||||
pub mod source;
|
||||
|
||||
pub use handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolver, UrlResolverError};
|
||||
pub use handlers::generic::GenericUrlHandler;
|
||||
pub use handlers::qobuz::QobuzUrlHandler;
|
||||
pub use handlers::radiofrance::RadioFranceUrlHandler;
|
||||
pub use source::UrlSource;
|
||||
253
pmourlsource/src/source.rs
Normal file
253
pmourlsource/src/source.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver,
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
pub fn new(resolver: UrlResolver) -> Self {
|
||||
Self { resolver }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for UrlSource {
|
||||
fn name(&self) -> &str {
|
||||
"URL / Partage"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"url"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> SourceCapabilities {
|
||||
SourceCapabilities {
|
||||
supports_search: true,
|
||||
handles_url_input: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> pmosource::Result<Container> {
|
||||
Ok(Container {
|
||||
id: "url".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: "URL / Partage".to_string(),
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
||||
match object_id {
|
||||
"url" => Ok(BrowseResult::Containers(vec![])),
|
||||
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Résout une URL collée dans la barre de recherche.
|
||||
///
|
||||
/// Le `query.text` est l'URL brute saisie par l'utilisateur.
|
||||
/// Retourne un stub container dont l'ID correspond au container_id
|
||||
/// de la source cible (ex. `qobuz:album:l46fxnqnxp5vs`). Le content
|
||||
/// directory handler route le browse() ultérieur vers la bonne source.
|
||||
async fn search(&self, query: &SearchQuery) -> pmosource::Result<BrowseResult> {
|
||||
let url = query.text.trim();
|
||||
|
||||
if url.is_empty() {
|
||||
return Ok(BrowseResult::Containers(vec![]));
|
||||
}
|
||||
|
||||
match self.resolver.resolve(url).await {
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id: _,
|
||||
container_id,
|
||||
}) => {
|
||||
// Stub container : le content directory le route vers la source cible.
|
||||
let title = display_title_for_url(url);
|
||||
let container = Container {
|
||||
id: container_id,
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("0".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: album, items }) => {
|
||||
let album = album.or_else(|| Some(display_title_for_url(url)));
|
||||
let didl_items: Vec<Item> = items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| resolved_track_to_item(t, i, album.as_deref()))
|
||||
.collect();
|
||||
Ok(BrowseResult::Items(didl_items))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Track(t)) => {
|
||||
let item = resolved_track_to_item(t, 0, None);
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Stream { uri, title, mime_type }) => {
|
||||
let item = stream_to_item(uri, title, mime_type);
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(_)) => {
|
||||
// Texte libre (pas une URL) — les autres sources traitent normalement.
|
||||
Ok(BrowseResult::Containers(vec![]))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(url = %url, error = %e, "UrlSource: résolution échouée");
|
||||
Err(MusicSourceError::BrowseError(format!(
|
||||
"Résolution URL échouée : {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
|
||||
fn supports_fifo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn last_change(&self) -> Option<SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un `ResolvedTrack` en `pmodidl::Item` jouable.
|
||||
fn resolved_track_to_item(t: ResolvedTrack, index: usize, album: Option<&str>) -> Item {
|
||||
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
||||
Item {
|
||||
id: format!("url:item:{}", index),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: t.title,
|
||||
creator: t.artist.clone(),
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: t.artist,
|
||||
album: t.album.or_else(|| album.map(|s| s.to_string())),
|
||||
genre: None,
|
||||
album_art: t.album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: Some(format!("{}", index + 1)),
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: t.duration,
|
||||
url: t.uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un flux continu en `pmodidl::Item`.
|
||||
fn stream_to_item(uri: String, title: String, mime_type: String) -> Item {
|
||||
let protocol_info = format!("http-get:*:{}:*", mime_type);
|
||||
Item {
|
||||
id: "url:item:0".to_string(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title,
|
||||
creator: None,
|
||||
class: "object.item.audioItem.audioBroadcast".to_string(),
|
||||
artist: None,
|
||||
album: None,
|
||||
genre: None,
|
||||
album_art: None,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: None,
|
||||
url: uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
/// Ex: "https://open.qobuz.com/album/abc" → "Album (open.qobuz.com)"
|
||||
fn display_title_for_url(url: &str) -> String {
|
||||
// Extraire l'hôte
|
||||
let host = url
|
||||
.find("://")
|
||||
.and_then(|i| {
|
||||
let after = &url[i + 3..];
|
||||
let end = after.find('/').unwrap_or(after.len());
|
||||
Some(&after[..end])
|
||||
})
|
||||
.unwrap_or("");
|
||||
|
||||
// Extraire le premier segment du path
|
||||
let type_label = if url.contains("/album/") {
|
||||
"Album"
|
||||
} else if url.contains("/track/") {
|
||||
"Titre"
|
||||
} else if url.contains("/playlist/") {
|
||||
"Playlist"
|
||||
} else if url.contains("/artist/") {
|
||||
"Artiste"
|
||||
} else {
|
||||
"Contenu"
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
type_label.to_string()
|
||||
} else {
|
||||
format!("{} ({})", type_label, host)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user