18 Commits

Author SHA1 Message Date
ec56e479ba Merge pull request 'feat(pmoparadise): migrate channel IDs to u16 and use dynamic registry' (#110) from push-qltronmxryzp into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m2s
Reviewed-on: #110
2026-07-11 22:58:23 +02:00
fc49c79b34 feat(pmoparadise): migrate channel IDs to u16 and use dynamic registry
Replaces the static ALL_CHANNELS array with a dynamic, thread-safe registry fetched via the Radio Paradise API. Migrates all channel identifiers from u8 to u16 across client, config, server, and example modules to support expanded ID ranges. Updates validation to runtime lookups, converts builders to async, and adds local cover caching. Bumps version from 0.3.61 to 0.3.62.
2026-07-11 22:57:49 +02:00
884b092af8 Merge pull request 'feat: add web share target support and implement catalog search API' (#109) from push-vsvqwqmmouro into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m58s
Reviewed-on: #109
2026-06-28 23:50:33 +02:00
472211012b feat: add web share target support and implement catalog search API
This patch release bumps the version to 0.3.61 and introduces several key improvements across the stack. The backend `/info` route registration is deferred until after UPnP initialization to ensure the local server ID is correctly exposed. A new `GET /{id}/search` endpoint has been added to the pmosource API for querying music catalogs. On the frontend, Web Share Target support is enabled via Vite configuration and a dedicated composable that handles incoming URL parameters, playback state, and error notifications. Renderer selection state is also now exposed for UI synchronization.
2026-06-28 23:40:35 +02:00
ce9c779bb2 Merge pull request 'chore: bump version to 0.3.60 and handle webmanifest MIME types' (#108) from push-oywlvnwootxq into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m57s
Reviewed-on: #108
2026-06-28 12:37:33 +02:00
070ca8f3e0 chore: bump version to 0.3.60 and handle webmanifest MIME types
Update PMOMusic/Cargo.toml and version.txt from 0.3.59 to 0.3.60. Add explicit application/manifest+json detection for .webmanifest files in serve_embed.rs to compensate for mime_guess limitations, converting the MIME value to an owned String and updating the CONTENT_TYPE header reference accordingly.
2026-06-28 12:37:09 +02:00
01f49670e9 Merge pull request 'feat: add PWA support and bump project version to 0.3.59' (#107) from push-qmqrwyqslsxn into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
Reviewed-on: #107
2026-06-28 12:14:43 +02:00
ed9b8046e4 feat: add PWA support and bump project version to 0.3.59
Integrate vite-plugin-pwa to enable service worker generation, offline caching, and standalone display mode. Configure manifest metadata, 192px/512px icons, theme color, and iOS status bar styling. Bump project version to 0.3.59 across Cargo.toml, Cargo.lock, and version.txt, and regenerate package-lock.json.
2026-06-28 12:04:32 +02:00
f5d9e2590e Merge pull request 'push-nlvvpvkumokx' (#105) from push-nlvvpvkumokx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
Reviewed-on: #105
2026-06-21 19:22:52 +02:00
ad45e40900 chore: resolve merge conflicts and update version to 0.3.56
Updates Cargo.toml, Cargo.lock, and version.txt from 0.3.53 to 0.3.56. Resolves divergent branch changes across configuration files and preserves the macOS .DS_Store binary structure during integration.
2026-06-21 19:22:32 +02:00
cc27e4efca Bump version to 0.3.55 and update Dockerfile
Incremented version from 0.3.54 to 0.3.55 in Cargo.toml and version.txt. Added COPY instruction for pmourlsource module in Dockerfile.
2026-06-21 19:11:47 +02:00
b7285e14c4 chore: bump version to 0.3.54 and configure Serena project
Update PMOMusic crate version in Cargo.toml and the project version.txt from 0.3.53 to 0.3.54. Add Serena project configuration by introducing .serena/.gitignore to exclude local cache files, and create .serena/project.yml to set Rust as the language server target with UTF-8 encoding.
2026-06-21 19:07:45 +02:00
da4d9008ad Merge pull request 'push-zwznsplyvnyp' (#103) from push-zwznsplyvnyp into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m27s
Reviewed-on: #103
2026-06-21 18:41:46 +02:00
f757e10734 chore: bump version to 0.3.54 and configure Serena project
Update PMOMusic crate version in Cargo.toml and the project version.txt from 0.3.53 to 0.3.54. Add Serena project configuration by introducing .serena/.gitignore to exclude local cache files, and create .serena/project.yml to set Rust as the language server target with UTF-8 encoding.
2026-06-21 18:05:13 +02:00
009545de40 feat(media): enhance source routing and add playlist caching
Update UrlSource::new() to accept a base_url parameter for relative path resolution. Extend the Qobuz router with Playlist and Artist variants to fetch metadata and construct DIDL containers. Introduce an in-memory PlaylistStore cache, refactor search() and browse() to route and cache dynamic playlist items, and add helper utilities for deterministic ID generation.
2026-06-21 15:09:22 +02:00
bfa6231b1d feat: add async get_container and parallelize source browsing
Introduce an async `get_container` method across source implementations to fetch lightweight container metadata efficiently. Add the `futures` crate as a dependency to enable concurrent operations. Refactor the `browse` flow to short-circuit ephemeral IDs and delegate metadata resolution to `get_container`. Update the RadioFrance handler to scrape episode pages in parallel, bypassing limited RSS feeds and improving overall browsing performance.
2026-06-21 15:06:31 +02:00
87bce3edfa feat: Replace hardcoded container stubs with dynamic source resolution
Uses `get_source_from_registry` to browse target sources and extract real metadata (title, artist, cover, track count). Updates fallback logic to set `parent_id` to `source_id` instead of `"url"`, ensuring consistent frontend routing and accurate UI rendering.
2026-06-21 15:03:20 +02:00
d49bb604ca feat: add UrlSource for arbitrary URL-based media playback
Introduces the pmourlsource crate as a standard MusicSource that resolves HTTP/HTTPS URLs via a priority-ordered UrlHandler registry. Includes specialized handlers for Qobuz and Radio France alongside an SSRF-safe generic scraper supporting playlists, feeds, and HTML audio. Integrates with existing browse flows, REST endpoints, and Android Web Share targets while updating source capability flags to correctly route URL queries.
2026-06-21 15:01:51 +02:00
52 changed files with 8028 additions and 316 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

133
.serena/project.yml Normal file
View 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 readonly.
# 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: []

View 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
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.53"
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"

View File

@@ -23,6 +23,7 @@ members = [
"pmoflac",
"pmometadata",
"pmocontrol",
"pmourlsource",
]
[workspace.dependencies]

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
PMOMusic-A-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -1,13 +1,13 @@
[package]
name = "PMOMusic"
version = "0.3.53"
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"] }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

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

View File

@@ -579,6 +579,8 @@ export function useRenderers() {
volumeUp,
volumeDown,
toggleMute,
// Selection
selectedRendererId,
// Playlist binding
attachPlaylist,
detachPlaylist,

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -207,6 +207,7 @@ impl MusicSource for RadioFranceSource {
supports_multiple_formats: false,
supports_advanced_search: false,
supports_pagination: false,
handles_url_input: false,
}
}

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

122
pmourlsource/src/handler.rs Normal file
View 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()
}
}

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

View File

@@ -0,0 +1,3 @@
pub mod generic;
pub mod qobuz;
pub mod radiofrance;

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

View 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
View 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
View 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()
}

View File

@@ -1 +1 @@
0.3.53
0.3.62