push-zwznsplyvnyp #103
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,11 +273,32 @@ impl ContentHandler {
|
||||
}
|
||||
}
|
||||
BrowseResult::Items(items) => {
|
||||
if let Some(item) = items.first() {
|
||||
let didl = to_didl_lite(&[], &[item.clone()])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
// 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() {
|
||||
@@ -615,16 +636,26 @@ 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 {
|
||||
if let Ok(result) = source.search(&query).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(items);
|
||||
}
|
||||
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),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(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