des debug mais je ne sais plus de quoi
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3372,6 +3372,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
|
"futures",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"pmoaudiocache",
|
"pmoaudiocache",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
@@ -3384,6 +3385,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|||||||
285
PLAYER_PMOSOURCE_README.md
Normal file
285
PLAYER_PMOSOURCE_README.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Player Générique PMO Music
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
|
||||||
|
|
||||||
|
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Endpoints utilisés
|
||||||
|
GET /api/sources // Liste les sources
|
||||||
|
GET /api/sources/{id} // Info sur une source
|
||||||
|
GET /api/sources/{id}/root // Container racine
|
||||||
|
GET /api/sources/{id}/browse // Parcourt un container
|
||||||
|
GET /api/sources/{id}/resolve // Résout l'URI d'un item
|
||||||
|
GET /api/sources/{id}/image // Image de la source
|
||||||
|
GET /api/sources/{id}/capabilities // Capacités de la source
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonctions implémentées :**
|
||||||
|
- `listSources()` - Liste toutes les sources enregistrées
|
||||||
|
- `getSource(id)` - Récupère une source spécifique
|
||||||
|
- `getSourceRoot(id)` - Récupère le container racine
|
||||||
|
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
|
||||||
|
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
|
||||||
|
- `getSourceImageUrl(id)` - URL de l'image de la source
|
||||||
|
|
||||||
|
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
|
||||||
|
|
||||||
|
Composant Vue.js qui implémente :
|
||||||
|
|
||||||
|
#### Fonctionnalités implémentées
|
||||||
|
|
||||||
|
1. **Sélection de sources**
|
||||||
|
- Affichage de toutes les sources disponibles
|
||||||
|
- Affichage du logo de chaque source
|
||||||
|
- Affichage des capacités (FIFO, Search, Favorites)
|
||||||
|
|
||||||
|
2. **Navigation dans les containers**
|
||||||
|
- Breadcrumb pour remonter dans la hiérarchie
|
||||||
|
- Affichage des sous-containers (dossiers)
|
||||||
|
- Navigation par clic dans les containers
|
||||||
|
|
||||||
|
3. **Liste des morceaux**
|
||||||
|
- Affichage de tous les items audio d'un container
|
||||||
|
- Métadonnées : titre, artiste, album, cover art
|
||||||
|
- Numérotation des morceaux
|
||||||
|
|
||||||
|
4. **Lecteur audio**
|
||||||
|
- Lecture d'un morceau via résolution d'URI
|
||||||
|
- Contrôles audio natifs HTML5
|
||||||
|
- Section "Now Playing" avec métadonnées
|
||||||
|
- Gestion des erreurs de lecture
|
||||||
|
|
||||||
|
5. **Interface utilisateur**
|
||||||
|
- Design moderne avec dégradés et animations
|
||||||
|
- Responsive design
|
||||||
|
- Indicateurs visuels (morceau actif, en cours de lecture)
|
||||||
|
- Messages d'erreur clairs
|
||||||
|
|
||||||
|
### 3. Intégration
|
||||||
|
|
||||||
|
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// router/index.ts
|
||||||
|
const routes = [
|
||||||
|
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||||
|
// ... autres routes
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ce qui fonctionne
|
||||||
|
|
||||||
|
✅ **Complètement fonctionnel avec l'API actuelle de pmosource :**
|
||||||
|
|
||||||
|
1. Découverte des sources disponibles
|
||||||
|
2. Navigation complète dans la hiérarchie des containers
|
||||||
|
3. Affichage des métadonnées des morceaux
|
||||||
|
4. Résolution des URIs et lecture audio
|
||||||
|
5. Affichage des images de sources
|
||||||
|
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
|
||||||
|
- Mise à jour automatique toutes les 3 secondes
|
||||||
|
- Pas de polling, push serveur
|
||||||
|
- Reconnexion automatique
|
||||||
|
|
||||||
|
## Limitations identifiées et améliorations possibles
|
||||||
|
|
||||||
|
### 1. Métadonnées de couverture d'album
|
||||||
|
|
||||||
|
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Le champ `album_art` dans `Item` contient parfois une URI
|
||||||
|
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
|
||||||
|
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Résout l'URI de la couverture d'album pour un item
|
||||||
|
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Recherche globale
|
||||||
|
|
||||||
|
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas d'interface standardisée pour la recherche dans l'UI
|
||||||
|
- Pas de retour clair sur les capacités de recherche
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
|
||||||
|
- Documenter clairement le format attendu des requêtes de recherche
|
||||||
|
|
||||||
|
### 3. Pagination
|
||||||
|
|
||||||
|
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
|
||||||
|
- Pas de méthode `get_total_count(object_id)` dans le trait
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Retourne le nombre total d'items dans un container
|
||||||
|
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou ajouter `total_available` dans `BrowseResponse` :
|
||||||
|
```rust
|
||||||
|
pub struct SourceBrowseResponse {
|
||||||
|
// ... champs existants
|
||||||
|
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
|
||||||
|
|
||||||
|
**Solution implémentée :**
|
||||||
|
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
|
||||||
|
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
|
||||||
|
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
|
||||||
|
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
|
||||||
|
|
||||||
|
**Comment ça fonctionne :**
|
||||||
|
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
|
||||||
|
2. Le client se connecte avec `EventSource` (API browser native)
|
||||||
|
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
|
||||||
|
|
||||||
|
**Pour RadioParadise :**
|
||||||
|
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
|
||||||
|
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
|
||||||
|
|
||||||
|
### 5. Playlists utilisateur
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas encore testé dans le player
|
||||||
|
- Nécessiterait une UI dédiée
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Créer une section "Playlists" dans le player
|
||||||
|
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
|
||||||
|
|
||||||
|
### 6. Favoris
|
||||||
|
|
||||||
|
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
|
||||||
|
- Afficher visuellement les morceaux favoris
|
||||||
|
- Créer une section "Mes Favoris"
|
||||||
|
|
||||||
|
### 7. Auto-play / Queue
|
||||||
|
|
||||||
|
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Interface pour gérer une queue de lecture
|
||||||
|
pub trait Playable: MusicSource {
|
||||||
|
async fn get_next_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn get_previous_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn add_to_queue(&self, item: Item) -> Result<()>;
|
||||||
|
async fn clear_queue(&self) -> Result<()>;
|
||||||
|
async fn get_queue(&self) -> Result<Vec<Item>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Durée totale d'un container
|
||||||
|
|
||||||
|
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Statistiques d'un container spécifique
|
||||||
|
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
|
||||||
|
|
||||||
|
pub struct ContainerStats {
|
||||||
|
pub item_count: usize,
|
||||||
|
pub total_duration_ms: Option<u64>,
|
||||||
|
pub total_size_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Formats audio disponibles
|
||||||
|
|
||||||
|
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un sélecteur de qualité dans le player
|
||||||
|
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
|
||||||
|
|
||||||
|
### 10. État du cache
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Afficher un indicateur de cache sur chaque morceau
|
||||||
|
- Bouton "📥 Télécharger" pour mettre en cache
|
||||||
|
- Barre de progression pour le téléchargement
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
### Court terme
|
||||||
|
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
|
||||||
|
2. 🔄 Identifier les bugs et limitations pratiques
|
||||||
|
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
|
||||||
|
2. Ajouter la gestion de queue et auto-play
|
||||||
|
3. Ajouter la recherche si supportée
|
||||||
|
4. Intégrer la gestion du cache
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
1. Support des playlists utilisateur
|
||||||
|
2. Support des favoris
|
||||||
|
3. Égaliseur et effets audio
|
||||||
|
4. Visualisations audio
|
||||||
|
5. Mode hors-ligne avec cache
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
|
||||||
|
|
||||||
|
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
|
||||||
|
2. **La pagination avancée** (pas de compte total)
|
||||||
|
3. **Les métadonnées temps réel** (pour les streams live)
|
||||||
|
4. **La gestion de queue** (pas d'API dédiée)
|
||||||
|
|
||||||
|
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
Pour tester le player :
|
||||||
|
|
||||||
|
1. Lancer le serveur backend avec au moins une source enregistrée :
|
||||||
|
```bash
|
||||||
|
cargo run --example single_channel_server --features full
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Accéder à l'application web :
|
||||||
|
```
|
||||||
|
http://localhost:8080/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
|
||||||
|
|
||||||
|
## Remarques importantes
|
||||||
|
|
||||||
|
- ✅ Le player **n'utilise QUE l'API pmosource générique**
|
||||||
|
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
|
||||||
|
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
|
||||||
|
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)
|
||||||
1157
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
1157
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import HelloWorld from "../components/HelloWorld.vue";
|
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||||
import LogView from "../components/LogView.vue";
|
import LogView from "../components/LogView.vue";
|
||||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||||
@@ -8,7 +8,7 @@ import APIDashboard from "../components/APIDashboard.vue";
|
|||||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: HelloWorld },
|
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||||
{ path: "/logs", name: "logs", component: LogView },
|
{ path: "/logs", name: "logs", component: LogView },
|
||||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
||||||
|
|||||||
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Service pour interagir avec l'API pmosource générique
|
||||||
|
*
|
||||||
|
* Ce service utilise uniquement l'API REST définie dans pmosource::api
|
||||||
|
* et ne dépend d'aucune implémentation spécifique (comme pmoparadise)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api/sources'
|
||||||
|
|
||||||
|
// Types correspondant aux structures de l'API pmosource
|
||||||
|
|
||||||
|
export interface SourceInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
supports_fifo: boolean
|
||||||
|
capabilities: SourceCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceCapabilities {
|
||||||
|
supports_search: boolean
|
||||||
|
supports_favorites: boolean
|
||||||
|
supports_playlists: boolean
|
||||||
|
supports_user_content: boolean
|
||||||
|
supports_high_res_audio: boolean
|
||||||
|
max_sample_rate: number | null
|
||||||
|
supports_multiple_formats: boolean
|
||||||
|
supports_advanced_search: boolean
|
||||||
|
supports_pagination: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourcesList {
|
||||||
|
count: number
|
||||||
|
sources: SourceInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseContainer {
|
||||||
|
id: string
|
||||||
|
parent_id: string
|
||||||
|
title: string
|
||||||
|
class: string
|
||||||
|
child_count: string | null
|
||||||
|
restricted: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseItemResource {
|
||||||
|
url: string
|
||||||
|
protocol_info: string
|
||||||
|
duration: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseItem {
|
||||||
|
id: string
|
||||||
|
parent_id: string
|
||||||
|
title: string
|
||||||
|
class: string
|
||||||
|
artist: string | null
|
||||||
|
album: string | null
|
||||||
|
creator: string | null
|
||||||
|
album_art: string | null
|
||||||
|
resources: BrowseItemResource[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseResponse {
|
||||||
|
object_id: string
|
||||||
|
containers: BrowseContainer[]
|
||||||
|
items: BrowseItem[]
|
||||||
|
returned_containers: number
|
||||||
|
returned_items: number
|
||||||
|
total: number
|
||||||
|
update_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveUriResponse {
|
||||||
|
object_id: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRootContainer {
|
||||||
|
id: string
|
||||||
|
parent_id: string
|
||||||
|
title: string
|
||||||
|
class: string
|
||||||
|
child_count: string | null
|
||||||
|
searchable: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste toutes les sources musicales enregistrées
|
||||||
|
*/
|
||||||
|
export async function listSources(): Promise<SourcesList> {
|
||||||
|
const response = await fetch(`${API_BASE}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to list sources: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les informations d'une source spécifique
|
||||||
|
*/
|
||||||
|
export async function getSource(sourceId: string): Promise<SourceInfo> {
|
||||||
|
const response = await fetch(`${API_BASE}/${sourceId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get source: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le container racine d'une source
|
||||||
|
*/
|
||||||
|
export async function getSourceRoot(sourceId: string): Promise<SourceRootContainer> {
|
||||||
|
const response = await fetch(`${API_BASE}/${sourceId}/root`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get source root: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parcourt un container d'une source
|
||||||
|
*
|
||||||
|
* @param sourceId - ID de la source
|
||||||
|
* @param objectId - ID de l'objet à parcourir (optionnel, par défaut utilise la racine)
|
||||||
|
* @param startingIndex - Index de départ pour la pagination
|
||||||
|
* @param requestedCount - Nombre d'éléments demandés
|
||||||
|
*/
|
||||||
|
export async function browseSource(
|
||||||
|
sourceId: string,
|
||||||
|
objectId?: string,
|
||||||
|
startingIndex?: number,
|
||||||
|
requestedCount?: number
|
||||||
|
): Promise<BrowseResponse> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (objectId) params.set('object_id', objectId)
|
||||||
|
if (startingIndex !== undefined) params.set('starting_index', startingIndex.toString())
|
||||||
|
if (requestedCount !== undefined) params.set('requested_count', requestedCount.toString())
|
||||||
|
|
||||||
|
const url = `${API_BASE}/${sourceId}/browse?${params.toString()}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to browse source: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout l'URI réelle d'un objet (pour le streaming)
|
||||||
|
*
|
||||||
|
* @param sourceId - ID de la source
|
||||||
|
* @param objectId - ID de l'objet à résoudre
|
||||||
|
*/
|
||||||
|
export async function resolveUri(sourceId: string, objectId: string): Promise<ResolveUriResponse> {
|
||||||
|
const params = new URLSearchParams({ object_id: objectId })
|
||||||
|
const url = `${API_BASE}/${sourceId}/resolve?${params.toString()}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to resolve URI: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'URL de l'image par défaut d'une source
|
||||||
|
*
|
||||||
|
* @param sourceId - ID de la source
|
||||||
|
* @returns L'URL de l'image
|
||||||
|
*/
|
||||||
|
export function getSourceImageUrl(sourceId: string): string {
|
||||||
|
return `${API_BASE}/${sourceId}/image`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les capacités d'une source
|
||||||
|
*/
|
||||||
|
export async function getSourceCapabilities(sourceId: string): Promise<SourceCapabilities> {
|
||||||
|
const response = await fetch(`${API_BASE}/${sourceId}/capabilities`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get source capabilities: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les métadonnées détaillées d'un item spécifique
|
||||||
|
*
|
||||||
|
* @param sourceId - ID de la source
|
||||||
|
* @param objectId - ID de l'item à récupérer
|
||||||
|
*/
|
||||||
|
export async function getItem(sourceId: string, objectId: string): Promise<BrowseItem> {
|
||||||
|
const params = new URLSearchParams({ object_id: objectId })
|
||||||
|
const url = `${API_BASE}/${sourceId}/item?${params.toString()}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get item: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
@@ -63,6 +63,71 @@ impl RadioParadiseSource {
|
|||||||
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch current metadata from the live stream
|
||||||
|
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
|
||||||
|
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug);
|
||||||
|
|
||||||
|
// Try to fetch metadata via HTTP
|
||||||
|
match reqwest::get(&metadata_url).await {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
match response.json::<serde_json::Value>().await {
|
||||||
|
Ok(json) => {
|
||||||
|
// Parse metadata from JSON and create an Item
|
||||||
|
let title = json["title"].as_str().unwrap_or("Unknown Title").to_string();
|
||||||
|
let artist = json["artist"].as_str().map(|s| s.to_string());
|
||||||
|
let album = json["album"].as_str().map(|s| s.to_string());
|
||||||
|
let year = json["year"].as_u64().map(|y| y as u32);
|
||||||
|
let cover_url = json["cover_url"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Parse duration from JSON (in seconds as a float)
|
||||||
|
let duration = json["duration"]
|
||||||
|
.as_object()
|
||||||
|
.and_then(|d| d.get("secs"))
|
||||||
|
.and_then(|s| s.as_f64())
|
||||||
|
.or_else(|| json["duration"].as_f64())
|
||||||
|
.map(|secs| {
|
||||||
|
let total_secs = secs as u64;
|
||||||
|
format!("{}:{:02}:{:02}",
|
||||||
|
total_secs / 3600,
|
||||||
|
(total_secs % 3600) / 60,
|
||||||
|
total_secs % 60)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the item with current metadata
|
||||||
|
let item = Item {
|
||||||
|
id: format!("radio-paradise:channel:{}:live", slug),
|
||||||
|
parent_id: format!("radio-paradise:channel:{}", slug),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
title,
|
||||||
|
creator: artist.clone(),
|
||||||
|
class: "object.item.audioItem.audioBroadcast".to_string(),
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
genre: Some("Radio".to_string()),
|
||||||
|
album_art: cover_url,
|
||||||
|
album_art_pk: None,
|
||||||
|
date: year.map(|y| y.to_string()),
|
||||||
|
original_track_number: None,
|
||||||
|
resources: vec![Resource {
|
||||||
|
protocol_info: "http-get:*:audio/ogg:*".to_string(),
|
||||||
|
bits_per_sample: None,
|
||||||
|
sample_frequency: None,
|
||||||
|
nr_audio_channels: Some("2".to_string()),
|
||||||
|
duration,
|
||||||
|
url: self.build_live_url(slug),
|
||||||
|
}],
|
||||||
|
descriptions: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(item))
|
||||||
|
}
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the playlist ID for a channel's history
|
/// Get the playlist ID for a channel's history
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
fn history_playlist_id(slug: &str) -> String {
|
fn history_playlist_id(slug: &str) -> String {
|
||||||
@@ -350,6 +415,70 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_item(&self, object_id: &str) -> Result<Item> {
|
||||||
|
match Self::parse_object_id(object_id) {
|
||||||
|
ObjectIdType::LiveStream { slug } => {
|
||||||
|
// Try to fetch current metadata from live stream
|
||||||
|
if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await {
|
||||||
|
return Ok(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to static item if metadata fetch fails
|
||||||
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||||
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||||
|
})?;
|
||||||
|
Ok(self.build_live_stream_item(descriptor))
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectIdType::HistoryTrack { slug, pk } => {
|
||||||
|
// Get from history playlist
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
{
|
||||||
|
let playlist_id = Self::history_playlist_id(&slug);
|
||||||
|
let manager = pmoplaylist::PlaylistManager();
|
||||||
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
||||||
|
MusicSourceError::BrowseError(format!(
|
||||||
|
"Failed to get playlist {}: {}",
|
||||||
|
playlist_id, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Try to find the entry with this pk
|
||||||
|
let entries = reader.get_entries(0, 1000).await.map_err(|e| {
|
||||||
|
MusicSourceError::BrowseError(format!(
|
||||||
|
"Failed to read playlist entries: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if entry.pk == pk {
|
||||||
|
return self.playlist_entry_to_item(&slug, &entry).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(MusicSourceError::ObjectNotFound(format!(
|
||||||
|
"Track with pk {} not found in history",
|
||||||
|
pk
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "playlist"))]
|
||||||
|
{
|
||||||
|
let _ = (slug, pk);
|
||||||
|
Err(MusicSourceError::NotSupported(
|
||||||
|
"Playlist feature not enabled".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
||||||
|
"Cannot get item for object: {}",
|
||||||
|
object_id
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn supports_fifo(&self) -> bool {
|
fn supports_fifo(&self) -> bool {
|
||||||
// History playlists are FIFO
|
// History playlists are FIFO
|
||||||
cfg!(feature = "playlist")
|
cfg!(feature = "playlist")
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ axum = { version = "0.8", optional = true }
|
|||||||
utoipa = { version = "5.3", optional = true }
|
utoipa = { version = "5.3", optional = true }
|
||||||
tracing = { version = "0.1", optional = true }
|
tracing = { version = "0.1", optional = true }
|
||||||
lazy_static = { version = "1.4", optional = true }
|
lazy_static = { version = "1.4", optional = true }
|
||||||
|
tokio-stream = { version = "0.1", optional = true, features = ["time"] }
|
||||||
|
futures = { version = "0.3", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["cache"]
|
default = ["cache"]
|
||||||
cache = ["pmoaudiocache", "pmocovers"]
|
cache = ["pmoaudiocache", "pmocovers"]
|
||||||
server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static"]
|
server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static", "tokio-stream", "futures"]
|
||||||
|
|||||||
@@ -855,6 +855,118 @@ async fn resolve_source_uri(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées détaillées d'un item
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}/item",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "ID de la source"),
|
||||||
|
ObjectQuery
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Métadonnées de l'item", body = BrowseItemInfo),
|
||||||
|
(status = 404, description = "Source ou objet introuvable", body = ErrorResponse),
|
||||||
|
(status = 501, description = "Fonctionnalité non supportée", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de la récupération de l'item", body = ErrorResponse),
|
||||||
|
),
|
||||||
|
tag = "sources"
|
||||||
|
)]
|
||||||
|
async fn get_source_item(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(params): Query<ObjectQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match get_source(&id).await {
|
||||||
|
Some(source) => match source.get_item(¶ms.object_id).await {
|
||||||
|
Ok(item) => {
|
||||||
|
let item_info = BrowseItemInfo::from(&item);
|
||||||
|
(StatusCode::OK, Json(item_info)).into_response()
|
||||||
|
}
|
||||||
|
Err(MusicSourceError::ObjectNotFound(_)) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Item not found".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(MusicSourceError::NotSupported(msg)) => (
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
Json(ErrorResponse { error: msg }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to get item: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Source '{}' not found", id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream les métadonnées d'un item en temps réel via Server-Sent Events
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
async fn stream_source_item_metadata(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(params): Query<ObjectQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use futures::stream::{self, Stream};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio_stream::StreamExt as _;
|
||||||
|
|
||||||
|
match get_source(&id).await {
|
||||||
|
Some(source) => {
|
||||||
|
let object_id = params.object_id.clone();
|
||||||
|
|
||||||
|
// Create a stream that fetches metadata every 3 seconds
|
||||||
|
let stream = stream::repeat_with(move || {
|
||||||
|
let source = source.clone();
|
||||||
|
let object_id = object_id.clone();
|
||||||
|
async move {
|
||||||
|
match source.get_item(&object_id).await {
|
||||||
|
Ok(item) => {
|
||||||
|
let item_info = BrowseItemInfo::from(&item);
|
||||||
|
match serde_json::to_string(&item_info) {
|
||||||
|
Ok(json) => Ok(Event::default().data(json)),
|
||||||
|
Err(e) => Err(format!("Failed to serialize metadata: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Failed to get item: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(|fut| fut)
|
||||||
|
.throttle(Duration::from_secs(3))
|
||||||
|
.filter_map(|result| match result {
|
||||||
|
Ok(event) => Some(Ok::<_, Infallible>(event)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error fetching metadata: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default()).into_response()
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Source '{}' not found", id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère le statut du cache pour un objet
|
/// Récupère le statut du cache pour un objet
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -1096,6 +1208,8 @@ pub fn create_sources_router() -> Router {
|
|||||||
.route("/{id}/root", get(get_source_root))
|
.route("/{id}/root", get(get_source_root))
|
||||||
.route("/{id}/browse", get(browse_source))
|
.route("/{id}/browse", get(browse_source))
|
||||||
.route("/{id}/image", get(get_source_image))
|
.route("/{id}/image", get(get_source_image))
|
||||||
|
.route("/{id}/item", get(get_source_item))
|
||||||
|
.route("/{id}/item/stream", get(stream_source_item_metadata))
|
||||||
.route("/{id}/resolve", get(resolve_source_uri))
|
.route("/{id}/resolve", get(resolve_source_uri))
|
||||||
.route("/{id}/cache/status", get(get_source_cache_status))
|
.route("/{id}/cache/status", get(get_source_cache_status))
|
||||||
.route("/{id}/cache", post(request_source_cache))
|
.route("/{id}/cache", post(request_source_cache))
|
||||||
@@ -1118,6 +1232,7 @@ pub fn create_sources_router() -> Router {
|
|||||||
get_source_root,
|
get_source_root,
|
||||||
browse_source,
|
browse_source,
|
||||||
get_source_image,
|
get_source_image,
|
||||||
|
get_source_item,
|
||||||
resolve_source_uri,
|
resolve_source_uri,
|
||||||
get_source_cache_status,
|
get_source_cache_status,
|
||||||
request_source_cache,
|
request_source_cache,
|
||||||
|
|||||||
@@ -419,6 +419,38 @@ pub trait MusicSource: Debug + Send + Sync {
|
|||||||
/// ```
|
/// ```
|
||||||
async fn browse(&self, object_id: &str) -> Result<BrowseResult>;
|
async fn browse(&self, object_id: &str) -> Result<BrowseResult>;
|
||||||
|
|
||||||
|
/// Get detailed metadata for a specific item
|
||||||
|
///
|
||||||
|
/// Returns the full metadata of an item by its object_id.
|
||||||
|
/// This is useful for refreshing metadata during playback or
|
||||||
|
/// getting details of a specific track without browsing its parent.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object_id` - The ID of the item to retrieve
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The complete `Item` with all metadata.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MusicSourceError::ObjectNotFound` if the item doesn't exist.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let item = source.get_item("track-123").await?;
|
||||||
|
/// println!("Now playing: {} by {}", item.title, item.artist.unwrap_or_default());
|
||||||
|
/// ```
|
||||||
|
async fn get_item(&self, object_id: &str) -> Result<Item> {
|
||||||
|
// Default implementation: try to find it in parent's browse result
|
||||||
|
// This is inefficient and should be overridden by implementations
|
||||||
|
Err(MusicSourceError::NotSupported(
|
||||||
|
"get_item not implemented, override this method".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the actual URI for a track
|
/// Resolve the actual URI for a track
|
||||||
///
|
///
|
||||||
/// This method should return the URI that can be used to stream/download
|
/// This method should return the URI that can be used to stream/download
|
||||||
|
|||||||
Reference in New Issue
Block a user