diff --git a/Cargo.lock b/Cargo.lock index cda2f193..a88257c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/PLAYER_PMOSOURCE_README.md b/PLAYER_PMOSOURCE_README.md new file mode 100644 index 00000000..838823ac --- /dev/null +++ b/PLAYER_PMOSOURCE_README.md @@ -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>; +``` + +### 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; +``` + +Ou ajouter `total_available` dans `BrowseResponse` : +```rust +pub struct SourceBrowseResponse { + // ... champs existants + pub total_available: Option, // 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>; + async fn get_previous_track(&self) -> Result>; + async fn add_to_queue(&self, item: Item) -> Result<()>; + async fn clear_queue(&self) -> Result<()>; + async fn get_queue(&self) -> Result>; +} +``` + +### 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; + +pub struct ContainerStats { + pub item_count: usize, + pub total_duration_ms: Option, + pub total_size_bytes: Option, +} +``` + +### 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.) diff --git a/pmoapp/webapp/src/components/GenericMusicPlayer.vue b/pmoapp/webapp/src/components/GenericMusicPlayer.vue new file mode 100644 index 00000000..8253c0b2 --- /dev/null +++ b/pmoapp/webapp/src/components/GenericMusicPlayer.vue @@ -0,0 +1,1157 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index a9bc198f..dadb688e 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -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 }, diff --git a/pmoapp/webapp/src/services/pmosource.ts b/pmoapp/webapp/src/services/pmosource.ts new file mode 100644 index 00000000..71931968 --- /dev/null +++ b/pmoapp/webapp/src/services/pmosource.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 3524a7e8..c8ad676c 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -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> { + 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::().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 { + 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") diff --git a/pmosource/Cargo.toml b/pmosource/Cargo.toml index c45ea91c..5baf3e7d 100644 --- a/pmosource/Cargo.toml +++ b/pmosource/Cargo.toml @@ -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"] diff --git a/pmosource/src/api.rs b/pmosource/src/api.rs index 68202dd8..556f5e0b 100644 --- a/pmosource/src/api.rs +++ b/pmosource/src/api.rs @@ -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, + Query(params): Query, +) -> 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, + Query(params): Query, +) -> 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, diff --git a/pmosource/src/lib.rs b/pmosource/src/lib.rs index 3e0cfa01..12b21471 100644 --- a/pmosource/src/lib.rs +++ b/pmosource/src/lib.rs @@ -419,6 +419,38 @@ pub trait MusicSource: Debug + Send + Sync { /// ``` async fn browse(&self, object_id: &str) -> Result; + /// 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 { + // 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