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",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"pmoaudiocache",
|
||||
"pmoconfig",
|
||||
@@ -3384,6 +3385,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"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 HelloWorld from "../components/HelloWorld.vue";
|
||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
@@ -8,7 +8,7 @@ import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
{ 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[cfg(feature = "playlist")]
|
||||
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 {
|
||||
// History playlists are FIFO
|
||||
cfg!(feature = "playlist")
|
||||
|
||||
@@ -44,8 +44,10 @@ axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
tracing = { version = "0.1", 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]
|
||||
default = ["cache"]
|
||||
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
|
||||
#[cfg(feature = "server")]
|
||||
#[utoipa::path(
|
||||
@@ -1096,6 +1208,8 @@ pub fn create_sources_router() -> Router {
|
||||
.route("/{id}/root", get(get_source_root))
|
||||
.route("/{id}/browse", get(browse_source))
|
||||
.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}/cache/status", get(get_source_cache_status))
|
||||
.route("/{id}/cache", post(request_source_cache))
|
||||
@@ -1118,6 +1232,7 @@ pub fn create_sources_router() -> Router {
|
||||
get_source_root,
|
||||
browse_source,
|
||||
get_source_image,
|
||||
get_source_item,
|
||||
resolve_source_uri,
|
||||
get_source_cache_status,
|
||||
request_source_cache,
|
||||
|
||||
@@ -419,6 +419,38 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
/// ```
|
||||
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
|
||||
///
|
||||
/// This method should return the URI that can be used to stream/download
|
||||
|
||||
Reference in New Issue
Block a user