Compare commits
20 Commits
56a9f15cc1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec56e479ba | |||
| fc49c79b34 | |||
| 884b092af8 | |||
| 472211012b | |||
| ce9c779bb2 | |||
| 070ca8f3e0 | |||
| 01f49670e9 | |||
| ed9b8046e4 | |||
| f5d9e2590e | |||
| ad45e40900 | |||
| cc27e4efca | |||
| b7285e14c4 | |||
| da4d9008ad | |||
| f757e10734 | |||
| 009545de40 | |||
| bfa6231b1d | |||
| 87bce3edfa | |||
| d49bb604ca | |||
| 429bc0d274 | |||
| d810e99a5b |
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "pmomusic"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- rust
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
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.
|
||||
129
Cargo.lock
generated
129
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.52"
|
||||
version = "0.3.62"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -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,21 @@ dependencies = [
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"pmodidl",
|
||||
"pmosource",
|
||||
"reqwest",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.2"
|
||||
@@ -4529,6 +4585,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 +4859,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 +4882,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4777,6 +4891,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
@@ -4786,6 +4901,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4958,6 +5074,7 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6641,6 +6758,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]
|
||||
|
||||
@@ -57,6 +57,7 @@ COPY pmoplaylist/ ./pmoplaylist/
|
||||
COPY pmoflac/ ./pmoflac/
|
||||
COPY pmometadata/ ./pmometadata/
|
||||
COPY pmocontrol/ ./pmocontrol/
|
||||
COPY pmourlsource/ ./pmourlsource/
|
||||
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||
COPY pmoapp/ ./pmoapp/
|
||||
|
||||
|
||||
BIN
PMOMusic-A-192.png
Normal file
BIN
PMOMusic-A-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
PMOMusic-A-512.png
Normal file
BIN
PMOMusic-A-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.52"
|
||||
version = "0.3.62"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
|
||||
@@ -15,14 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// #[cfg(tokio_unstable)]
|
||||
// console_subscriber::init();
|
||||
|
||||
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
let server = Server::create_upnp_server().await?;
|
||||
|
||||
// Initialiser le système de gestion des sources musicales avec API REST
|
||||
info!("📡 Initializing music sources management system...");
|
||||
@@ -60,6 +53,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());
|
||||
@@ -85,6 +84,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
|
||||
let local_server_id = server_instance.udn().to_string();
|
||||
|
||||
// Exposer les informations de base de l'instance locale
|
||||
{
|
||||
let local_server_id_clone = local_server_id.clone();
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_route("/info", move || {
|
||||
let id = local_server_id_clone.clone();
|
||||
async move { serde_json::json!({"version": "1.0.0", "local_server_id": id}) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/app/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/app/icons/icon-192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>webapp</title>
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PMOMusic" />
|
||||
<title>PMOMusic</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
4858
pmoapp/webapp/package-lock.json
generated
4858
pmoapp/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -10,7 +10,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import NotificationToast from '@/components/NotificationToast.vue'
|
||||
import { useShareTarget } from '@/composables/useShareTarget'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUIStore()
|
||||
const { shareError } = useShareTarget()
|
||||
|
||||
watch(shareError, (err) => {
|
||||
if (err) ui.notifyError(err)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -66,8 +66,20 @@ async function handleSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
async function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
if (!currentServer.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, "0");
|
||||
currentContainerId.value = "0";
|
||||
setPath([{ id: "0", title: currentServer.value.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur navigation racine après clear search:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
@@ -175,6 +187,7 @@ watch(
|
||||
clearPath();
|
||||
closeMenu();
|
||||
imageStates.clear();
|
||||
searchInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -225,6 +238,7 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server;
|
||||
searchInput.value = '';
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -244,6 +258,7 @@ function goBack() {
|
||||
currentServer.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
|
||||
@@ -579,6 +579,8 @@ export function useRenderers() {
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
// Selection
|
||||
selectedRendererId,
|
||||
// Playlist binding
|
||||
attachPlaylist,
|
||||
detachPlaylist,
|
||||
|
||||
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { searchSource } from '@/services/pmosource'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
|
||||
export interface ShareTargetResult {
|
||||
url: string
|
||||
title: string | null
|
||||
containerId: string
|
||||
}
|
||||
|
||||
const pendingShare = ref<ShareTargetResult | null>(null)
|
||||
const shareError = ref<string | null>(null)
|
||||
|
||||
let localServerId: string | null = null
|
||||
|
||||
async function fetchLocalServerId(): Promise<string | null> {
|
||||
if (localServerId) return localServerId
|
||||
try {
|
||||
const resp = await fetch('/api/info')
|
||||
if (!resp.ok) return null
|
||||
const data = await resp.json()
|
||||
localServerId = data.local_server_id ?? null
|
||||
return localServerId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useShareTarget() {
|
||||
const { selectedRendererId, attachAndPlayPlaylist } = useRenderers()
|
||||
|
||||
async function handleShareIfPresent() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const sharedUrl = params.get('share_url') ?? params.get('share_text') ?? null
|
||||
const sharedTitle = params.get('share_title')
|
||||
|
||||
if (!sharedUrl) return
|
||||
|
||||
const clean = new URL(window.location.href)
|
||||
clean.searchParams.delete('share_url')
|
||||
clean.searchParams.delete('share_title')
|
||||
clean.searchParams.delete('share_text')
|
||||
window.history.replaceState({}, '', clean.toString())
|
||||
|
||||
try {
|
||||
shareError.value = null
|
||||
const result = await searchSource('url', sharedUrl)
|
||||
if (result.total === 0) {
|
||||
shareError.value = `Aucun contenu trouvé pour : ${sharedUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
const container = result.containers[0] ?? null
|
||||
const containerId = container?.id ?? result.items[0]?.id
|
||||
|
||||
if (!containerId) {
|
||||
shareError.value = 'Contenu résolu mais sans identifiant jouable'
|
||||
return
|
||||
}
|
||||
|
||||
const serverId = await fetchLocalServerId()
|
||||
const rendererId = selectedRendererId.value
|
||||
|
||||
if (!serverId || !rendererId) {
|
||||
// Pas de renderer sélectionné ou serveur inconnu : stocker pour affichage manuel
|
||||
pendingShare.value = { url: sharedUrl, title: sharedTitle, containerId }
|
||||
return
|
||||
}
|
||||
|
||||
await attachAndPlayPlaylist(rendererId, serverId, containerId)
|
||||
} catch (e) {
|
||||
shareError.value = e instanceof Error ? e.message : 'Erreur lors de la résolution'
|
||||
}
|
||||
}
|
||||
|
||||
function clearShare() {
|
||||
pendingShare.value = null
|
||||
shareError.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleShareIfPresent()
|
||||
})
|
||||
|
||||
return {
|
||||
pendingShare,
|
||||
shareError,
|
||||
clearShare,
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,18 @@ export function getSourceImageUrl(sourceId: string): string {
|
||||
return `${API_BASE}/${sourceId}/image`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans une source musicale (URL, texte libre…)
|
||||
*/
|
||||
export async function searchSource(sourceId: string, query: string): Promise<BrowseResponse> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/search?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les capacités d'une source
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
base: '/app/',
|
||||
manifest: {
|
||||
name: 'PMOMusic',
|
||||
short_name: 'PMOMusic',
|
||||
description: 'Contrôleur UPnP/DLNA pour votre musique',
|
||||
start_url: '/app/',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
theme_color: '#111827',
|
||||
background_color: '#111827',
|
||||
icons: [
|
||||
{
|
||||
src: '/app/icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/app/icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: '/app/',
|
||||
method: 'GET',
|
||||
params: {
|
||||
url: 'share_url',
|
||||
title: 'share_title',
|
||||
text: 'share_text',
|
||||
},
|
||||
},
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/app/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^\/audio\//,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use pmoaudiocache::{AudioCacheExt, get_audio_cache, register_audio_cache};
|
||||
use pmocovers::{CoverCacheExt, get_cover_cache, register_cover_cache};
|
||||
use pmoparadise::{
|
||||
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||
channels::{ALL_CHANNELS, ChannelDescriptor},
|
||||
channels::{ChannelDescriptor, channels},
|
||||
stream_channel::register_global_channel_manager,
|
||||
};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
@@ -47,7 +47,7 @@ pub trait ParadiseStreamingExt {
|
||||
///
|
||||
/// # Routes créées
|
||||
///
|
||||
/// Pour chaque canal (main, mellow, rock, eclectic) :
|
||||
/// Pour chaque canal connu du registre (main, mellow, rock, eclectic, beyond, ...) :
|
||||
/// - `/radioparadise/stream/{slug}/flac` - Stream FLAC live
|
||||
/// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live
|
||||
/// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC
|
||||
@@ -157,10 +157,10 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
||||
manager: manager.clone(),
|
||||
});
|
||||
|
||||
// Ajouter les routes pour chaque canal
|
||||
// Ajouter les routes pour chaque canal (registre rafraîchi par le manager)
|
||||
info!("🌐 Registering streaming routes...");
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let slug = descriptor.slug;
|
||||
for descriptor in channels().iter() {
|
||||
let slug = descriptor.slug.as_str();
|
||||
let channel_id = descriptor.id;
|
||||
|
||||
// Route FLAC live
|
||||
@@ -243,7 +243,7 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
||||
|
||||
async fn stream_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_flac();
|
||||
@@ -259,7 +259,7 @@ async fn stream_flac(
|
||||
|
||||
async fn stream_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_ogg();
|
||||
@@ -275,7 +275,7 @@ async fn stream_ogg(
|
||||
|
||||
async fn get_metadata(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let metadata = channel.metadata().await;
|
||||
@@ -284,7 +284,7 @@ async fn get_metadata(
|
||||
|
||||
async fn stream_history_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -307,7 +307,7 @@ async fn stream_history_flac(
|
||||
|
||||
async fn stream_history_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -347,10 +347,8 @@ fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
|
||||
});
|
||||
}
|
||||
|
||||
fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> {
|
||||
fn channel_from_live_playlist(playlist_id: &str) -> Option<ChannelDescriptor> {
|
||||
const PREFIX: &str = "radio-paradise-live-";
|
||||
let slug = playlist_id.strip_prefix(PREFIX)?;
|
||||
ALL_CHANNELS
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.slug == slug)
|
||||
pmoparadise::channels::channel_by_slug(slug)
|
||||
}
|
||||
|
||||
@@ -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,34 @@ 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 base_url = self.base_url().to_string();
|
||||
let source = Arc::new(UrlSource::new(resolver, base_url));
|
||||
self.register_music_source(source).await;
|
||||
|
||||
tracing::info!("✅ URL source registered successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) if id <= 3 => id,
|
||||
_ => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -26,7 +26,7 @@ use pmoaudiocache::{
|
||||
register_audio_cache as register_global_audio_cache,
|
||||
};
|
||||
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache};
|
||||
use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder};
|
||||
use pmoparadise::{channels::channels, ParadiseChannelManager, ParadiseHistoryBuilder};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
use pmoserver::{init_logging, ServerBuilder};
|
||||
use tokio_util::io::ReaderStream;
|
||||
@@ -80,8 +80,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build();
|
||||
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let slug = descriptor.slug;
|
||||
for descriptor in channels().iter() {
|
||||
let slug = descriptor.slug.as_str();
|
||||
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
|
||||
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
|
||||
let icy_path = format!("/radioparadise/stream/{}/icy", slug);
|
||||
@@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
info!("========================================");
|
||||
info!("Radio Paradise streaming server running on http://localhost:8080");
|
||||
info!("Available channels:");
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
for descriptor in channels().iter() {
|
||||
info!(
|
||||
" {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic/<client_id>/(flac|ogg))",
|
||||
descriptor.display_name, descriptor.slug
|
||||
@@ -177,7 +177,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
async fn stream_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_flac();
|
||||
@@ -193,7 +193,7 @@ async fn stream_flac(
|
||||
|
||||
async fn stream_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_ogg();
|
||||
@@ -209,7 +209,7 @@ async fn stream_ogg(
|
||||
|
||||
async fn stream_icy(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_icy();
|
||||
@@ -226,7 +226,7 @@ async fn stream_icy(
|
||||
|
||||
async fn get_metadata(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let metadata = channel.metadata().await;
|
||||
@@ -235,7 +235,7 @@ async fn get_metadata(
|
||||
|
||||
async fn stream_history_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -258,7 +258,7 @@ async fn stream_history_flac(
|
||||
|
||||
async fn stream_history_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
@@ -25,7 +25,7 @@ use pmocovers::{
|
||||
new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
|
||||
};
|
||||
use pmoparadise::{
|
||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||
channels::{channels, resolve_channel, ChannelDescriptor},
|
||||
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
||||
};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
@@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let channel = Arc::new(
|
||||
ParadiseStreamChannel::new(
|
||||
descriptor,
|
||||
descriptor.clone(),
|
||||
channel_config,
|
||||
Some(cover_cache.clone()),
|
||||
Some(history_opts),
|
||||
@@ -227,15 +227,8 @@ async fn get_cover(
|
||||
|
||||
fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> {
|
||||
if let Some(token) = arg {
|
||||
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) {
|
||||
return Ok(*desc);
|
||||
}
|
||||
if let Ok(id) = token.parse::<u8>() {
|
||||
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) {
|
||||
return Ok(*desc);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Unknown channel identifier: {token}");
|
||||
return resolve_channel(&token)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown channel identifier: {token}"));
|
||||
}
|
||||
Ok(ALL_CHANNELS[0])
|
||||
Ok(channels()[0].clone())
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) if id <= 3 => id,
|
||||
_ => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -1,103 +1,187 @@
|
||||
//! Radio Paradise channel definitions
|
||||
//!
|
||||
//! This module defines the available Radio Paradise channels and their metadata.
|
||||
//! This module maintains a dynamic registry of the available Radio Paradise
|
||||
//! channels. The registry is initialized with a built-in default list and can
|
||||
//! be refreshed at runtime from the `list_chan` API endpoint via
|
||||
//! [`refresh_channels`], so newly added channels (Beyond, Serenity, KFAT, ...)
|
||||
//! are picked up without a code change.
|
||||
//!
|
||||
//! Channel IDs are not contiguous (0, 1, 2, 3, 5, 42, 945...): never iterate
|
||||
//! over an ID range, always go through [`channels`].
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Logical identifier for a Radio Paradise channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ParadiseChannelKind {
|
||||
Main,
|
||||
Mellow,
|
||||
Rock,
|
||||
Eclectic,
|
||||
}
|
||||
|
||||
impl ParadiseChannelKind {
|
||||
pub const fn id(self) -> u8 {
|
||||
match self {
|
||||
Self::Main => 0,
|
||||
Self::Mellow => 1,
|
||||
Self::Rock => 2,
|
||||
Self::Eclectic => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "main",
|
||||
Self::Mellow => "mellow",
|
||||
Self::Rock => "rock",
|
||||
Self::Eclectic => "eclectic",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Main Mix",
|
||||
Self::Mellow => "Mellow Mix",
|
||||
Self::Rock => "Rock Mix",
|
||||
Self::Eclectic => "Eclectic Mix",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
||||
Self::Mellow => "Mellower, less aggressive music",
|
||||
Self::Rock => "Heavier, more guitar-driven music",
|
||||
Self::Eclectic => "Curated worldwide selection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ParadiseChannelKind {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"main" | "0" => Ok(Self::Main),
|
||||
"mellow" | "1" => Ok(Self::Mellow),
|
||||
"rock" | "2" => Ok(Self::Rock),
|
||||
"eclectic" | "3" => Ok(Self::Eclectic),
|
||||
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Metadata descriptor for a channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChannelDescriptor {
|
||||
pub kind: ParadiseChannelKind,
|
||||
pub id: u8,
|
||||
pub slug: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
/// Channel ID as used by the RP API (`chan` parameter). Not contiguous.
|
||||
pub id: u16,
|
||||
/// Stable identifier used in playlist IDs, config paths, routes and UPnP
|
||||
/// object IDs. Legacy slugs are preserved for channels 0-3 so existing
|
||||
/// persisted playlists and configuration keep working.
|
||||
pub slug: String,
|
||||
/// Human-readable channel name.
|
||||
pub display_name: String,
|
||||
/// Short description of the channel.
|
||||
pub description: String,
|
||||
/// Cover image URL provided by the API, if any.
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl ChannelDescriptor {
|
||||
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
||||
fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self {
|
||||
Self {
|
||||
id: kind.id(),
|
||||
slug: kind.slug(),
|
||||
display_name: kind.display_name(),
|
||||
description: kind.description(),
|
||||
kind,
|
||||
id,
|
||||
slug: slug.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
description: description.to_string(),
|
||||
// Stable URL pattern observed on img.radioparadise.com; the value
|
||||
// is overwritten by the API-provided one after refresh_channels().
|
||||
image: Some(format!(
|
||||
"https://img.radioparadise.com/channels/0/{}/cover_512x512/0.jpg",
|
||||
id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All available Radio Paradise channels
|
||||
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
||||
];
|
||||
/// Legacy slugs for the historical channels (0-3).
|
||||
///
|
||||
/// Playlist IDs, config paths and UPnP object IDs are derived from the slug,
|
||||
/// so the original slugs must be preserved even though the API now reports
|
||||
/// different `stream_name`s ("main-mix", "global", ...).
|
||||
fn legacy_slug(id: u16) -> Option<&'static str> {
|
||||
match id {
|
||||
0 => Some("main"),
|
||||
1 => Some("mellow"),
|
||||
2 => Some("rock"),
|
||||
3 => Some("eclectic"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximum valid channel ID
|
||||
pub const fn max_channel_id() -> u8 {
|
||||
(ALL_CHANNELS.len() - 1) as u8
|
||||
/// Built-in channel list, used as fallback when the API cannot be reached.
|
||||
///
|
||||
/// Snapshot of the `list_chan` endpoint (2026-07), with legacy slugs for 0-3.
|
||||
pub fn default_channels() -> Vec<ChannelDescriptor> {
|
||||
vec![
|
||||
ChannelDescriptor::new_static(
|
||||
0,
|
||||
"main",
|
||||
"The Main Mix",
|
||||
"Eclectic mix of rock, world, electronica, and more",
|
||||
),
|
||||
ChannelDescriptor::new_static(1, "mellow", "Mellow Mix", "Mellower, less aggressive music"),
|
||||
ChannelDescriptor::new_static(2, "rock", "RockIt!", "Heavier, more guitar-driven music"),
|
||||
ChannelDescriptor::new_static(3, "eclectic", "The Globe", "Curated worldwide selection"),
|
||||
ChannelDescriptor::new_static(5, "beyond", "Beyond...", "Adventurous, exploratory music"),
|
||||
ChannelDescriptor::new_static(
|
||||
42,
|
||||
"serenity",
|
||||
"Serenity",
|
||||
"Generative ambient soundscapes",
|
||||
),
|
||||
ChannelDescriptor::new_static(945, "kfat", "KFAT", "Americana, blues and country"),
|
||||
]
|
||||
}
|
||||
|
||||
static CHANNEL_REGISTRY: Lazy<RwLock<Arc<Vec<ChannelDescriptor>>>> =
|
||||
Lazy::new(|| RwLock::new(Arc::new(default_channels())));
|
||||
|
||||
/// Snapshot of the currently known channels.
|
||||
///
|
||||
/// Returns the built-in defaults until [`refresh_channels`] has succeeded.
|
||||
pub fn channels() -> Arc<Vec<ChannelDescriptor>> {
|
||||
CHANNEL_REGISTRY
|
||||
.read()
|
||||
.expect("channel registry poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Look up a channel by its API ID.
|
||||
pub fn channel_by_id(id: u16) -> Option<ChannelDescriptor> {
|
||||
channels().iter().find(|ch| ch.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Look up a channel by its slug.
|
||||
pub fn channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
|
||||
channels().iter().find(|ch| ch.slug == slug).cloned()
|
||||
}
|
||||
|
||||
/// Resolve a channel from a user-supplied string: slug or numeric ID.
|
||||
pub fn resolve_channel(s: &str) -> Option<ChannelDescriptor> {
|
||||
let s = s.trim();
|
||||
if let Ok(id) = s.parse::<u16>() {
|
||||
return channel_by_id(id);
|
||||
}
|
||||
channel_by_slug(&s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Raw channel entry as returned by the `list_chan` API endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ApiChannel {
|
||||
pub chan: String,
|
||||
pub title: String,
|
||||
pub stream_name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub channel_type: String,
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiChannel {
|
||||
/// Convert to a descriptor. Returns `None` for entries our block-based
|
||||
/// pipeline cannot play (non-"block" channels) or with an unparsable ID.
|
||||
pub(crate) fn into_descriptor(self) -> Option<ChannelDescriptor> {
|
||||
if self.channel_type != "block" {
|
||||
return None;
|
||||
}
|
||||
let id: u16 = self.chan.parse().ok()?;
|
||||
let slug = legacy_slug(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(self.stream_name);
|
||||
Some(ChannelDescriptor {
|
||||
id,
|
||||
slug,
|
||||
// The API provides no description; reuse the title.
|
||||
description: self.title.clone(),
|
||||
display_name: self.title,
|
||||
image: self.image,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the channel registry from the Radio Paradise API.
|
||||
///
|
||||
/// On success the registry is replaced with the fetched list and the new
|
||||
/// snapshot is returned. On failure the registry is left untouched (built-in
|
||||
/// defaults or previous successful fetch).
|
||||
pub async fn refresh_channels(
|
||||
client: &crate::client::RadioParadiseClient,
|
||||
) -> crate::error::Result<Arc<Vec<ChannelDescriptor>>> {
|
||||
let fetched = client.list_channels().await?;
|
||||
if fetched.is_empty() {
|
||||
return Err(crate::error::Error::other(
|
||||
"list_chan returned no playable channel",
|
||||
));
|
||||
}
|
||||
let snapshot = Arc::new(fetched);
|
||||
*CHANNEL_REGISTRY
|
||||
.write()
|
||||
.expect("channel registry poisoned") = snapshot.clone();
|
||||
tracing::info!(
|
||||
"Radio Paradise channel registry refreshed: {} channels ({})",
|
||||
snapshot.len(),
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|ch| ch.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Default maximum number of tracks to keep in history
|
||||
@@ -111,33 +195,63 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_channel_ids() {
|
||||
assert_eq!(ParadiseChannelKind::Main.id(), 0);
|
||||
assert_eq!(ParadiseChannelKind::Mellow.id(), 1);
|
||||
assert_eq!(ParadiseChannelKind::Rock.id(), 2);
|
||||
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3);
|
||||
fn test_default_channels_have_legacy_slugs() {
|
||||
let channels = default_channels();
|
||||
assert_eq!(channels[0].slug, "main");
|
||||
assert_eq!(channels[1].slug, "mellow");
|
||||
assert_eq!(channels[2].slug, "rock");
|
||||
assert_eq!(channels[3].slug, "eclectic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_channel_id() {
|
||||
assert_eq!(max_channel_id(), 3);
|
||||
fn test_default_channels_include_new_channels() {
|
||||
let channels = default_channels();
|
||||
assert!(channels.iter().any(|ch| ch.id == 5 && ch.slug == "beyond"));
|
||||
assert!(channels.iter().any(|ch| ch.id == 42 && ch.slug == "serenity"));
|
||||
assert!(channels.iter().any(|ch| ch.id == 945 && ch.slug == "kfat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_channels_length() {
|
||||
assert_eq!(ALL_CHANNELS.len(), 4);
|
||||
fn test_resolve_channel() {
|
||||
assert_eq!(resolve_channel("main").map(|ch| ch.id), Some(0));
|
||||
assert_eq!(resolve_channel("0").map(|ch| ch.id), Some(0));
|
||||
assert_eq!(resolve_channel("MELLOW").map(|ch| ch.id), Some(1));
|
||||
assert_eq!(resolve_channel("945").map(|ch| ch.slug), Some("kfat".to_string()));
|
||||
assert!(resolve_channel("invalid").is_none());
|
||||
// IDs are sparse: 4 is not a channel
|
||||
assert!(resolve_channel("4").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_from_str() {
|
||||
assert!(matches!(
|
||||
"main".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!(matches!(
|
||||
"0".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!("invalid".parse::<ParadiseChannelKind>().is_err());
|
||||
fn test_api_channel_conversion() {
|
||||
let api = ApiChannel {
|
||||
chan: "3".to_string(),
|
||||
title: "The Globe".to_string(),
|
||||
stream_name: "global".to_string(),
|
||||
channel_type: "block".to_string(),
|
||||
image: None,
|
||||
};
|
||||
let desc = api.into_descriptor().unwrap();
|
||||
// Legacy slug preserved for channel 3
|
||||
assert_eq!(desc.slug, "eclectic");
|
||||
assert_eq!(desc.display_name, "The Globe");
|
||||
|
||||
let api = ApiChannel {
|
||||
chan: "945".to_string(),
|
||||
title: "KFAT".to_string(),
|
||||
stream_name: "kfat".to_string(),
|
||||
channel_type: "block".to_string(),
|
||||
image: None,
|
||||
};
|
||||
assert_eq!(api.into_descriptor().unwrap().slug, "kfat");
|
||||
|
||||
let api = ApiChannel {
|
||||
chan: "7".to_string(),
|
||||
title: "Live Stream".to_string(),
|
||||
stream_name: "live".to_string(),
|
||||
channel_type: "live".to_string(),
|
||||
image: None,
|
||||
};
|
||||
assert!(api.into_descriptor().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours
|
||||
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
||||
|
||||
/// Default channel (0 = main mix)
|
||||
pub const DEFAULT_CHANNEL: u8 = 0;
|
||||
pub const DEFAULT_CHANNEL: u16 = 0;
|
||||
|
||||
/// Radio Paradise HTTP client
|
||||
///
|
||||
@@ -55,7 +55,7 @@ pub const DEFAULT_CHANNEL: u8 = 0;
|
||||
pub struct RadioParadiseClient {
|
||||
pub(crate) client: Client,
|
||||
api_base: String,
|
||||
channel: u8,
|
||||
channel: u16,
|
||||
pub(crate) request_timeout: Duration,
|
||||
pub(crate) block_timeout: Duration,
|
||||
next_block_url: Option<String>,
|
||||
@@ -92,7 +92,7 @@ impl RadioParadiseClient {
|
||||
}
|
||||
|
||||
/// Get the current channel (0 = main mix)
|
||||
pub fn channel(&self) -> u8 {
|
||||
pub fn channel(&self) -> u16 {
|
||||
self.channel
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ impl RadioParadiseClient {
|
||||
}
|
||||
|
||||
/// Clone the client with a different channel while preserving other settings.
|
||||
pub fn clone_with_channel(&self, channel: u8) -> Self {
|
||||
pub fn clone_with_channel(&self, channel: u16) -> Self {
|
||||
let mut cloned = self.clone();
|
||||
cloned.channel = channel;
|
||||
cloned.next_block_url = None;
|
||||
@@ -234,6 +234,36 @@ impl RadioParadiseClient {
|
||||
pub fn http_client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// List the channels currently advertised by the Radio Paradise API
|
||||
///
|
||||
/// Only block-based channels (playable by this crate) are returned.
|
||||
/// Use `channels::refresh_channels()` to update the global registry.
|
||||
pub async fn list_channels(&self) -> Result<Vec<crate::channels::ChannelDescriptor>> {
|
||||
let url = Url::parse(&format!("{}/list_chan", self.api_base))?;
|
||||
|
||||
debug!("Fetching channel list: {}", url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::other(format!(
|
||||
"API returned error status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let raw: Vec<crate::channels::ApiChannel> = response.json().await?;
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.filter_map(|ch| ch.into_descriptor())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for configuring a RadioParadiseClient
|
||||
@@ -241,7 +271,7 @@ impl RadioParadiseClient {
|
||||
pub struct ClientBuilder {
|
||||
client: Option<Client>,
|
||||
api_base: String,
|
||||
channel: u8,
|
||||
channel: u16,
|
||||
request_timeout: Duration,
|
||||
block_timeout: Duration,
|
||||
user_agent: String,
|
||||
@@ -280,8 +310,8 @@ impl ClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
|
||||
pub fn channel(mut self, channel: u8) -> Self {
|
||||
/// Set the channel (see `channels::channels()` for the available IDs)
|
||||
pub fn channel(mut self, channel: u16) -> Self {
|
||||
self.channel = channel;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
|
||||
use crate::{
|
||||
channels::{channel_by_id, resolve_channel},
|
||||
client::DEFAULT_CHANNEL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde_yaml::Value;
|
||||
@@ -94,11 +97,10 @@ pub trait RadioParadiseConfigExt {
|
||||
///
|
||||
/// # Channels disponibles
|
||||
///
|
||||
/// Peut être configuré comme chaîne de caractères ou nombre :
|
||||
/// - "main" ou 0 = Main Mix (eclectic, diverse mix)
|
||||
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music)
|
||||
/// - "rock" ou 2 = Rock Mix (classic & modern rock)
|
||||
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
|
||||
/// Peut être configuré comme chaîne de caractères (slug) ou nombre (ID).
|
||||
/// La liste des canaux est dynamique (voir `channels::channels()`) :
|
||||
/// par exemple "main"/0, "mellow"/1, "rock"/2, "eclectic"/3, "beyond"/5,
|
||||
/// "serenity"/42, "kfat"/945.
|
||||
///
|
||||
/// # Exemple de configuration YAML
|
||||
///
|
||||
@@ -114,13 +116,13 @@ pub trait RadioParadiseConfigExt {
|
||||
/// let channel = config.get_paradise_default_channel()?;
|
||||
/// let client = RadioParadiseClient::builder().channel(channel).build().await?;
|
||||
/// ```
|
||||
fn get_paradise_default_channel(&self) -> Result<u8>;
|
||||
fn get_paradise_default_channel(&self) -> Result<u16>;
|
||||
|
||||
/// Définit le channel par défaut
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `channel` - Le channel (0-3)
|
||||
/// * `channel` - L'ID du channel (doit exister dans le registre de canaux)
|
||||
///
|
||||
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
|
||||
/// dans le fichier de configuration.
|
||||
@@ -128,14 +130,10 @@ pub trait RadioParadiseConfigExt {
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoparadise::channels::ParadiseChannelKind;
|
||||
///
|
||||
/// // Use Mellow Mix by default
|
||||
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
|
||||
/// // Or simply:
|
||||
/// config.set_paradise_default_channel(1)?;
|
||||
/// ```
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
|
||||
fn set_paradise_default_channel(&self, channel: u16) -> Result<()>;
|
||||
}
|
||||
|
||||
impl RadioParadiseConfigExt for Config {
|
||||
@@ -157,13 +155,13 @@ impl RadioParadiseConfigExt for Config {
|
||||
)
|
||||
}
|
||||
|
||||
fn get_paradise_default_channel(&self) -> Result<u8> {
|
||||
fn get_paradise_default_channel(&self) -> Result<u16> {
|
||||
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
|
||||
Ok(Value::String(s)) => {
|
||||
// Try to parse as channel name (e.g., "main", "mellow", etc.)
|
||||
match s.parse::<ParadiseChannelKind>() {
|
||||
Ok(kind) => Ok(kind.id()),
|
||||
Err(_) => {
|
||||
// Slug ("main", "mellow", ...) ou ID numérique en chaîne
|
||||
match resolve_channel(&s) {
|
||||
Some(descriptor) => Ok(descriptor.id),
|
||||
None => {
|
||||
// Invalid channel name, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
@@ -171,19 +169,18 @@ impl RadioParadiseConfigExt for Config {
|
||||
}
|
||||
}
|
||||
Ok(Value::Number(n)) => {
|
||||
// Accept numeric channel ID (0-3)
|
||||
if let Some(ch) = n.as_u64() {
|
||||
if ch <= 3 {
|
||||
Ok(ch as u8)
|
||||
} else {
|
||||
// Accept numeric channel ID (must exist in the registry)
|
||||
match n
|
||||
.as_u64()
|
||||
.and_then(|ch| u16::try_from(ch).ok())
|
||||
.and_then(channel_by_id)
|
||||
{
|
||||
Some(descriptor) => Ok(descriptor.id),
|
||||
None => {
|
||||
// Invalid channel number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
} else {
|
||||
// Not a valid number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -197,19 +194,14 @@ impl RadioParadiseConfigExt for Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
|
||||
// Convert channel ID to user-friendly string name
|
||||
let channel_name = match channel {
|
||||
0 => "main",
|
||||
1 => "mellow",
|
||||
2 => "rock",
|
||||
3 => "eclectic",
|
||||
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
|
||||
};
|
||||
fn set_paradise_default_channel(&self, channel: u16) -> Result<()> {
|
||||
// Convert channel ID to user-friendly slug
|
||||
let descriptor = channel_by_id(channel)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid channel ID: {}", channel))?;
|
||||
|
||||
self.set_value(
|
||||
&["sources", "radio_paradise", "default_channel"],
|
||||
Value::String(channel_name.to_string()),
|
||||
Value::String(descriptor.slug),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
||||
//! à un serveur pmoserver.
|
||||
|
||||
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
|
||||
use crate::channels::{channel_by_id, channels, refresh_channels, ChannelDescriptor};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
@@ -26,7 +26,7 @@ pub struct RadioParadiseState {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct ParadiseQuery {
|
||||
channel: Option<u8>,
|
||||
channel: Option<u16>,
|
||||
}
|
||||
|
||||
impl RadioParadiseState {
|
||||
@@ -35,6 +35,15 @@ impl RadioParadiseState {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
|
||||
|
||||
// Mettre à jour le registre de canaux depuis l'API (fallback sur les
|
||||
// canaux par défaut en cas d'échec réseau)
|
||||
if let Err(e) = refresh_channels(&client).await {
|
||||
tracing::warn!(
|
||||
"Failed to refresh Radio Paradise channel list, using defaults: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client: Arc::new(RwLock::new(client)),
|
||||
})
|
||||
@@ -52,7 +61,7 @@ impl RadioParadiseState {
|
||||
let mut client = base_client;
|
||||
|
||||
if let Some(channel) = params.channel {
|
||||
if channel > max_channel_id() {
|
||||
if channel_by_id(channel).is_none() {
|
||||
tracing::warn!("Invalid Radio Paradise channel requested: {}", channel);
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -66,20 +75,26 @@ impl RadioParadiseState {
|
||||
/// Information sur un canal Radio Paradise
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChannelInfo {
|
||||
/// ID du canal (0-3)
|
||||
pub id: u8,
|
||||
/// ID du canal (attention : IDs non contigus, ex. 0, 1, 2, 3, 5, 42, 945)
|
||||
pub id: u16,
|
||||
/// Slug du canal ("main", "mellow", "beyond", ...)
|
||||
pub slug: String,
|
||||
/// Nom du canal
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Route locale de l'image du canal (servie par le cache covers)
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&ChannelDescriptor> for ChannelInfo {
|
||||
fn from(descriptor: &ChannelDescriptor) -> Self {
|
||||
Self {
|
||||
id: descriptor.id,
|
||||
name: descriptor.display_name.to_string(),
|
||||
description: descriptor.description.to_string(),
|
||||
slug: descriptor.slug.clone(),
|
||||
name: descriptor.display_name.clone(),
|
||||
description: descriptor.description.clone(),
|
||||
image: descriptor.image.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +266,7 @@ impl From<NowPlaying> for NowPlayingResponse {
|
||||
get,
|
||||
path = "/now-playing",
|
||||
params(
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Morceau en cours", body = NowPlayingResponse),
|
||||
@@ -277,7 +292,7 @@ async fn get_now_playing(
|
||||
get,
|
||||
path = "/block/current",
|
||||
params(
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Block actuel", body = BlockResponse),
|
||||
@@ -304,7 +319,7 @@ async fn get_current_block(
|
||||
path = "/block/{event_id}",
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Block demandé", body = BlockResponse),
|
||||
@@ -340,8 +355,27 @@ async fn get_block_by_id(
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
let channels: Vec<ChannelInfo> = ALL_CHANNELS.iter().map(Into::into).collect();
|
||||
Json(channels)
|
||||
let cover_cache = pmocovers::get_cover_cache();
|
||||
let mut list = Vec::new();
|
||||
for descriptor in channels().iter() {
|
||||
let mut info: ChannelInfo = descriptor.into();
|
||||
// Toutes les images transitent par le cache covers local : on expose
|
||||
// la route du cache, jamais l'URL externe img.radioparadise.com
|
||||
info.image = match (&descriptor.image, &cover_cache) {
|
||||
(Some(url), Some(cache)) => {
|
||||
match cache.add_from_url(url, Some("radioparadise-channels")).await {
|
||||
Ok(pk) => Some(pmocache::covers_route_for(&pk, None)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to cache channel image {}: {}", url, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
Json(list)
|
||||
}
|
||||
|
||||
/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block
|
||||
@@ -351,7 +385,7 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("index" = usize, Path, description = "Index du morceau (0-based)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Morceau demandé", body = SongInfo),
|
||||
@@ -404,7 +438,7 @@ async fn get_song_by_index(
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("song_index" = usize, Path, description = "Index du morceau (0-based)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse),
|
||||
@@ -456,7 +490,7 @@ async fn get_cover_url(
|
||||
path = "/stream-url/{event_id}",
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
|
||||
@@ -500,17 +534,15 @@ Cette API permet d'accéder aux métadonnées et flux de Radio Paradise.
|
||||
## Fonctionnalités
|
||||
|
||||
- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks
|
||||
- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic)
|
||||
- **Multi-canaux** : Support de tous les canaux Radio Paradise (liste dynamique)
|
||||
- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité
|
||||
- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille)
|
||||
- **Historique** : Accès aux blocks passés via event_id
|
||||
|
||||
## Canaux disponibles
|
||||
|
||||
- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more
|
||||
- **1: Mellow Mix** - Mellower, less aggressive music
|
||||
- **2: Rock Mix** - Heavier, more guitar-driven music
|
||||
- **3: Eclectic Mix** - Curated worldwide selection
|
||||
La liste des canaux est récupérée dynamiquement depuis l'API Radio Paradise
|
||||
(`GET /channels`). Attention : les IDs ne sont pas contigus (ex. 0, 1, 2, 3, 5, 42, 945).
|
||||
|
||||
## Format des données
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
|
||||
//!
|
||||
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
|
||||
//! exposing live streams and historical playlists for all 4 channels.
|
||||
//! exposing live streams and historical playlists for every channel known
|
||||
//! to the dynamic channel registry (see `crate::channels`).
|
||||
|
||||
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
||||
use crate::channels::{channel_by_slug, channels, ChannelDescriptor};
|
||||
use pmosource::pmodidl::{Container, Item, Resource};
|
||||
use pmosource::{
|
||||
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
||||
@@ -27,7 +28,7 @@ const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200);
|
||||
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
||||
///
|
||||
/// Provides access to:
|
||||
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
|
||||
/// - Live FLAC streams for every channel in the registry (Main, Mellow, Rock, Eclectic, Beyond, ...)
|
||||
/// - Historical playlists (FIFO) for each channel
|
||||
///
|
||||
/// # Object ID Schema
|
||||
@@ -116,12 +117,12 @@ impl RadioParadiseSource {
|
||||
use pmoplaylist::PlaylistManager;
|
||||
|
||||
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
|
||||
let ids: Vec<String> = ALL_CHANNELS
|
||||
let ids: Vec<String> = channels()
|
||||
.iter()
|
||||
.flat_map(|ch| {
|
||||
vec![
|
||||
Self::live_playlist_id(ch.slug),
|
||||
Self::history_playlist_id(ch.slug),
|
||||
Self::live_playlist_id(&ch.slug),
|
||||
Self::history_playlist_id(&ch.slug),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
@@ -151,20 +152,21 @@ impl RadioParadiseSource {
|
||||
tokio::spawn(async move {
|
||||
strong.bump_update_counter().await;
|
||||
// Notifier ContentDirectory des conteneurs concernés
|
||||
let known_channels = channels();
|
||||
let containers: Vec<String> = if pid.contains("history") {
|
||||
// history playlist -> container history
|
||||
ALL_CHANNELS
|
||||
known_channels
|
||||
.iter()
|
||||
.find(|ch| pid.ends_with(ch.slug))
|
||||
.find(|ch| pid.ends_with(&ch.slug))
|
||||
.map(|ch| {
|
||||
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// live playlist -> container liveplaylist
|
||||
ALL_CHANNELS
|
||||
known_channels
|
||||
.iter()
|
||||
.find(|ch| pid.ends_with(ch.slug))
|
||||
.find(|ch| pid.ends_with(&ch.slug))
|
||||
.map(|ch| {
|
||||
vec![format!(
|
||||
"radio-paradise:channel:{}:liveplaylist",
|
||||
@@ -192,6 +194,26 @@ impl RadioParadiseSource {
|
||||
format!("{}/api/sources/{}/image", self.base_url, self.id())
|
||||
}
|
||||
|
||||
/// Résout l'image d'un canal en URL locale servie par le cache covers.
|
||||
///
|
||||
/// Toutes les images transitent par pmocovers : aucune URL externe ne doit
|
||||
/// apparaître dans les métadonnées UPnP. En cas de cache indisponible ou
|
||||
/// d'échec de téléchargement, fallback sur l'image par défaut de la source.
|
||||
async fn channel_art_url(&self, descriptor: &ChannelDescriptor) -> String {
|
||||
if let (Some(url), Some(cache)) = (descriptor.image.as_ref(), pmocovers::get_cover_cache())
|
||||
{
|
||||
match cache.add_from_url(url, Some("radioparadise-channels")).await {
|
||||
Ok(pk) => {
|
||||
return format!("{}{}", self.base_url, pmocache::covers_route_for(&pk, None));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to cache channel image {}: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.default_cover_url()
|
||||
}
|
||||
|
||||
/// Fetch current metadata from the live stream
|
||||
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
|
||||
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug);
|
||||
@@ -212,11 +234,27 @@ impl RadioParadiseSource {
|
||||
// Préférer l'URL de cache si cover_pk est fourni par le pipeline
|
||||
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
|
||||
// Stocker la route relative (le handler REST appliquera base_url.url_for())
|
||||
let cover_url = cover_pk
|
||||
let mut cover_url = cover_pk
|
||||
.as_ref()
|
||||
.map(|pk| pmocache::covers_route_for(pk, None))
|
||||
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
||||
.or_else(|| Some(self.default_cover_url()));
|
||||
.map(|pk| pmocache::covers_route_for(pk, None));
|
||||
if cover_url.is_none() {
|
||||
// Pas de pk : faire transiter l'URL externe par le cache covers
|
||||
if let (Some(remote), Some(cache)) =
|
||||
(json["cover_url"].as_str(), pmocovers::get_cover_cache())
|
||||
{
|
||||
match cache.add_from_url(remote, Some("radioparadise")).await {
|
||||
Ok(pk) => {
|
||||
cover_url = Some(pmocache::covers_route_for(&pk, None))
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"Failed to cache live cover {}: {}",
|
||||
remote,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
let cover_url = cover_url.or_else(|| Some(self.default_cover_url()));
|
||||
|
||||
// Parse duration from JSON (in seconds as a float)
|
||||
let duration = json["duration"]
|
||||
@@ -318,8 +356,8 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Get channel descriptor by slug
|
||||
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
||||
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
||||
fn get_channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
|
||||
channel_by_slug(slug)
|
||||
}
|
||||
|
||||
/// Parse an object ID into its components
|
||||
@@ -356,7 +394,7 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Build a channel container
|
||||
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
parent_id: "radio-paradise".to_string(),
|
||||
@@ -366,14 +404,14 @@ impl RadioParadiseSource {
|
||||
title: descriptor.display_name.to_string(),
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the live playlist container for a channel
|
||||
fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug),
|
||||
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
@@ -383,15 +421,15 @@ impl RadioParadiseSource {
|
||||
title: format!("{} - Live Playlist", descriptor.display_name),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a live stream item for a channel
|
||||
fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
|
||||
let stream_url = self.build_live_url(descriptor.slug);
|
||||
async fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
|
||||
let stream_url = self.build_live_url(&descriptor.slug);
|
||||
|
||||
Item {
|
||||
id: format!("radio-paradise:channel:{}:live", descriptor.slug),
|
||||
@@ -403,7 +441,7 @@ impl RadioParadiseSource {
|
||||
artist: Some("Radio Paradise".to_string()),
|
||||
album: Some(descriptor.display_name.to_string()),
|
||||
genre: Some("Radio".to_string()),
|
||||
album_art: Some(self.default_cover_url()),
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
@@ -422,7 +460,7 @@ impl RadioParadiseSource {
|
||||
sample_frequency: Some("44100".to_string()),
|
||||
nr_audio_channels: Some("2".to_string()),
|
||||
duration: None,
|
||||
url: self.build_live_ogg_url(descriptor.slug),
|
||||
url: self.build_live_ogg_url(&descriptor.slug),
|
||||
},
|
||||
],
|
||||
descriptions: vec![],
|
||||
@@ -430,7 +468,7 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Build a history container for a channel
|
||||
fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}:history", descriptor.slug),
|
||||
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
@@ -441,7 +479,7 @@ impl RadioParadiseSource {
|
||||
// Expose l'historique comme une playlist jouable
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
@@ -453,10 +491,10 @@ impl RadioParadiseSource {
|
||||
&self,
|
||||
descriptor: &ChannelDescriptor,
|
||||
) -> Container {
|
||||
let mut container = self.build_history_container(descriptor);
|
||||
let mut container = self.build_history_container(descriptor).await;
|
||||
|
||||
// Try to get actual count from playlist
|
||||
let playlist_id = Self::history_playlist_id(descriptor.slug);
|
||||
let playlist_id = Self::history_playlist_id(&descriptor.slug);
|
||||
let manager = pmoplaylist::PlaylistManager();
|
||||
|
||||
if let Ok(reader) = manager.get_read_handle(&playlist_id).await {
|
||||
@@ -663,11 +701,11 @@ impl MusicSource for RadioParadiseSource {
|
||||
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
||||
match Self::parse_object_id(object_id) {
|
||||
ObjectIdType::Root => {
|
||||
// Return the 4 channel containers
|
||||
let containers: Vec<Container> = ALL_CHANNELS
|
||||
.iter()
|
||||
.map(|ch| self.build_channel_container(ch))
|
||||
.collect();
|
||||
// Return one container per known channel
|
||||
let mut containers = Vec::new();
|
||||
for ch in channels().iter() {
|
||||
containers.push(self.build_channel_container(ch).await);
|
||||
}
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
}
|
||||
@@ -678,13 +716,13 @@ impl MusicSource for RadioParadiseSource {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
|
||||
let live_item = self.build_live_stream_item(descriptor);
|
||||
let live_playlist_container = self.build_live_playlist_container(descriptor);
|
||||
let live_item = self.build_live_stream_item(&descriptor).await;
|
||||
let live_playlist_container = self.build_live_playlist_container(&descriptor).await;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
let history_container = self.build_history_container_with_count(descriptor).await;
|
||||
let history_container = self.build_history_container_with_count(&descriptor).await;
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
let history_container = self.build_history_container(descriptor);
|
||||
let history_container = self.build_history_container(&descriptor).await;
|
||||
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![live_playlist_container, history_container],
|
||||
@@ -702,7 +740,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
#[cfg(feature = "playlist")]
|
||||
{
|
||||
let history_container =
|
||||
self.build_history_container_with_count(descriptor).await;
|
||||
self.build_history_container_with_count(&descriptor).await;
|
||||
let items = self.get_history_items(&slug, 0, 100).await?;
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![history_container],
|
||||
@@ -713,7 +751,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
{
|
||||
// If playlist feature is disabled, return just the container
|
||||
let history_container = self.build_history_container(descriptor);
|
||||
let history_container = self.build_history_container(&descriptor).await;
|
||||
Ok(BrowseResult::Containers(vec![history_container]))
|
||||
}
|
||||
}
|
||||
@@ -723,7 +761,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
let item = self.build_live_stream_item(descriptor);
|
||||
let item = self.build_live_stream_item(&descriptor).await;
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
@@ -735,7 +773,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
{
|
||||
let container = self.build_live_playlist_container(descriptor);
|
||||
let container = self.build_live_playlist_container(&descriptor).await;
|
||||
let items = self.get_live_playlist_items(&slug, 0, 100).await?;
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![container],
|
||||
@@ -745,7 +783,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
{
|
||||
let container = self.build_live_playlist_container(descriptor);
|
||||
let container = self.build_live_playlist_container(&descriptor).await;
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
}
|
||||
@@ -816,6 +854,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,7 +913,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
Ok(self.build_live_stream_item(descriptor))
|
||||
Ok(self.build_live_stream_item(&descriptor).await)
|
||||
}
|
||||
|
||||
ObjectIdType::HistoryTrack { slug, pk } => {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS},
|
||||
channels::{channels, refresh_channels, ChannelDescriptor},
|
||||
client::RadioParadiseClient,
|
||||
models::{Block, EventId},
|
||||
playlist_feeder::RadioParadisePlaylistFeeder,
|
||||
@@ -133,13 +133,13 @@ impl Default for ParadiseHistoryBuilder {
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
impl ParadiseStreamChannelConfig {
|
||||
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self {
|
||||
pub fn from_config(cfg: &pmoconfig::Config, channel_slug: &str) -> Self {
|
||||
use serde_yaml::Value;
|
||||
let path = [
|
||||
"sources",
|
||||
"radio_paradise",
|
||||
"channels",
|
||||
channel.slug(),
|
||||
channel_slug,
|
||||
"max_lead_seconds",
|
||||
];
|
||||
match cfg.get_value(&path) {
|
||||
@@ -303,10 +303,10 @@ impl ParadiseStreamChannel {
|
||||
// 6. Lancer le pipeline audio
|
||||
let stop_token = CancellationToken::new();
|
||||
let pipeline_stop = stop_token.clone();
|
||||
let channel_display_name = descriptor.display_name;
|
||||
let channel_display_name = descriptor.display_name.clone();
|
||||
|
||||
let state = Arc::new(ChannelState {
|
||||
descriptor,
|
||||
descriptor: descriptor.clone(),
|
||||
config,
|
||||
client,
|
||||
feeder: feeder.clone(),
|
||||
@@ -401,7 +401,7 @@ impl ParadiseStreamChannel {
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> ChannelDescriptor {
|
||||
self.descriptor
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
/// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client.
|
||||
@@ -888,11 +888,11 @@ impl Drop for HistoryOggStream {
|
||||
|
||||
/// Gestionnaire multi-canaux.
|
||||
pub struct ParadiseChannelManager {
|
||||
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
|
||||
channels: HashMap<u16, Arc<ParadiseStreamChannel>>,
|
||||
}
|
||||
|
||||
impl ParadiseChannelManager {
|
||||
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self {
|
||||
pub fn new(channels: HashMap<u16, Arc<ParadiseStreamChannel>>) -> Self {
|
||||
Self { channels }
|
||||
}
|
||||
|
||||
@@ -901,13 +901,32 @@ impl ParadiseChannelManager {
|
||||
history_builder: Option<ParadiseHistoryBuilder>,
|
||||
server_base_url: Option<String>,
|
||||
) -> Result<Self> {
|
||||
// Rafraîchir la liste des canaux depuis l'API avant d'initialiser les
|
||||
// pipelines (fallback sur le registre courant en cas d'échec réseau)
|
||||
match RadioParadiseClient::new().await {
|
||||
Ok(client) => {
|
||||
if let Err(e) = refresh_channels(&client).await {
|
||||
tracing::warn!(
|
||||
"Failed to refresh Radio Paradise channel list, using current registry: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to create Radio Paradise client for channel discovery: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
let channel_list = channels();
|
||||
tracing::info!(
|
||||
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
|
||||
ALL_CHANNELS.len(),
|
||||
channel_list.len(),
|
||||
server_base_url
|
||||
);
|
||||
let mut map = HashMap::new();
|
||||
for descriptor in ALL_CHANNELS.iter().copied() {
|
||||
for descriptor in channel_list.iter().cloned() {
|
||||
let mut config = ParadiseStreamChannelConfig::default();
|
||||
config.server_base_url = server_base_url.clone();
|
||||
|
||||
@@ -940,7 +959,12 @@ impl ParadiseChannelManager {
|
||||
);
|
||||
let channel = match tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts),
|
||||
ParadiseStreamChannel::new(
|
||||
descriptor.clone(),
|
||||
config,
|
||||
cover_cache.clone(),
|
||||
history_opts,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -980,7 +1004,7 @@ impl ParadiseChannelManager {
|
||||
Self::with_defaults_with_cover_cache(None, None, None).await
|
||||
}
|
||||
|
||||
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
|
||||
pub fn get(&self, id: u16) -> Option<Arc<ParadiseStreamChannel>> {
|
||||
self.channels.get(&id).cloned()
|
||||
}
|
||||
|
||||
@@ -988,7 +1012,7 @@ impl ParadiseChannelManager {
|
||||
self.channels.values()
|
||||
}
|
||||
|
||||
pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> {
|
||||
pub async fn prefetch_until_horizon(&self, channel_id: u16) -> Result<()> {
|
||||
let channel = self
|
||||
.get(channel_id)
|
||||
.ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?;
|
||||
|
||||
@@ -1925,6 +1925,69 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_container(&self, object_id: &str) -> Result<Option<pmodidl::Container>> {
|
||||
use crate::didl::ToDIDL;
|
||||
match self.parse_object_id(object_id) {
|
||||
ObjectIdType::Album(album_id) => {
|
||||
let album = self
|
||||
.inner
|
||||
.client
|
||||
.get_album(&album_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap();
|
||||
let container = album
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Playlist(playlist_id) => {
|
||||
let playlist = self
|
||||
.inner
|
||||
.client
|
||||
.get_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let container = playlist
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Artist(artist_id) => {
|
||||
// Pas d'endpoint artist direct — on tire le nom/image depuis les albums
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_artist_albums(&artist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let first = albums.first();
|
||||
let artist_name = first
|
||||
.map(|a| a.artist.name.clone())
|
||||
.unwrap_or_else(|| format!("Artiste {}", artist_id));
|
||||
let album_art = first.and_then(|a| a.image_cached.clone().or_else(|| a.image.clone()));
|
||||
let container = pmodidl::Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "qobuz".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(albums.len().to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist_name.clone(),
|
||||
class: "object.container.person.musicArtist".to_string(),
|
||||
artist: Some(artist_name),
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||
// Try cache manager first
|
||||
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
|
||||
@@ -2088,6 +2151,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +50,17 @@ impl<E: RustEmbed> ServeEmbed<E> {
|
||||
|
||||
for candidate in candidates {
|
||||
if let Some(content) = E::get(candidate) {
|
||||
let mime = mime_guess::from_path(candidate).first_or_octet_stream();
|
||||
// mime_guess ne connaît pas .webmanifest (trop récent)
|
||||
let mime = if candidate.ends_with(".webmanifest") {
|
||||
"application/manifest+json".to_string()
|
||||
} else {
|
||||
mime_guess::from_path(candidate)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
};
|
||||
return Some(
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime.as_ref())],
|
||||
[(header::CONTENT_TYPE, mime.as_str())],
|
||||
content.data.into_owned(),
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
@@ -1179,6 +1179,88 @@ async fn unregister_source_handler(Path(id): Path<String>) -> impl IntoResponse
|
||||
}
|
||||
}
|
||||
|
||||
/// Paramètres pour la recherche dans une source
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
|
||||
struct SearchParams {
|
||||
/// Texte de recherche (URL ou termes)
|
||||
q: String,
|
||||
}
|
||||
|
||||
/// Recherche dans une source musicale
|
||||
#[cfg(feature = "server")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/{id}/search",
|
||||
params(
|
||||
("id" = String, Path, description = "ID de la source"),
|
||||
SearchParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Résultats de la recherche", body = SourceBrowseResponse),
|
||||
(status = 404, description = "Source introuvable", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de la recherche", body = ErrorResponse),
|
||||
),
|
||||
tag = "sources"
|
||||
)]
|
||||
async fn search_source(
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> impl IntoResponse {
|
||||
match get_source(&id).await {
|
||||
Some(source) => {
|
||||
let query = crate::SearchQuery {
|
||||
text: params.q,
|
||||
media_type: crate::MediaSearchType::All,
|
||||
scope: crate::SearchScope::Catalog,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
match source.search(&query).await {
|
||||
Ok(result) => {
|
||||
let (containers_raw, items_raw) = match result {
|
||||
crate::BrowseResult::Containers(c) => (c, Vec::new()),
|
||||
crate::BrowseResult::Items(i) => (Vec::new(), i),
|
||||
crate::BrowseResult::Mixed { containers, items } => (containers, items),
|
||||
};
|
||||
let containers: Vec<BrowseContainerInfo> =
|
||||
containers_raw.iter().map(BrowseContainerInfo::from).collect();
|
||||
let items: Vec<BrowseItemInfo> =
|
||||
items_raw.iter().map(BrowseItemInfo::from).collect();
|
||||
let returned_containers = containers.len();
|
||||
let returned_items = items.len();
|
||||
let total = returned_containers + returned_items;
|
||||
let update_id = source.update_id().await;
|
||||
let response = SourceBrowseResponse {
|
||||
object_id: source.id().to_string(),
|
||||
containers,
|
||||
items,
|
||||
returned_containers,
|
||||
returned_items,
|
||||
total,
|
||||
update_id,
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Search failed: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Source '{}' not found", id),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée le router pour l'API des sources (endpoints de lecture uniquement)
|
||||
///
|
||||
/// # Returns
|
||||
@@ -1216,6 +1298,7 @@ pub fn create_sources_router() -> Router {
|
||||
.route("/{id}/cache/status", get(get_source_cache_status))
|
||||
.route("/{id}/cache", post(request_source_cache))
|
||||
.route("/{id}/formats", get(get_source_formats))
|
||||
.route("/{id}/search", get(search_source))
|
||||
}
|
||||
|
||||
/// Structure pour la documentation OpenAPI de base
|
||||
|
||||
@@ -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
|
||||
@@ -464,6 +467,18 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Retourne les métadonnées d'un container (titre, artiste, cover, child_count)
|
||||
/// SANS charger ses enfants — un seul appel API léger.
|
||||
///
|
||||
/// Utilisé par UrlSource pour afficher un album/playlist en résultat de recherche
|
||||
/// avec les bonnes métadonnées et le bon `class` UPnP, avant que l'utilisateur
|
||||
/// ne navigue dedans ou ne lance la lecture.
|
||||
///
|
||||
/// L'implémentation par défaut retourne None (non supporté).
|
||||
async fn get_container(&self, _object_id: &str) -> Result<Option<Container>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve the actual URI for a track
|
||||
///
|
||||
/// This method should return the URI that can be used to stream/download
|
||||
@@ -650,6 +665,7 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
pmourlsource/Cargo.toml
Normal file
15
pmourlsource/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[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 }
|
||||
futures = { 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"));
|
||||
}
|
||||
}
|
||||
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
use reqwest::{redirect, Client};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Handler dédié aux URLs radiofrance.fr — priorité 80.
|
||||
///
|
||||
/// RadioFrance utilise SvelteKit (SSR).
|
||||
///
|
||||
/// Stratégie selon le type d'URL :
|
||||
///
|
||||
/// 1. **Page podcast** (`/podcasts/{slug}`)
|
||||
/// → `rssFeed:"https://..."` inline → fetch + parse RSS
|
||||
///
|
||||
/// 2. **Page série** (`/podcasts/serie-{slug}`)
|
||||
/// → JSON-LD `ItemList` → extraire les URLs d'épisodes → fetch concurrent
|
||||
/// (RadioFrance limite leur RSS à 2 éléments ; scraping direct donne tous les épisodes)
|
||||
///
|
||||
/// 3. **Page épisode** (`/podcasts/{podcast}/{episode}-{id}`)
|
||||
/// → URL MP3 `media.radiofrance-podcast.net` inline
|
||||
pub struct RadioFranceUrlHandler {
|
||||
client: Arc<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: Arc::new(client) })
|
||||
}
|
||||
|
||||
async fn resolve_inner(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let html = self.fetch_html(url).await?;
|
||||
|
||||
// --- Cas 1 : page podcast → rssFeed non vide ---
|
||||
if let Some(rss_url) = extract_rss_feed_key(&html) {
|
||||
tracing::debug!(rss_url = %rss_url, "RadioFrance: rssFeed trouvé");
|
||||
return self.fetch_rss(&rss_url).await;
|
||||
}
|
||||
|
||||
// --- Cas 2 : page série → fetch concurrent des pages épisodes ---
|
||||
let episode_urls = extract_episode_urls_from_series(&html, url);
|
||||
if !episode_urls.is_empty() {
|
||||
tracing::debug!(
|
||||
count = episode_urls.len(),
|
||||
"RadioFrance: série — fetch concurrent des épisodes"
|
||||
);
|
||||
let feed_title = extract_og_title(&html);
|
||||
let feed_image = extract_og_image(&html);
|
||||
let album = feed_title.clone().or_else(|| extract_title_tag(&html));
|
||||
|
||||
let client = self.client.clone();
|
||||
let fetches: Vec<_> = episode_urls
|
||||
.into_iter()
|
||||
.map(|ep_url| {
|
||||
let client = client.clone();
|
||||
let album = album.clone();
|
||||
let feed_image = feed_image.clone();
|
||||
async move {
|
||||
match fetch_html_with_client(&client, &ep_url).await {
|
||||
Ok(ep_html) => episode_to_track(&ep_html, &ep_url, album.as_deref(), feed_image.as_deref()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tracks: Vec<ResolvedTrack> = join_all(fetches).await.into_iter().flatten().collect();
|
||||
|
||||
if !tracks.is_empty() {
|
||||
return Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items: tracks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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> {
|
||||
fetch_html_with_client(&self.client, url).await
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_html_with_client(client: &Client, url: &str) -> Result<String, UrlResolverError> {
|
||||
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()))
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait toutes les URLs d'épisodes depuis le JSON-LD `ItemList` d'une page série.
|
||||
///
|
||||
/// Filtre les URLs non-épisodes (série elle-même, images, domaine seul…).
|
||||
/// Une URL d'épisode a exactement 4 segments de path :
|
||||
/// `/{station}/podcasts/{podcast-slug}/{episode-slug}`
|
||||
fn extract_episode_urls_from_series(html: &str, series_url: &str) -> Vec<String> {
|
||||
let item_marker = "\"@type\":\"ItemList\"";
|
||||
let list_pos = match html.find(item_marker) {
|
||||
Some(p) => p,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let url_prefix = "\"url\":\"https://www.radiofrance.fr/";
|
||||
let after_list = &html[list_pos..];
|
||||
let mut urls = Vec::new();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(rel_pos) = after_list[search_from..].find(url_prefix) {
|
||||
let rel_pos = search_from + rel_pos;
|
||||
let from = list_pos + rel_pos + "\"url\":\"".len();
|
||||
let Some(end_rel) = html[from..].find('"') else { break };
|
||||
let candidate = &html[from..from + end_rel];
|
||||
|
||||
if is_episode_url(candidate, series_url) {
|
||||
urls.push(candidate.to_string());
|
||||
}
|
||||
search_from = rel_pos + url_prefix.len();
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// Retourne true si l'URL est bien une page d'épisode (≥4 segments de path).
|
||||
fn is_episode_url(url: &str, series_url: &str) -> bool {
|
||||
if url.trim_end_matches('/') == series_url.trim_end_matches('/') {
|
||||
return false;
|
||||
}
|
||||
if !url.contains("/podcasts/") {
|
||||
return false;
|
||||
}
|
||||
// Exclure fichiers statiques (images…)
|
||||
let last = url.rsplit('/').next().unwrap_or("");
|
||||
if last.contains('.') {
|
||||
return false;
|
||||
}
|
||||
// Doit avoir ≥ 4 segments après le domaine : /station/podcasts/podcast/episode
|
||||
let path_segments: usize = url
|
||||
.splitn(4, "radiofrance.fr")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.count();
|
||||
path_segments >= 4
|
||||
}
|
||||
|
||||
/// Extrait un `ResolvedTrack` depuis la page HTML d'un épisode RadioFrance.
|
||||
fn episode_to_track(html: &str, url: &str, album: Option<&str>, feed_image: Option<&str>) -> Option<ResolvedTrack> {
|
||||
let mp3_url = extract_mp3_url(html)?;
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
let album_art = extract_og_image(html).or_else(|| feed_image.map(|s| s.to_string()));
|
||||
Some(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: album.map(|s| s.to_string()),
|
||||
duration: None,
|
||||
album_art,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
340
pmourlsource/src/source.rs
Normal file
340
pmourlsource/src/source.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmosource::api::get_source as get_source_from_registry;
|
||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
||||
|
||||
/// Store éphémère pour les playlists URL : playlist_id → (container, items)
|
||||
type PlaylistStore = Arc<RwLock<HashMap<String, (Container, Vec<Item>)>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver,
|
||||
base_url: String,
|
||||
playlists: PlaylistStore,
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
pub fn new(resolver: UrlResolver, base_url: String) -> Self {
|
||||
Self {
|
||||
resolver,
|
||||
base_url,
|
||||
playlists: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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![])),
|
||||
// Playlist éphémère créée par build_url_playlist
|
||||
_ if object_id.starts_with("urlsource-") => {
|
||||
let store = self.playlists.read().map_err(|_| {
|
||||
MusicSourceError::BrowseError("playlist store lock poisoned".to_string())
|
||||
})?;
|
||||
match store.get(object_id) {
|
||||
Some((_, items)) => Ok(BrowseResult::Items(items.clone())),
|
||||
None => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
// Court-circuiter les IDs "url:*" pour éviter des erreurs dans les logs
|
||||
// des autres sources (items éphémères non persistables par ID).
|
||||
_ if object_id.starts_with("url:") => {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}) => {
|
||||
if let Some(source) = get_source_from_registry(&source_id).await {
|
||||
match source.get_container(&container_id).await {
|
||||
Ok(Some(mut container)) => {
|
||||
container.parent_id = source_id;
|
||||
return Ok(BrowseResult::Containers(vec![container]));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_id = %source_id,
|
||||
container_id = %container_id,
|
||||
error = %e,
|
||||
"UrlSource: get_container échoué"
|
||||
);
|
||||
}
|
||||
}
|
||||
match source.get_item(&container_id).await {
|
||||
Ok(item) => return Ok(BrowseResult::Items(vec![item])),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let title = display_title_for_url(url);
|
||||
let container = Container {
|
||||
id: container_id,
|
||||
parent_id: source_id,
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some("1".to_string()),
|
||||
searchable: Some("1".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: playlist_title, items }) => {
|
||||
let title = playlist_title.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, items);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Track(t)) => {
|
||||
// Pour un épisode unique, créer une playlist avec 1 item.
|
||||
// Titre de la playlist = nom du podcast (album) ou titre de l'épisode.
|
||||
let title = t.album.clone()
|
||||
.or_else(|| Some(t.title.clone()))
|
||||
.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, vec![t]);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Stream { uri, title, mime_type }) => {
|
||||
let item = stream_to_item(uri, title, mime_type);
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(_)) => {
|
||||
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![])
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
/// Crée un container playlist éphémère en mémoire depuis des tracks résolus.
|
||||
///
|
||||
/// Les items gardent leurs URLs directes (RadioFrance, etc.) et leur MIME type
|
||||
/// d'origine — pas de proxy via pmoaudiocache, donc pas de conversion FLAC
|
||||
/// et pas de problème avec les formats M4A/AAC.
|
||||
fn build_url_playlist(&self, url: &str, title: String, tracks: Vec<ResolvedTrack>) -> Container {
|
||||
let playlist_id = format!("urlsource-{:016x}", url_hash(url));
|
||||
let n = tracks.len();
|
||||
|
||||
// Cover = album_art du premier épisode
|
||||
let album_art = tracks.first().and_then(|t| t.album_art.clone());
|
||||
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
||||
Item {
|
||||
id: format!("{}:{}", playlist_id, i),
|
||||
parent_id: playlist_id.clone(),
|
||||
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(|| Some(title.clone())),
|
||||
genre: None,
|
||||
album_art: t.album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: Some(format!("{}", i + 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![],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let container = Container {
|
||||
id: playlist_id.clone(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(n.to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title: title.clone(),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
|
||||
// Stocker dans le store éphémère (écrase toute entrée précédente)
|
||||
if let Ok(mut store) = self.playlists.write() {
|
||||
store.insert(playlist_id, (container.clone(), items));
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn display_title_for_url(url: &str) -> String {
|
||||
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("");
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stable d'une URL pour construire un ID de playlist déterministe.
|
||||
fn url_hash(url: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.52
|
||||
0.3.62
|
||||
|
||||
Reference in New Issue
Block a user