Merge pull request 'push-zwznsplyvnyp' (#103) from push-zwznsplyvnyp into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m27s
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m27s
Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "pmomusic"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- rust
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Play From URL — source `UrlSource`
|
||||
|
||||
Inspiré par BubbleUPnP : recevoir n'importe quelle URL (lien de partage Qobuz,
|
||||
flux radio, playlist M3U, page web contenant de l'audio…) et la jouer
|
||||
immédiatement sur le renderer actif.
|
||||
|
||||
---
|
||||
|
||||
## Vision architecturale
|
||||
|
||||
`UrlSource` est une **source musicale ordinaire** qui implémente `MusicSource`,
|
||||
exactement comme Qobuz, RadioFrance ou RadioParadise. Elle apparaît dans le
|
||||
drawer gauche au même titre que les autres sources du serveur PMO.
|
||||
|
||||
Sa particularité : sa **barre de recherche est le champ URL**. L'utilisateur
|
||||
colle ou tape une URL, appuie sur Entrée — la source résout l'URL et retourne
|
||||
le contenu jouable comme un `BrowseResult` normal.
|
||||
|
||||
```
|
||||
Drawer gauche
|
||||
└─ PMO Music Server
|
||||
├─ Qobuz
|
||||
├─ Radio Paradise
|
||||
├─ Radio France
|
||||
└─ URL / Partage ← nouvelle source
|
||||
└─ [barre de recherche = champ URL]
|
||||
└─ coller une URL + Entrée
|
||||
└─ résolution → BrowseResult → queue + play
|
||||
```
|
||||
|
||||
Avantages de cette approche :
|
||||
- **Zéro nouvelle UI** : la barre de recherche existante du drawer gère tout
|
||||
- **Zéro nouvel endpoint REST** : browse/search existants suffisent
|
||||
- **Zéro cas particulier** dans le drawer ou le content directory handler
|
||||
- `browse()` du container racine peut afficher un **historique** des URLs jouées
|
||||
|
||||
---
|
||||
|
||||
## Trait `UrlHandler` (dans `pmosource`)
|
||||
|
||||
Chaque source (et un handler générique) peut revendiquer les URLs qu'elle sait
|
||||
résoudre.
|
||||
|
||||
```rust
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante
|
||||
/// → la UrlSource délègue le browse à cette source
|
||||
SourceContainer {
|
||||
source_id: String, // "qobuz", "radiofrance", …
|
||||
container_id: String, // "qobuz:album:l46fxnqnxp5vs"
|
||||
},
|
||||
/// Liste de tracks (M3U, PLS, XSPF, RSS/podcast…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
/// Track unique ou flux continu
|
||||
Track {
|
||||
uri: String,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
Stream {
|
||||
uri: String,
|
||||
metadata: StreamMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier (défaut 50)
|
||||
fn priority(&self) -> u8 { 50 }
|
||||
/// Test rapide sans I/O (regex sur l'URL)
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé)
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handlers spécifiques aux sources
|
||||
|
||||
### `QobuzUrlHandler` (dans `pmoqobuz`) — priorité 90
|
||||
|
||||
URLs reconnues. Les IDs sont potentiellement alphanumériques pour tous les
|
||||
types (pas seulement les albums) :
|
||||
|
||||
| Forme d'URL | Exemple |
|
||||
|---|---|
|
||||
| `open.qobuz.com/album/<id>` | `https://open.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `play.qobuz.com/album/<id>` | `https://play.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `open.qobuz.com/track/<id>` | `https://open.qobuz.com/track/48471123` |
|
||||
| `open.qobuz.com/playlist/<id>` | `https://open.qobuz.com/playlist/63246908` |
|
||||
| `open.qobuz.com/artist/<id>` | `https://open.qobuz.com/artist/125709` |
|
||||
|
||||
Regex d'extraction : `[a-zA-Z0-9]+` pour tous les types sans exception.
|
||||
|
||||
Résolution sans appel API — l'ID est directement mappé sur un container_id :
|
||||
|
||||
```
|
||||
open.qobuz.com/album/l46fxnqnxp5vs
|
||||
→ ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz",
|
||||
container_id: "qobuz:album:l46fxnqnxp5vs",
|
||||
}
|
||||
```
|
||||
|
||||
### `RadioFranceUrlHandler` (dans `pmoradiofrance`) — priorité 90
|
||||
|
||||
URLs `radiofrance.fr/*`, `francemusique.fr/*`, `fip.fr/*`, etc.
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
### `RadioParadiseUrlHandler` (dans `pmoparadise`) — priorité 90
|
||||
|
||||
URLs `radioparadise.com/*`
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
---
|
||||
|
||||
## Handler générique (dans `pmourlresolver`, nouveau crate) — priorité 10
|
||||
|
||||
Dernier recours. Pipeline interne :
|
||||
|
||||
```
|
||||
URL
|
||||
│
|
||||
├─ Garde-fou SSRF : rejeter si IP résolue est privée/locale
|
||||
│ (RFC-1918 : 10/8, 172.16/12, 192.168/16 ; loopback : 127/8, ::1 ;
|
||||
│ link-local : 169.254/16, fe80::/10)
|
||||
│ → aucun cas d'usage légitime pour une URL interne ici
|
||||
│
|
||||
├─ HEAD request → Content-Type audio/* ?
|
||||
│ └─ → ResolvedContent::Stream / Track (URI directe)
|
||||
│
|
||||
├─ Extension ou Content-Type playlist ?
|
||||
│ ├─ .m3u / .m3u8 / application/vnd.apple.mpegurl → parse M3U
|
||||
│ ├─ .pls / audio/x-scpls → parse PLS
|
||||
│ └─ .xspf / application/xspf+xml → parse XSPF
|
||||
│
|
||||
├─ application/rss+xml / application/xml ?
|
||||
│ └─ → parse RSS, extraire les <enclosure> audio → Playlist
|
||||
│
|
||||
└─ text/html ?
|
||||
└─ GET + parse HTML
|
||||
├─ <audio src="...">
|
||||
├─ <link type="application/rss+xml"> → RSS/Podcast
|
||||
├─ og:audio
|
||||
└─ JSON-LD @type MusicRecording / MusicAlbum
|
||||
```
|
||||
|
||||
Pas de yt-dlp ni de dépendance Python externe — hors scope.
|
||||
|
||||
---
|
||||
|
||||
## `UrlSource` — implémentation de `MusicSource`
|
||||
|
||||
```rust
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver, // registre des handlers
|
||||
history: Arc<RwLock<VecDeque<HistoryEntry>>>, // dernières URLs
|
||||
}
|
||||
```
|
||||
|
||||
### `name()` / `id()`
|
||||
|
||||
```rust
|
||||
fn name(&self) -> &str { "URL / Partage" }
|
||||
fn id(&self) -> &str { "url" }
|
||||
```
|
||||
|
||||
### `root_container()`
|
||||
|
||||
Retourne un container dont le contenu (`browse("url")`) est l'historique des
|
||||
dernières URLs résolues avec succès (titre, source résolue, date).
|
||||
|
||||
### `search(query)` — cœur de la fonctionnalité
|
||||
|
||||
`query.text` est l'URL collée par l'utilisateur.
|
||||
|
||||
```
|
||||
search(url)
|
||||
│
|
||||
├─ resolver.resolve(url)
|
||||
│
|
||||
└─ match ResolvedContent
|
||||
├─ SourceContainer { source_id, container_id }
|
||||
│ → get_source(source_id) → source.browse(container_id)
|
||||
│ → retourner le BrowseResult tel quel
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Playlist { items }
|
||||
│ → construire un BrowseResult::Items depuis les tracks
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Track / Stream
|
||||
│ → BrowseResult::Items avec un seul item
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
└─ Err → BrowseResult vide + log
|
||||
```
|
||||
|
||||
Pour la délégation `SourceContainer`, `UrlSource` accède au `SOURCE_REGISTRY`
|
||||
global (déjà disponible dans `pmosource`). Elle est enregistrée après les autres
|
||||
sources donc elles sont toutes présentes au moment de la résolution.
|
||||
|
||||
### `browse(container_id)`
|
||||
|
||||
- `"url"` → liste de l'historique (containers/items)
|
||||
- `"url:history:<n>"` → détail d'une entrée historique (si Playlist)
|
||||
|
||||
---
|
||||
|
||||
## Initialisation dans `pmomediaserver`
|
||||
|
||||
```rust
|
||||
// Après enregistrement de Qobuz, RadioFrance, RadioParadise…
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
resolver.register(Arc::new(QobuzUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioFranceUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioParadiseUrlHandler::new()));
|
||||
resolver.register(Arc::new(GenericUrlHandler::new())); // toujours en dernier
|
||||
|
||||
let url_source = Arc::new(UrlSource::new(resolver));
|
||||
register_source(url_source).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
Le pipeline de résolution (`UrlResolver`) est le même quel que soit le point
|
||||
d'entrée. Deux modes complémentaires :
|
||||
|
||||
### Mode "pull" — le drawer
|
||||
|
||||
1. L'utilisateur ouvre le drawer gauche → voit "URL / Partage" dans la liste
|
||||
2. Il entre dedans → voit l'historique et la barre avec placeholder "Coller une URL…"
|
||||
3. Il colle `https://open.qobuz.com/album/l46fxnqnxp5vs` + Entrée
|
||||
4. Le drawer affiche les tracks de l'album (délégation Qobuz transparente)
|
||||
5. Il clique ▶ sur un track ou l'album entier → lecture normale
|
||||
|
||||
Aucune modification du drawer nécessaire.
|
||||
|
||||
### Mode "push" — endpoint REST + Web Share Target (Android)
|
||||
|
||||
Endpoint REST dans `pmocontrol` :
|
||||
|
||||
```
|
||||
POST /api/play-url
|
||||
{ "url": "https://open.qobuz.com/album/l46fxnqnxp5vs" }
|
||||
```
|
||||
|
||||
Résout l'URL via `UrlResolver` → ajoute au renderer actif → lecture immédiate.
|
||||
Pas de navigation dans le drawer, pas de clic supplémentaire.
|
||||
|
||||
**Web Share Target (PWA)** — intégration dans le share sheet Android :
|
||||
|
||||
```json
|
||||
// manifest.json
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"params": { "url": "url" }
|
||||
}
|
||||
```
|
||||
|
||||
La page `/share?url=...` appelle l'endpoint REST et se ferme. Depuis n'importe
|
||||
quelle application Android (Qobuz, navigateur, Spotify…) : menu "Partager" →
|
||||
choisir PMOMusic → l'album/track joue immédiatement sur le renderer courant,
|
||||
exactement comme BubbleUPnP.
|
||||
|
||||
Le renderer "courant" est celui qui est sélectionné dans la session active.
|
||||
Pour une PWA installée sur Android, c'est la session de l'utilisateur
|
||||
qui a installé l'app. Si plusieurs renderers sont disponibles, l'endpoint
|
||||
peut prendre un paramètre optionnel `renderer_id` pour cibler explicitement.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
### Étape 1 — Trait + QobuzUrlHandler + UrlSource minimale
|
||||
|
||||
- [ ] Ajouter `UrlHandler`, `ResolvedContent`, `UrlResolver` dans `pmosource`
|
||||
- [ ] Implémenter `QobuzUrlHandler` dans `pmoqobuz` (regex + mapping container_id)
|
||||
- [ ] Implémenter `UrlSource` avec `search()` gérant `SourceContainer`
|
||||
- [ ] Enregistrer dans `pmomediaserver`
|
||||
- [ ] Tester : coller un lien Qobuz → album joue
|
||||
|
||||
### Étape 2 — Formats de playlist directs
|
||||
|
||||
- [ ] Nouveau crate `pmourlresolver` avec `GenericUrlHandler`
|
||||
- [ ] Garde-fou SSRF (`is_safe_url()`)
|
||||
- [ ] Détection Content-Type + parse M3U, PLS, XSPF
|
||||
- [ ] `UrlSource::search()` gère `Playlist` et `Track/Stream`
|
||||
|
||||
### Étape 3 — Scraper HTML + historique
|
||||
|
||||
- [ ] Parse HTML : `<audio>`, og:audio, JSON-LD, RSS
|
||||
- [ ] Historique dans `UrlSource::browse()`
|
||||
- [ ] Placeholder adapté dans la barre de recherche du drawer
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
**Q1 — Barre de recherche : placeholder contextuel**
|
||||
Quand l'utilisateur est dans "URL / Partage", le placeholder devrait afficher
|
||||
"Coller une URL…" plutôt que "Rechercher…". Le drawer peut-il adapter le
|
||||
placeholder selon la source active ? À voir si c'est utile en pratique (le
|
||||
titre de la source dans le header est déjà indicatif).
|
||||
|
||||
**Q2 — Redirections**
|
||||
Les liens de partage mobiles Qobuz peuvent être des URLs raccourcies. Suivre
|
||||
les redirections automatiquement (reqwest le fait avec
|
||||
`redirect::Policy::limited(5)`).
|
||||
|
||||
**Q3 — Validation Qobuz**
|
||||
Le `QobuzUrlHandler` retourne un `SourceContainer` sans vérifier que l'album
|
||||
existe ou est accessible. L'erreur éventuelle sera levée au moment du browse
|
||||
délégué à `QobuzSource`. C'est acceptable : l'erreur arrivera rapidement avec
|
||||
un message clair.
|
||||
129
Cargo.lock
generated
129
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.53"
|
||||
version = "0.3.54"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -188,6 +188,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
@@ -963,6 +975,23 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1876,8 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1887,9 +1918,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2217,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2751,6 +2785,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -3969,6 +4009,7 @@ dependencies = [
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmourlsource",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -4230,6 +4271,21 @@ dependencies = [
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"pmodidl",
|
||||
"pmosource",
|
||||
"reqwest",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.2"
|
||||
@@ -4529,6 +4585,61 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
@@ -4748,6 +4859,7 @@ version = "0.12.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
@@ -4770,6 +4882,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4777,6 +4891,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
@@ -4786,6 +4901,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4958,6 +5074,7 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6641,6 +6758,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -23,6 +23,7 @@ members = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmocontrol",
|
||||
"pmourlsource",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.53"
|
||||
version = "0.3.54"
|
||||
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"] }
|
||||
|
||||
@@ -60,6 +60,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
|
||||
}
|
||||
|
||||
// Enregistrer la source URL / Partage
|
||||
info!("🔗 Registering URL source...");
|
||||
if let Err(e) = server.write().await.register_urlsource().await {
|
||||
tracing::warn!("⚠️ Failed to register URL source: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
|
||||
@@ -2399,12 +2399,15 @@ fn search_result_container_id(entries: &[ContainerEntry]) -> String {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.is_container)
|
||||
.and_then(|entry| {
|
||||
.map(|entry| {
|
||||
let parts: Vec<&str> = entry.id.splitn(5, ':').collect();
|
||||
if parts.len() == 5 && parts[0] == "qobuz" && parts[1] == "search" {
|
||||
Some(format!("qobuz:search:{}:all:{}", parts[2], parts[4]))
|
||||
// Résultat de recherche Qobuz : reconstruire le container virtuel parent
|
||||
// ex. "qobuz:search:catalog:albums:Beethoven" → "qobuz:search:catalog:all:Beethoven"
|
||||
format!("qobuz:search:{}:all:{}", parts[2], parts[4])
|
||||
} else {
|
||||
None
|
||||
// Résultat d'une autre source (ex. UrlSource) : retourner l'ID tel quel
|
||||
entry.id.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "search".to_string())
|
||||
|
||||
@@ -26,6 +26,7 @@ utoipa = { version = "5.3", optional = true }
|
||||
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
||||
pmoparadise = { path = "../pmoparadise", optional = true }
|
||||
pmoradiofrance = { path = "../pmoradiofrance", optional = true }
|
||||
pmourlsource = { path = "../pmourlsource", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
@@ -61,3 +62,5 @@ radiofrance = [
|
||||
"pmoradiofrance/logging",
|
||||
"dep:pmoconfig"
|
||||
]
|
||||
# Feature pour activer la source URL / Partage
|
||||
urlsource = ["api", "dep:pmourlsource"]
|
||||
|
||||
@@ -273,11 +273,32 @@ impl ContentHandler {
|
||||
}
|
||||
}
|
||||
BrowseResult::Items(items) => {
|
||||
if let Some(item) = items.first() {
|
||||
let didl = to_didl_lite(&[], &[item.clone()])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
// object_id is a container (album, playlist…) whose
|
||||
// browse() returns its children as Items. BrowseMetadata
|
||||
// must return the container itself, not the first child.
|
||||
let title = items
|
||||
.first()
|
||||
.and_then(|i| i.album.as_deref())
|
||||
.unwrap_or(object_id)
|
||||
.to_string();
|
||||
let album_art =
|
||||
items.first().and_then(|i| i.album_art.clone());
|
||||
let container = Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(items.len().to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
let didl = to_didl_lite(&[container], &[])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
if let Some(container) = containers.first() {
|
||||
@@ -615,16 +636,26 @@ impl ContentHandler {
|
||||
let mut all_containers = Vec::new();
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
// Si le texte ressemble à une URL, seules les sources qui gèrent les URLs
|
||||
// sont interrogées — les autres (Qobuz, etc.) interpréteraient l'URL comme
|
||||
// du texte libre et renverraient des résultats parasites.
|
||||
let is_url_query = text.starts_with("http://") || text.starts_with("https://");
|
||||
|
||||
for source in list_all_sources().await {
|
||||
if source.capabilities().supports_search {
|
||||
if let Ok(result) = source.search(&query).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(items);
|
||||
}
|
||||
let caps = source.capabilities();
|
||||
if !caps.supports_search {
|
||||
continue;
|
||||
}
|
||||
if is_url_query && !caps.handles_url_input {
|
||||
continue;
|
||||
}
|
||||
if let Ok(result) = source.search(&query).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ pub enum SourceInitError {
|
||||
#[error("Failed to initialize Radio France: {0}")]
|
||||
RadioFranceError(String),
|
||||
|
||||
#[cfg(feature = "urlsource")]
|
||||
#[error("Failed to initialize URL source: {0}")]
|
||||
UrlSourceError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
@@ -141,6 +145,14 @@ pub trait SourcesExt {
|
||||
/// ```
|
||||
#[cfg(feature = "radiofrance")]
|
||||
async fn register_radiofrance(&mut self) -> Result<()>;
|
||||
|
||||
/// Enregistre la source URL / Partage
|
||||
///
|
||||
/// Cette source permet de coller n'importe quelle URL (lien de partage Qobuz,
|
||||
/// flux audio, playlist M3U…) dans la barre de recherche et de lancer la lecture
|
||||
/// directement. Aucune authentification requise.
|
||||
#[cfg(feature = "urlsource")]
|
||||
async fn register_urlsource(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -281,6 +293,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)]
|
||||
|
||||
@@ -816,6 +816,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1925,6 +1925,69 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_container(&self, object_id: &str) -> Result<Option<pmodidl::Container>> {
|
||||
use crate::didl::ToDIDL;
|
||||
match self.parse_object_id(object_id) {
|
||||
ObjectIdType::Album(album_id) => {
|
||||
let album = self
|
||||
.inner
|
||||
.client
|
||||
.get_album(&album_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap();
|
||||
let container = album
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Playlist(playlist_id) => {
|
||||
let playlist = self
|
||||
.inner
|
||||
.client
|
||||
.get_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let container = playlist
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Artist(artist_id) => {
|
||||
// Pas d'endpoint artist direct — on tire le nom/image depuis les albums
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_artist_albums(&artist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let first = albums.first();
|
||||
let artist_name = first
|
||||
.map(|a| a.artist.name.clone())
|
||||
.unwrap_or_else(|| format!("Artiste {}", artist_id));
|
||||
let album_art = first.and_then(|a| a.image_cached.clone().or_else(|| a.image.clone()));
|
||||
let container = pmodidl::Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "qobuz".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(albums.len().to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist_name.clone(),
|
||||
class: "object.container.person.musicArtist".to_string(),
|
||||
artist: Some(artist_name),
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||
// Try cache manager first
|
||||
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
|
||||
@@ -2088,6 +2151,7 @@ impl MusicSource for QobuzSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: true,
|
||||
supports_pagination: true,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ impl MusicSource for RadioFranceSource {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct SourceCapabilities {
|
||||
pub supports_advanced_search: bool,
|
||||
/// Supports pagination in browse operations
|
||||
pub supports_pagination: bool,
|
||||
/// Handles URL input (http/https) instead of plain text search queries.
|
||||
/// When true, this source is called exclusively for URL-like search inputs.
|
||||
pub handles_url_input: bool,
|
||||
}
|
||||
|
||||
/// Audio format information
|
||||
@@ -464,6 +467,18 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Retourne les métadonnées d'un container (titre, artiste, cover, child_count)
|
||||
/// SANS charger ses enfants — un seul appel API léger.
|
||||
///
|
||||
/// Utilisé par UrlSource pour afficher un album/playlist en résultat de recherche
|
||||
/// avec les bonnes métadonnées et le bon `class` UPnP, avant que l'utilisateur
|
||||
/// ne navigue dedans ou ne lance la lecture.
|
||||
///
|
||||
/// L'implémentation par défaut retourne None (non supporté).
|
||||
async fn get_container(&self, _object_id: &str) -> Result<Option<Container>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve the actual URI for a track
|
||||
///
|
||||
/// This method should return the URI that can be used to stream/download
|
||||
@@ -650,6 +665,7 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
pmourlsource/Cargo.toml
Normal file
15
pmourlsource/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
|
||||
url = "2"
|
||||
BIN
pmourlsource/assets/url-source.webp
Normal file
BIN
pmourlsource/assets/url-source.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
122
pmourlsource/src/handler.rs
Normal file
122
pmourlsource/src/handler.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UrlResolverError {
|
||||
#[error("URL non reconnue : {0}")]
|
||||
NotSupported(String),
|
||||
#[error("Résolution échouée : {0}")]
|
||||
ResolutionFailed(String),
|
||||
#[error("URL bloquée (réseau privé/local)")]
|
||||
SsrfBlocked,
|
||||
}
|
||||
|
||||
/// Un track résolu depuis une source externe (RSS enclosure, audio direct…)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedTrack {
|
||||
/// URL directe de l'audio (jouable par le renderer)
|
||||
pub uri: String,
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<String>, // format "H:MM:SS.mmm" UPnP
|
||||
pub album_art: Option<String>,
|
||||
pub mime_type: String, // ex. "audio/mpeg", "audio/aac"
|
||||
}
|
||||
|
||||
impl ResolvedTrack {
|
||||
pub fn new(uri: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
uri: uri.into(),
|
||||
title: title.into(),
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: None,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contenu résolu depuis une URL externe
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante.
|
||||
/// La UrlSource retourne un stub container avec cet ID ; le content directory
|
||||
/// le route naturellement vers la source propriétaire lors du browse.
|
||||
SourceContainer {
|
||||
source_id: String,
|
||||
container_id: String,
|
||||
},
|
||||
|
||||
/// Liste ordonnée de tracks (RSS/podcast, M3U, PLS, XSPF…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
|
||||
/// Flux continu (radio, stream live)
|
||||
Stream {
|
||||
uri: String,
|
||||
title: String,
|
||||
mime_type: String,
|
||||
},
|
||||
|
||||
/// Track unique identifié directement
|
||||
Track(ResolvedTrack),
|
||||
}
|
||||
|
||||
/// Trait implémenté par chaque handler spécialisé (Qobuz, RadioFrance…)
|
||||
/// et par le handler générique de dernier recours.
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier. Défaut : 50.
|
||||
fn priority(&self) -> u8 {
|
||||
50
|
||||
}
|
||||
/// Filtre rapide sans I/O — simple test regex/contains sur l'URL.
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé).
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
|
||||
/// Registre ordonné de handlers. Les handlers sont triés par priorité décroissante.
|
||||
pub struct UrlResolver {
|
||||
handlers: Vec<Box<dyn UrlHandler>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UrlResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UrlResolver")
|
||||
.field("handlers", &format!("{} handlers", self.handlers.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlResolver {
|
||||
pub fn new() -> Self {
|
||||
Self { handlers: vec![] }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handler: Box<dyn UrlHandler>) {
|
||||
self.handlers.push(handler);
|
||||
self.handlers
|
||||
.sort_by(|a, b| b.priority().cmp(&a.priority()));
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
for handler in &self.handlers {
|
||||
if handler.can_handle(url) {
|
||||
return handler.resolve(url).await;
|
||||
}
|
||||
}
|
||||
Err(UrlResolverError::NotSupported(url.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UrlResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
573
pmourlsource/src/handlers/generic.rs
Normal file
573
pmourlsource/src/handlers/generic.rs
Normal file
@@ -0,0 +1,573 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{redirect, Client};
|
||||
|
||||
/// Handler générique de dernier recours — priorité 10.
|
||||
///
|
||||
/// Pipeline :
|
||||
/// 1. Garde-fou SSRF (rejette les IPs privées/locales)
|
||||
/// 2. GET avec suivi de redirections (max 5)
|
||||
/// 3. Détection par Content-Type :
|
||||
/// - audio/* → Stream direct
|
||||
/// - application/rss+xml, … → parse RSS/Atom → Playlist
|
||||
/// - .m3u / .pls / .xspf → parse playlist → Playlist
|
||||
/// 4. text/html → cherche :
|
||||
/// - <link type="application/rss+xml"> → fetch RSS → Playlist
|
||||
/// - <audio src="…">
|
||||
/// - og:audio / og:url audio
|
||||
pub struct GenericUrlHandler {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GenericUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("PMOMusic/1.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Rejette les URLs ciblant des réseaux privés/locaux (SSRF).
|
||||
fn is_safe_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
// Rejeter loopback, link-local, et RFC-1918
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return false;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return !ip.is_loopback() && !ip.is_unspecified() && is_public_ip(ip);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn fetch_and_resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
let final_url = resp.url().to_string();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
// Audio direct
|
||||
if content_type.starts_with("audio/") {
|
||||
let mime = content_type.split(';').next().unwrap_or("audio/mpeg").trim().to_string();
|
||||
let title = title_from_url(&final_url);
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: final_url,
|
||||
title,
|
||||
mime_type: mime,
|
||||
});
|
||||
}
|
||||
|
||||
// Playlist M3U
|
||||
if content_type.contains("mpegurl") || final_url.ends_with(".m3u") || final_url.ends_with(".m3u8") {
|
||||
return parse_m3u(&body, &final_url);
|
||||
}
|
||||
|
||||
// Playlist PLS
|
||||
if content_type.contains("scpls") || final_url.ends_with(".pls") {
|
||||
return parse_pls(&body, &final_url);
|
||||
}
|
||||
|
||||
// RSS / Atom / podcast
|
||||
if is_rss_content_type(&content_type) || final_url.ends_with(".xml") {
|
||||
return parse_rss(&body, &final_url);
|
||||
}
|
||||
|
||||
// HTML — chercher RSS link puis audio elements
|
||||
if content_type.starts_with("text/html") || content_type.is_empty() {
|
||||
return self.scrape_html(&body, &final_url).await;
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Content-Type non géré : {}",
|
||||
content_type
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scrape_html(&self, html: &str, base_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
// 1. Chercher un lien RSS (<link type="application/rss+xml" href="...">)
|
||||
if let Some(rss_url) = extract_rss_link(html, base_url) {
|
||||
tracing::debug!(rss_url = %rss_url, "HTML scraper found RSS feed");
|
||||
if Self::is_safe_url(&rss_url) {
|
||||
if let Ok(resp) = self.client.get(&rss_url).send().await {
|
||||
if let Ok(body) = resp.text().await {
|
||||
if let Ok(result) = parse_rss(&body, &rss_url) {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Chercher <audio src="...">
|
||||
if let Some(audio_url) = extract_audio_src(html, base_url) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found <audio>");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. og:audio
|
||||
if let Some(audio_url) = extract_og_audio(html) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found og:audio");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun contenu audio trouvé dans la page : {}",
|
||||
base_url
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GenericUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for GenericUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"GenericUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
10
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.starts_with("http://") || url.starts_with("https://")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
if !Self::is_safe_url(url) {
|
||||
return Err(UrlResolverError::SsrfBlocked);
|
||||
}
|
||||
self.fetch_and_resolve(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parseurs ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_date: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
// Parsing XML ligne par ligne — quick_xml non disponible ici,
|
||||
// on utilise une approche par extraction de patterns XML simples.
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_date = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
// Vérifier que c'est bien de l'audio
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<pubDate") {
|
||||
current_date = extract_xml_text(trimmed, "pubDate");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode audio dans le feed RSS : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_m3u(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut pending_title: Option<String> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line == "#EXTM3U" {
|
||||
continue;
|
||||
}
|
||||
if let Some(info) = line.strip_prefix("#EXTINF:") {
|
||||
// #EXTINF:<duration>,<title>
|
||||
let title = info.splitn(2, ',').nth(1).unwrap_or("").trim().to_string();
|
||||
if !title.is_empty() {
|
||||
pending_title = Some(title);
|
||||
}
|
||||
} else if !line.starts_with('#') {
|
||||
let title = pending_title.take().unwrap_or_else(|| title_from_url(line));
|
||||
items.push(ResolvedTrack::new(line, title));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("M3U vide".to_string()));
|
||||
}
|
||||
|
||||
if items.len() == 1 {
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: items.remove(0).uri,
|
||||
title: items.first().map(|t| t.title.clone()).unwrap_or_default(),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
fn parse_pls(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut uris: Vec<String> = Vec::new();
|
||||
let mut titles: Vec<String> = Vec::new();
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.to_lowercase().strip_prefix("file") {
|
||||
if let Some(url) = rest.splitn(2, '=').nth(1) {
|
||||
uris.push(url.trim().to_string());
|
||||
}
|
||||
} else if let Some(rest) = line.to_lowercase().strip_prefix("title") {
|
||||
if let Some(t) = rest.splitn(2, '=').nth(1) {
|
||||
titles.push(t.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uris.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("PLS vide".to_string()));
|
||||
}
|
||||
|
||||
let items: Vec<ResolvedTrack> = uris
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, uri)| {
|
||||
let title = titles.get(i).cloned().unwrap_or_else(|| title_from_url(&uri));
|
||||
ResolvedTrack::new(uri, title)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if items.len() == 1 {
|
||||
let item = items.into_iter().next().unwrap();
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: item.uri,
|
||||
title: item.title,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
// ── Utilitaires d'extraction HTML/XML ───────────────────────────────────────
|
||||
|
||||
fn extract_rss_link(html: &str, base_url: &str) -> Option<String> {
|
||||
// <link ... type="application/rss+xml" ... href="URL" ...>
|
||||
// ou <link ... href="URL" ... type="application/rss+xml" ...>
|
||||
let lower = html.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(start) = lower[pos..].find("<link") {
|
||||
let start = pos + start;
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
let tag_lower = &lower[start..end];
|
||||
|
||||
if tag_lower.contains("application/rss+xml") || tag_lower.contains("application/atom+xml") {
|
||||
if let Some(href) = extract_attr(tag, "href") {
|
||||
return Some(resolve_url(base_url, &href));
|
||||
}
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_audio_src(html: &str, base_url: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
if let Some(start) = lower.find("<audio") {
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
if let Some(src) = extract_attr(tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
// <source src="..."> inside <audio>
|
||||
let after = &html[end..];
|
||||
let lower_after = after.to_lowercase();
|
||||
if let Some(src_start) = lower_after.find("<source") {
|
||||
let src_end = after[src_start..].find('>').map(|e| src_start + e + 1).unwrap_or(after.len());
|
||||
let src_tag = &after[src_start..src_end];
|
||||
if let Some(src) = extract_attr(src_tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_og_audio(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:audio")
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extrait la valeur d'un attribut HTML/XML depuis une balise.
|
||||
/// Gère les guillemets simples, doubles et sans guillemets.
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le contenu texte d'un élément XML simple sur une seule ligne.
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
// Chercher <tag> ou <tag ...>
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
// Avancer jusqu'à la fermeture de la balise ouvrante
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Résout une URL relative par rapport à une base.
|
||||
fn resolve_url(base: &str, target: &str) -> String {
|
||||
if target.starts_with("http://") || target.starts_with("https://") {
|
||||
return target.to_string();
|
||||
}
|
||||
if target.starts_with("//") {
|
||||
let scheme = if base.starts_with("https") { "https" } else { "http" };
|
||||
return format!("{}:{}", scheme, target);
|
||||
}
|
||||
if let Ok(base_url) = url::Url::parse(base) {
|
||||
if let Ok(resolved) = base_url.join(target) {
|
||||
return resolved.to_string();
|
||||
}
|
||||
}
|
||||
target.to_string()
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
fn title_from_url(url: &str) -> String {
|
||||
url.rsplit('/')
|
||||
.find(|s| !s.is_empty())
|
||||
.unwrap_or(url)
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.replace(['-', '_'], " ")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Détermine si le Content-Type est RSS/Atom.
|
||||
fn is_rss_content_type(ct: &str) -> bool {
|
||||
ct.contains("rss") || ct.contains("atom") || ct.contains("xml")
|
||||
}
|
||||
|
||||
/// Convertit une durée iTunes ("HH:MM:SS" ou "MM:SS" ou secondes) en format UPnP ("H:MM:SS.000").
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
// Secondes brutes
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
return format!("0:{}.000", d);
|
||||
}
|
||||
3 => {
|
||||
return format!("{}.000", d);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Vérifie qu'une IP est publique (non privée, non loopback, non link-local).
|
||||
fn is_public_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
!v4.is_private()
|
||||
&& !v4.is_loopback()
|
||||
&& !v4.is_link_local()
|
||||
&& !v4.is_broadcast()
|
||||
&& !v4.is_documentation()
|
||||
&& !v4.is_unspecified()
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
!v6.is_loopback() && !v6.is_unspecified() && !is_v6_link_local(v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_v6_link_local(ip: std::net::Ipv6Addr) -> bool {
|
||||
// fe80::/10
|
||||
ip.segments()[0] & 0xffc0 == 0xfe80
|
||||
}
|
||||
3
pmourlsource/src/handlers/mod.rs
Normal file
3
pmourlsource/src/handlers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod generic;
|
||||
pub mod qobuz;
|
||||
pub mod radiofrance;
|
||||
138
pmourlsource/src/handlers/qobuz.rs
Normal file
138
pmourlsource/src/handlers/qobuz.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::handler::{ResolvedContent, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Résout les URLs de partage Qobuz vers des container_ids natifs.
|
||||
///
|
||||
/// Supporte open.qobuz.com et play.qobuz.com.
|
||||
/// Les IDs peuvent être alphanumériques pour tous les types (album, track, playlist, artist).
|
||||
///
|
||||
/// Exemples :
|
||||
/// https://open.qobuz.com/album/l46fxnqnxp5vs → qobuz:album:l46fxnqnxp5vs
|
||||
/// https://open.qobuz.com/track/48471123 → qobuz:track:48471123
|
||||
/// https://open.qobuz.com/playlist/63246908 → qobuz:playlist:63246908
|
||||
/// https://open.qobuz.com/artist/125709 → qobuz:artist:125709
|
||||
pub struct QobuzUrlHandler;
|
||||
|
||||
impl QobuzUrlHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn parse(&self, url: &str) -> Option<(String, String)> {
|
||||
// Localiser "qobuz.com/" dans l'URL
|
||||
let after_domain = url.find("qobuz.com/").map(|i| &url[i + "qobuz.com".len()..])?;
|
||||
|
||||
// after_domain commence par "/"
|
||||
let path = after_domain.trim_start_matches('/');
|
||||
let mut parts = path.splitn(3, '/');
|
||||
|
||||
let type_ = parts.next().unwrap_or("");
|
||||
let id_raw = parts.next().unwrap_or("");
|
||||
// Supprimer les query params éventuels (#, ?)
|
||||
let id = id_raw.split('?').next().unwrap_or(id_raw);
|
||||
let id = id.split('#').next().unwrap_or(id);
|
||||
|
||||
match type_ {
|
||||
"album" | "track" | "playlist" | "artist" => {
|
||||
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
Some((type_.to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QobuzUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for QobuzUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"QobuzUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
90
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("qobuz.com/")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let (type_, id) = self
|
||||
.parse(url)
|
||||
.ok_or_else(|| UrlResolverError::NotSupported(url.to_string()))?;
|
||||
|
||||
let container_id = format!("qobuz:{}:{}", type_, id);
|
||||
|
||||
tracing::debug!(
|
||||
url = %url,
|
||||
container_id = %container_id,
|
||||
"QobuzUrlHandler resolved"
|
||||
);
|
||||
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz".to_string(),
|
||||
container_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_album_alphanumeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_track_numeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/track/48471123")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:track:48471123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_play_subdomain() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://play.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_type_rejected() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h.resolve("https://open.qobuz.com/label/123").await;
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_handle() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
assert!(h.can_handle("https://open.qobuz.com/album/abc"));
|
||||
assert!(!h.can_handle("https://www.spotify.com/album/abc"));
|
||||
}
|
||||
}
|
||||
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
use reqwest::{redirect, Client};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Handler dédié aux URLs radiofrance.fr — priorité 80.
|
||||
///
|
||||
/// RadioFrance utilise SvelteKit (SSR).
|
||||
///
|
||||
/// Stratégie selon le type d'URL :
|
||||
///
|
||||
/// 1. **Page podcast** (`/podcasts/{slug}`)
|
||||
/// → `rssFeed:"https://..."` inline → fetch + parse RSS
|
||||
///
|
||||
/// 2. **Page série** (`/podcasts/serie-{slug}`)
|
||||
/// → JSON-LD `ItemList` → extraire les URLs d'épisodes → fetch concurrent
|
||||
/// (RadioFrance limite leur RSS à 2 éléments ; scraping direct donne tous les épisodes)
|
||||
///
|
||||
/// 3. **Page épisode** (`/podcasts/{podcast}/{episode}-{id}`)
|
||||
/// → URL MP3 `media.radiofrance-podcast.net` inline
|
||||
pub struct RadioFranceUrlHandler {
|
||||
client: Arc<Client>,
|
||||
}
|
||||
|
||||
impl RadioFranceUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client: Arc::new(client) })
|
||||
}
|
||||
|
||||
async fn resolve_inner(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let html = self.fetch_html(url).await?;
|
||||
|
||||
// --- Cas 1 : page podcast → rssFeed non vide ---
|
||||
if let Some(rss_url) = extract_rss_feed_key(&html) {
|
||||
tracing::debug!(rss_url = %rss_url, "RadioFrance: rssFeed trouvé");
|
||||
return self.fetch_rss(&rss_url).await;
|
||||
}
|
||||
|
||||
// --- Cas 2 : page série → fetch concurrent des pages épisodes ---
|
||||
let episode_urls = extract_episode_urls_from_series(&html, url);
|
||||
if !episode_urls.is_empty() {
|
||||
tracing::debug!(
|
||||
count = episode_urls.len(),
|
||||
"RadioFrance: série — fetch concurrent des épisodes"
|
||||
);
|
||||
let feed_title = extract_og_title(&html);
|
||||
let feed_image = extract_og_image(&html);
|
||||
let album = feed_title.clone().or_else(|| extract_title_tag(&html));
|
||||
|
||||
let client = self.client.clone();
|
||||
let fetches: Vec<_> = episode_urls
|
||||
.into_iter()
|
||||
.map(|ep_url| {
|
||||
let client = client.clone();
|
||||
let album = album.clone();
|
||||
let feed_image = feed_image.clone();
|
||||
async move {
|
||||
match fetch_html_with_client(&client, &ep_url).await {
|
||||
Ok(ep_html) => episode_to_track(&ep_html, &ep_url, album.as_deref(), feed_image.as_deref()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tracks: Vec<ResolvedTrack> = join_all(fetches).await.into_iter().flatten().collect();
|
||||
|
||||
if !tracks.is_empty() {
|
||||
return Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items: tracks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cas 3 : page épisode → MP3 direct ---
|
||||
if let Some(mp3_url) = extract_mp3_url(&html) {
|
||||
tracing::debug!(mp3_url = %mp3_url, "RadioFrance: MP3 direct trouvé");
|
||||
let title = extract_og_title(&html)
|
||||
.or_else(|| extract_title_tag(&html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(&html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun podcast/épisode trouvé sur la page RadioFrance : {}",
|
||||
url
|
||||
)))
|
||||
}
|
||||
|
||||
async fn fetch_html(&self, url: &str) -> Result<String, UrlResolverError> {
|
||||
fetch_html_with_client(&self.client, url).await
|
||||
}
|
||||
|
||||
async fn fetch_rss(&self, rss_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let body = self
|
||||
.client
|
||||
.get(rss_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(format!("RSS fetch : {}", e)))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
parse_rss(&body, rss_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RadioFranceUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client for RadioFranceUrlHandler")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for RadioFranceUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"RadioFranceUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("radiofrance.fr")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
self.resolve_inner(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_html_with_client(client: &Client, url: &str) -> Result<String, UrlResolverError> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "fr-FR,fr;q=0.9")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))
|
||||
}
|
||||
|
||||
// ── Extraction SvelteKit ─────────────────────────────────────────────────────
|
||||
|
||||
/// Cherche `rssFeed:"https://..."` dans le JS SvelteKit inline.
|
||||
/// Retourne None si le champ est absent ou vide.
|
||||
fn extract_rss_feed_key(html: &str) -> Option<String> {
|
||||
let needle = "rssFeed:\"https://";
|
||||
let pos = html.find(needle)?;
|
||||
let start = pos + "rssFeed:\"".len();
|
||||
let end = html[start..].find('"')? + start;
|
||||
let url = html[start..end].replace("\\/", "/");
|
||||
if url.is_empty() || !url.starts_with("http") {
|
||||
None
|
||||
} else {
|
||||
Some(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait toutes les URLs d'épisodes depuis le JSON-LD `ItemList` d'une page série.
|
||||
///
|
||||
/// Filtre les URLs non-épisodes (série elle-même, images, domaine seul…).
|
||||
/// Une URL d'épisode a exactement 4 segments de path :
|
||||
/// `/{station}/podcasts/{podcast-slug}/{episode-slug}`
|
||||
fn extract_episode_urls_from_series(html: &str, series_url: &str) -> Vec<String> {
|
||||
let item_marker = "\"@type\":\"ItemList\"";
|
||||
let list_pos = match html.find(item_marker) {
|
||||
Some(p) => p,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let url_prefix = "\"url\":\"https://www.radiofrance.fr/";
|
||||
let after_list = &html[list_pos..];
|
||||
let mut urls = Vec::new();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(rel_pos) = after_list[search_from..].find(url_prefix) {
|
||||
let rel_pos = search_from + rel_pos;
|
||||
let from = list_pos + rel_pos + "\"url\":\"".len();
|
||||
let Some(end_rel) = html[from..].find('"') else { break };
|
||||
let candidate = &html[from..from + end_rel];
|
||||
|
||||
if is_episode_url(candidate, series_url) {
|
||||
urls.push(candidate.to_string());
|
||||
}
|
||||
search_from = rel_pos + url_prefix.len();
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// Retourne true si l'URL est bien une page d'épisode (≥4 segments de path).
|
||||
fn is_episode_url(url: &str, series_url: &str) -> bool {
|
||||
if url.trim_end_matches('/') == series_url.trim_end_matches('/') {
|
||||
return false;
|
||||
}
|
||||
if !url.contains("/podcasts/") {
|
||||
return false;
|
||||
}
|
||||
// Exclure fichiers statiques (images…)
|
||||
let last = url.rsplit('/').next().unwrap_or("");
|
||||
if last.contains('.') {
|
||||
return false;
|
||||
}
|
||||
// Doit avoir ≥ 4 segments après le domaine : /station/podcasts/podcast/episode
|
||||
let path_segments: usize = url
|
||||
.splitn(4, "radiofrance.fr")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.count();
|
||||
path_segments >= 4
|
||||
}
|
||||
|
||||
/// Extrait un `ResolvedTrack` depuis la page HTML d'un épisode RadioFrance.
|
||||
fn episode_to_track(html: &str, url: &str, album: Option<&str>, feed_image: Option<&str>) -> Option<ResolvedTrack> {
|
||||
let mp3_url = extract_mp3_url(html)?;
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
let album_art = extract_og_image(html).or_else(|| feed_image.map(|s| s.to_string()));
|
||||
Some(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: album.map(|s| s.to_string()),
|
||||
duration: None,
|
||||
album_art,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Extrait l'URL du premier fichier MP3 hébergé sur media.radiofrance-podcast.net.
|
||||
fn extract_mp3_url(html: &str) -> Option<String> {
|
||||
let needle = "https://media.radiofrance-podcast.net/";
|
||||
let pos = html.find(needle)?;
|
||||
let end = html[pos..].find(|c: char| c == '"' || c == '\'' || c.is_whitespace())? + pos;
|
||||
let url = html[pos..end].to_string();
|
||||
if url.ends_with(".mp3") || url.contains(".mp3?") || url.contains("ITEMA_") {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── RSS parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode dans le feed RSS RadioFrance : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Utilitaires HTML ─────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => return format!("0:{}.000", d),
|
||||
3 => return format!("{}.000", d),
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
9
pmourlsource/src/lib.rs
Normal file
9
pmourlsource/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod handler;
|
||||
pub mod handlers;
|
||||
pub mod source;
|
||||
|
||||
pub use handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolver, UrlResolverError};
|
||||
pub use handlers::generic::GenericUrlHandler;
|
||||
pub use handlers::qobuz::QobuzUrlHandler;
|
||||
pub use handlers::radiofrance::RadioFranceUrlHandler;
|
||||
pub use source::UrlSource;
|
||||
340
pmourlsource/src/source.rs
Normal file
340
pmourlsource/src/source.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmosource::api::get_source as get_source_from_registry;
|
||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
||||
|
||||
/// Store éphémère pour les playlists URL : playlist_id → (container, items)
|
||||
type PlaylistStore = Arc<RwLock<HashMap<String, (Container, Vec<Item>)>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver,
|
||||
base_url: String,
|
||||
playlists: PlaylistStore,
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
pub fn new(resolver: UrlResolver, base_url: String) -> Self {
|
||||
Self {
|
||||
resolver,
|
||||
base_url,
|
||||
playlists: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for UrlSource {
|
||||
fn name(&self) -> &str {
|
||||
"URL / Partage"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"url"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> SourceCapabilities {
|
||||
SourceCapabilities {
|
||||
supports_search: true,
|
||||
handles_url_input: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> pmosource::Result<Container> {
|
||||
Ok(Container {
|
||||
id: "url".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: "URL / Partage".to_string(),
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
||||
match object_id {
|
||||
"url" => Ok(BrowseResult::Containers(vec![])),
|
||||
// Playlist éphémère créée par build_url_playlist
|
||||
_ if object_id.starts_with("urlsource-") => {
|
||||
let store = self.playlists.read().map_err(|_| {
|
||||
MusicSourceError::BrowseError("playlist store lock poisoned".to_string())
|
||||
})?;
|
||||
match store.get(object_id) {
|
||||
Some((_, items)) => Ok(BrowseResult::Items(items.clone())),
|
||||
None => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
// Court-circuiter les IDs "url:*" pour éviter des erreurs dans les logs
|
||||
// des autres sources (items éphémères non persistables par ID).
|
||||
_ if object_id.starts_with("url:") => {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: &SearchQuery) -> pmosource::Result<BrowseResult> {
|
||||
let url = query.text.trim();
|
||||
|
||||
if url.is_empty() {
|
||||
return Ok(BrowseResult::Containers(vec![]));
|
||||
}
|
||||
|
||||
match self.resolver.resolve(url).await {
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id,
|
||||
container_id,
|
||||
}) => {
|
||||
if let Some(source) = get_source_from_registry(&source_id).await {
|
||||
match source.get_container(&container_id).await {
|
||||
Ok(Some(mut container)) => {
|
||||
container.parent_id = source_id;
|
||||
return Ok(BrowseResult::Containers(vec![container]));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_id = %source_id,
|
||||
container_id = %container_id,
|
||||
error = %e,
|
||||
"UrlSource: get_container échoué"
|
||||
);
|
||||
}
|
||||
}
|
||||
match source.get_item(&container_id).await {
|
||||
Ok(item) => return Ok(BrowseResult::Items(vec![item])),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let title = display_title_for_url(url);
|
||||
let container = Container {
|
||||
id: container_id,
|
||||
parent_id: source_id,
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some("1".to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: playlist_title, items }) => {
|
||||
let title = playlist_title.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, items);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Track(t)) => {
|
||||
// Pour un épisode unique, créer une playlist avec 1 item.
|
||||
// Titre de la playlist = nom du podcast (album) ou titre de l'épisode.
|
||||
let title = t.album.clone()
|
||||
.or_else(|| Some(t.title.clone()))
|
||||
.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, vec![t]);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Stream { uri, title, mime_type }) => {
|
||||
let item = stream_to_item(uri, title, mime_type);
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(_)) => {
|
||||
Ok(BrowseResult::Containers(vec![]))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(url = %url, error = %e, "UrlSource: résolution échouée");
|
||||
Err(MusicSourceError::BrowseError(format!(
|
||||
"Résolution URL échouée : {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
|
||||
fn supports_fifo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn last_change(&self) -> Option<SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
/// Crée un container playlist éphémère en mémoire depuis des tracks résolus.
|
||||
///
|
||||
/// Les items gardent leurs URLs directes (RadioFrance, etc.) et leur MIME type
|
||||
/// d'origine — pas de proxy via pmoaudiocache, donc pas de conversion FLAC
|
||||
/// et pas de problème avec les formats M4A/AAC.
|
||||
fn build_url_playlist(&self, url: &str, title: String, tracks: Vec<ResolvedTrack>) -> Container {
|
||||
let playlist_id = format!("urlsource-{:016x}", url_hash(url));
|
||||
let n = tracks.len();
|
||||
|
||||
// Cover = album_art du premier épisode
|
||||
let album_art = tracks.first().and_then(|t| t.album_art.clone());
|
||||
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
||||
Item {
|
||||
id: format!("{}:{}", playlist_id, i),
|
||||
parent_id: playlist_id.clone(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: t.title,
|
||||
creator: t.artist.clone(),
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: t.artist,
|
||||
album: t.album.or_else(|| Some(title.clone())),
|
||||
genre: None,
|
||||
album_art: t.album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: Some(format!("{}", i + 1)),
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: t.duration,
|
||||
url: t.uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let container = Container {
|
||||
id: playlist_id.clone(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(n.to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title: title.clone(),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
|
||||
// Stocker dans le store éphémère (écrase toute entrée précédente)
|
||||
if let Ok(mut store) = self.playlists.write() {
|
||||
store.insert(playlist_id, (container.clone(), items));
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un flux continu en `pmodidl::Item`.
|
||||
fn stream_to_item(uri: String, title: String, mime_type: String) -> Item {
|
||||
let protocol_info = format!("http-get:*:{}:*", mime_type);
|
||||
Item {
|
||||
id: "url:item:0".to_string(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title,
|
||||
creator: None,
|
||||
class: "object.item.audioItem.audioBroadcast".to_string(),
|
||||
artist: None,
|
||||
album: None,
|
||||
genre: None,
|
||||
album_art: None,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: None,
|
||||
url: uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
fn display_title_for_url(url: &str) -> String {
|
||||
let host = url
|
||||
.find("://")
|
||||
.and_then(|i| {
|
||||
let after = &url[i + 3..];
|
||||
let end = after.find('/').unwrap_or(after.len());
|
||||
Some(&after[..end])
|
||||
})
|
||||
.unwrap_or("");
|
||||
|
||||
let type_label = if url.contains("/album/") {
|
||||
"Album"
|
||||
} else if url.contains("/track/") {
|
||||
"Titre"
|
||||
} else if url.contains("/playlist/") {
|
||||
"Playlist"
|
||||
} else if url.contains("/artist/") {
|
||||
"Artiste"
|
||||
} else {
|
||||
"Contenu"
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
type_label.to_string()
|
||||
} else {
|
||||
format!("{} ({})", type_label, host)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stable d'une URL pour construire un ID de playlist déterministe.
|
||||
fn url_hash(url: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.53
|
||||
0.3.54
|
||||
|
||||
Reference in New Issue
Block a user