diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index 882ae346..a201f637 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -15,6 +15,7 @@ đŸŽ” UPnP Explorer 🎹 Cover Cache đŸŽ” Audio Cache + đŸ—‚ïž Playlists 🚀 API Dashboard diff --git a/pmoapp/webapp/src/components/PlayListManager.vue b/pmoapp/webapp/src/components/PlayListManager.vue new file mode 100644 index 00000000..1d1f3d6d --- /dev/null +++ b/pmoapp/webapp/src/components/PlayListManager.vue @@ -0,0 +1,1151 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 7502dbd9..295549c0 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -10,6 +10,7 @@ import GenericMusicPlayer from "../components/GenericMusicPlayer.vue"; import LogView from "../components/LogView.vue"; import CoverCacheManager from "../components/CoverCacheManager.vue"; import AudioCacheManager from "../components/AudioCacheManager.vue"; +import PlayListManager from "../components/PlayListManager.vue"; import UpnpExplorer from "../components/UpnpExplorer.vue"; import APIDashboard from "../components/APIDashboard.vue"; import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue"; @@ -53,6 +54,11 @@ const routes = [ name: "AudioCache", component: AudioCacheManager, }, + { + path: "/debug/playlists", + name: "PlaylistsManager", + component: PlayListManager, + }, { path: "/debug/upnp", name: "UpnpExplorer", diff --git a/pmoapp/webapp/src/services/playlists.ts b/pmoapp/webapp/src/services/playlists.ts new file mode 100644 index 00000000..160f95a2 --- /dev/null +++ b/pmoapp/webapp/src/services/playlists.ts @@ -0,0 +1,153 @@ +export interface PlaylistSummary { + id: string; + title: string; + role: string; + persistent: boolean; + track_count: number; + max_size?: number | null; + default_ttl_secs?: number | null; + last_change: string; +} + +export interface PlaylistTrack { + cache_pk: string; + added_at: string; + ttl_secs?: number | null; +} + +export interface PlaylistDetail { + summary: PlaylistSummary; + tracks: PlaylistTrack[]; +} + +export interface ApiErrorPayload { + error?: string; + message?: string; +} + +export interface CreatePlaylistPayload { + id: string; + title?: string; + role?: string; + persistent?: boolean; + max_size?: number; + default_ttl_secs?: number; +} + +export interface UpdatePlaylistPayload { + title?: string; + role?: string; + max_size?: number | null; + default_ttl_secs?: number | null; +} + +export interface AddTracksPayload { + cache_pks: string[]; + ttl_secs?: number; + lazy?: boolean; +} + +async function parseJsonOrThrow(response: Response): Promise { + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const error: ApiErrorPayload = await response.json(); + if (error?.message) { + message = error.message; + } + } catch { + // Ignore JSON parsing errors and keep default message + } + throw new Error(message); + } + + return response.json() as Promise; +} + +async function ensureSuccess(response: Response): Promise { + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const error: ApiErrorPayload = await response.json(); + if (error?.message) { + message = error.message; + } + } catch { + // ignore + } + throw new Error(message); + } +} + +export async function listPlaylists(): Promise { + const response = await fetch("/api/playlists"); + return parseJsonOrThrow(response); +} + +export async function getPlaylistDetail(id: string): Promise { + const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`); + return parseJsonOrThrow(response); +} + +export async function createPlaylist(body: CreatePlaylistPayload): Promise { + const response = await fetch("/api/playlists", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + return parseJsonOrThrow(response); +} + +export async function updatePlaylist( + id: string, + body: UpdatePlaylistPayload +): Promise { + const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + return parseJsonOrThrow(response); +} + +export async function deletePlaylist(id: string): Promise { + const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + await ensureSuccess(response); +} + +export async function addTracksToPlaylist( + id: string, + payload: AddTracksPayload +): Promise { + const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + return parseJsonOrThrow(response); +} + +export async function flushPlaylist(id: string): Promise { + const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, { + method: "DELETE", + }); + return parseJsonOrThrow(response); +} + +export async function removeTrackFromPlaylist(id: string, cachePk: string): Promise { + const response = await fetch( + `/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(cachePk)}`, + { + method: "DELETE", + } + ); + return parseJsonOrThrow(response); +} diff --git a/pmoparadise/src/playlist_feeder.rs b/pmoparadise/src/playlist_feeder.rs index 2d9abd15..a004f470 100644 --- a/pmoparadise/src/playlist_feeder.rs +++ b/pmoparadise/src/playlist_feeder.rs @@ -114,9 +114,21 @@ impl RadioParadisePlaylistFeeder { collection: Option, ) -> Result<(Self, ReadHandle)> { let manager = PlaylistManager::get(); - let write_handle = manager - .create_persistent_playlist_with_role(playlist_id.clone(), PlaylistRole::Radio) - .await?; + let mut write_handle = manager.get_write_handle(playlist_id.clone()).await?; + + // Assure-toi que les playlists Live ne deviennent jamais persistantes. + if write_handle.is_persistent() { + tracing::warn!( + "RadioParadisePlaylistFeeder: playlist {} was persistent, recreating as transient", + playlist_id + ); + write_handle.delete().await?; + write_handle = manager.get_write_handle(playlist_id.clone()).await?; + } + + // Force the logical role to 'Radio' for better visibility/debug. + write_handle.set_role(PlaylistRole::Radio).await?; + let read_handle = manager.get_read_handle(&playlist_id).await?; Ok(( diff --git a/pmoplaylist/src/api.rs b/pmoplaylist/src/api.rs new file mode 100644 index 00000000..beeb3754 --- /dev/null +++ b/pmoplaylist/src/api.rs @@ -0,0 +1,517 @@ +//! API REST pour la gestion des playlists. + +use std::time::{Duration, SystemTime}; + +use axum::{ + extract::Path, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::manager::{PlaylistOverview, PlaylistSnapshot, PlaylistTrackSnapshot}; +use crate::PlaylistRole; + +/// Router `/api/playlists` combinant les diffĂ©rents endpoints REST. +pub fn playlist_api_router() -> Router { + Router::new() + .route("/", get(list_playlists).post(create_playlist)) + .route( + "/{playlist_id}", + get(get_playlist) + .patch(update_playlist) + .delete(delete_playlist), + ) + .route( + "/{playlist_id}/tracks", + post(add_tracks).delete(flush_tracks), + ) + .route("/{playlist_id}/tracks/{cache_pk}", delete(remove_track)) +} + +/// RĂ©sumĂ© d'une playlist (utilisĂ© dans les listings). +#[derive(Debug, Serialize, ToSchema)] +pub struct PlaylistSummaryResponse { + pub id: String, + pub title: String, + #[schema(value_type = String)] + pub role: PlaylistRole, + pub persistent: bool, + pub track_count: usize, + pub max_size: Option, + pub default_ttl_secs: Option, + pub last_change: DateTime, +} + +/// RĂ©ponse dĂ©taillĂ©e pour une playlist (inclut les tracks). +#[derive(Debug, Serialize, ToSchema)] +pub struct PlaylistDetailResponse { + #[serde(flatten)] + #[schema(inline)] + pub summary: PlaylistSummaryResponse, + pub tracks: Vec, +} + +/// Track rĂ©fĂ©rencĂ© dans une playlist. +#[derive(Debug, Serialize, ToSchema)] +pub struct PlaylistTrackResponse { + pub cache_pk: String, + pub added_at: DateTime, + pub ttl_secs: Option, +} + +/// RequĂȘte pour crĂ©er une playlist persistante/Ă©phĂ©mĂšre. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreatePlaylistRequest { + pub id: String, + pub title: Option, + #[schema(value_type = String)] + pub role: Option, + #[schema(example = true)] + pub persistent: Option, + pub max_size: Option, + pub default_ttl_secs: Option, +} + +/// RequĂȘte pour mettre Ă  jour les mĂ©tadonnĂ©es/config d'une playlist. +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePlaylistRequest { + pub title: Option, + #[schema(value_type = String)] + pub role: Option, + pub max_size: Option>, + pub default_ttl_secs: Option>, +} + +/// RequĂȘte pour ajouter des morceaux dans une playlist. +#[derive(Debug, Deserialize, ToSchema)] +pub struct AddTracksRequest { + pub cache_pks: Vec, + #[schema(example = 3600)] + pub ttl_secs: Option, + #[schema(example = false)] + pub lazy: Option, +} + +/// RĂ©ponse d'erreur REST gĂ©nĂ©rique. +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub error: String, + pub message: String, +} + +#[utoipa::path( + get, + path = "/api/playlists", + tag = "playlists", + responses( + (status = 200, description = "Liste de toutes les playlists", body = [PlaylistSummaryResponse]) + ) +)] +pub async fn list_playlists() -> Response { + let manager = crate::manager::PlaylistManager(); + match manager.all_playlist_overviews().await { + Ok(overviews) => { + let payload: Vec = overviews + .into_iter() + .map(PlaylistSummaryResponse::from) + .collect(); + (StatusCode::OK, Json(payload)).into_response() + } + Err(err) => map_error(err), + } +} + +#[utoipa::path( + post, + path = "/api/playlists", + tag = "playlists", + request_body = CreatePlaylistRequest, + responses( + (status = 201, description = "Playlist créée", body = PlaylistDetailResponse), + (status = 400, description = "RequĂȘte invalide", body = ErrorResponse), + (status = 409, description = "Playlist dĂ©jĂ  existante", body = ErrorResponse) + ) +)] +pub async fn create_playlist(Json(req): Json) -> Response { + if req.id.trim().is_empty() { + return map_status( + StatusCode::BAD_REQUEST, + "INVALID_ID", + "Playlist id cannot be empty", + ); + } + + let manager = crate::manager::PlaylistManager(); + let persistent = req.persistent.unwrap_or(true); + let requested_role = req.role.clone(); + let role = requested_role.clone().unwrap_or_else(PlaylistRole::user); + + let result = async move { + let id = req.id.clone(); + if persistent { + let writer = manager + .create_persistent_playlist_with_role(id.clone(), role) + .await?; + apply_metadata_updates( + &writer, + req.title.clone(), + req.max_size, + req.default_ttl_secs, + ) + .await?; + } else { + let writer = manager.get_write_handle(id.clone()).await?; + if let Some(role) = requested_role { + writer.set_role(role).await?; + } + apply_metadata_updates( + &writer, + req.title.clone(), + req.max_size, + req.default_ttl_secs, + ) + .await?; + } + + manager.playlist_snapshot(&req.id).await + } + .await; + + match result { + Ok(snapshot) => ( + StatusCode::CREATED, + Json(PlaylistDetailResponse::from(snapshot)), + ) + .into_response(), + Err(err) => map_error(err), + } +} + +#[utoipa::path( + get, + path = "/api/playlists/{playlist_id}", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist") + ), + responses( + (status = 200, description = "Playlist dĂ©taillĂ©e", body = PlaylistDetailResponse), + (status = 404, description = "Playlist introuvable", body = ErrorResponse) + ) +)] +pub async fn get_playlist(Path(playlist_id): Path) -> Response { + let manager = crate::manager::PlaylistManager(); + match manager.playlist_snapshot(&playlist_id).await { + Ok(snapshot) => { + (StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response() + } + Err(err) => map_error(err), + } +} + +#[utoipa::path( + patch, + path = "/api/playlists/{playlist_id}", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist") + ), + request_body = UpdatePlaylistRequest, + responses( + (status = 200, description = "Playlist mise Ă  jour", body = PlaylistDetailResponse), + (status = 404, description = "Playlist introuvable", body = ErrorResponse) + ) +)] +pub async fn update_playlist( + Path(playlist_id): Path, + Json(req): Json, +) -> Response { + let UpdatePlaylistRequest { + title, + role, + max_size, + default_ttl_secs, + } = req; + + let manager = crate::manager::PlaylistManager(); + let result = async move { + // S'assurer que la playlist existe + manager.get_read_handle(&playlist_id).await?; + let writer = manager.get_write_handle(playlist_id.clone()).await?; + + if let Some(title) = title { + writer.set_title(title).await?; + } + if let Some(role) = role { + writer.set_role(role).await?; + } + if let Some(capacity) = max_size { + writer.set_capacity(capacity).await?; + } + if let Some(ttl) = default_ttl_secs { + writer.set_default_ttl(ttl.map(Duration::from_secs)).await?; + } + + manager.playlist_snapshot(&playlist_id).await + } + .await; + + match result { + Ok(snapshot) => { + (StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response() + } + Err(err) => map_error(err), + } +} + +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist") + ), + responses( + (status = 204, description = "Playlist supprimĂ©e"), + (status = 404, description = "Playlist introuvable", body = ErrorResponse) + ) +)] +pub async fn delete_playlist(Path(playlist_id): Path) -> Response { + let manager = crate::manager::PlaylistManager(); + match manager.delete_playlist(&playlist_id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(err) => map_error(err), + } +} + +#[utoipa::path( + post, + path = "/api/playlists/{playlist_id}/tracks", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist") + ), + request_body = AddTracksRequest, + responses( + (status = 200, description = "Morceaux ajoutĂ©s", body = PlaylistDetailResponse), + (status = 400, description = "RequĂȘte invalide", body = ErrorResponse), + (status = 404, description = "Playlist introuvable", body = ErrorResponse) + ) +)] +pub async fn add_tracks( + Path(playlist_id): Path, + Json(req): Json, +) -> Response { + if req.cache_pks.is_empty() { + return map_status( + StatusCode::BAD_REQUEST, + "EMPTY_PAYLOAD", + "cache_pks cannot be empty", + ); + } + + let manager = crate::manager::PlaylistManager(); + let result = async { + manager.get_read_handle(&playlist_id).await?; + let writer = manager.get_write_handle(playlist_id.clone()).await?; + let ttl = req.ttl_secs.map(Duration::from_secs); + let use_lazy = req.lazy.unwrap_or(false); + + if use_lazy { + if req.cache_pks.len() == 1 { + writer.push_lazy(req.cache_pks[0].clone()).await?; + } else { + writer.push_lazy_batch(req.cache_pks.clone()).await?; + } + } else if let Some(ttl) = ttl { + for pk in &req.cache_pks { + writer.push_with_ttl(pk.clone(), ttl).await?; + } + } else if req.cache_pks.len() == 1 { + writer.push(req.cache_pks[0].clone()).await?; + } else { + writer.push_set(req.cache_pks.clone()).await?; + } + + manager.playlist_snapshot(&playlist_id).await + } + .await; + + match result { + Ok(snapshot) => { + (StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response() + } + Err(err) => map_error(err), + } +} + +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}/tracks", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist") + ), + responses( + (status = 200, description = "Playlist vidĂ©e", body = PlaylistDetailResponse), + (status = 404, description = "Playlist introuvable", body = ErrorResponse) + ) +)] +pub async fn flush_tracks(Path(playlist_id): Path) -> Response { + let manager = crate::manager::PlaylistManager(); + let result = async { + manager.get_read_handle(&playlist_id).await?; + let writer = manager.get_write_handle(playlist_id.clone()).await?; + writer.flush().await?; + manager.playlist_snapshot(&playlist_id).await + } + .await; + + match result { + Ok(snapshot) => { + (StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response() + } + Err(err) => map_error(err), + } +} + +#[utoipa::path( + delete, + path = "/api/playlists/{playlist_id}/tracks/{cache_pk}", + tag = "playlists", + params( + ("playlist_id" = String, Path, description = "Identifiant de la playlist"), + ("cache_pk" = String, Path, description = "PK Ă  retirer") + ), + responses( + (status = 200, description = "Track retirĂ©", body = PlaylistDetailResponse), + (status = 404, description = "Playlist ou track introuvable", body = ErrorResponse) + ) +)] +pub async fn remove_track(Path((playlist_id, cache_pk)): Path<(String, String)>) -> Response { + let manager = crate::manager::PlaylistManager(); + let result = async { + manager.get_read_handle(&playlist_id).await?; + let writer = manager.get_write_handle(playlist_id.clone()).await?; + if !writer.remove_track(&cache_pk).await? { + return Err(crate::Error::CacheEntryNotFound(cache_pk)); + } + manager.playlist_snapshot(&playlist_id).await + } + .await; + + match result { + Ok(snapshot) => { + (StatusCode::OK, Json(PlaylistDetailResponse::from(snapshot))).into_response() + } + Err(crate::Error::CacheEntryNotFound(pk)) => map_status( + StatusCode::NOT_FOUND, + "TRACK_NOT_FOUND", + &format!("Track '{}' not found in playlist", pk), + ), + Err(err) => map_error(err), + } +} + +fn apply_metadata_updates( + writer: &crate::handle::WriteHandle, + title: Option, + max_size: Option, + default_ttl_secs: Option, +) -> impl std::future::Future> + '_ { + async move { + if let Some(title) = title { + writer.set_title(title).await?; + } + if let Some(max) = max_size { + writer.set_capacity(Some(max)).await?; + } + if let Some(ttl) = default_ttl_secs { + writer + .set_default_ttl(Some(Duration::from_secs(ttl))) + .await?; + } + Ok(()) + } +} + +fn playlist_track_to_response(track: &PlaylistTrackSnapshot) -> PlaylistTrackResponse { + PlaylistTrackResponse { + cache_pk: track.cache_pk.clone(), + added_at: system_time_to_datetime(track.added_at), + ttl_secs: track.ttl.map(|ttl| ttl.as_secs()), + } +} + +impl From for PlaylistSummaryResponse { + fn from(value: PlaylistOverview) -> Self { + Self { + id: value.id, + title: value.title, + role: value.role, + persistent: value.persistent, + track_count: value.track_count, + max_size: value.max_size, + default_ttl_secs: value.default_ttl.map(|ttl| ttl.as_secs()), + last_change: system_time_to_datetime(value.last_change), + } + } +} + +impl From for PlaylistDetailResponse { + fn from(value: PlaylistSnapshot) -> Self { + let summary = PlaylistSummaryResponse::from(value.overview); + let tracks = value + .tracks + .iter() + .map(playlist_track_to_response) + .collect(); + Self { summary, tracks } + } +} + +fn system_time_to_datetime(time: SystemTime) -> DateTime { + DateTime::::from(time) +} + +fn map_status>(status: StatusCode, error: &str, message: S) -> Response { + ( + status, + Json(ErrorResponse { + error: error.to_string(), + message: message.into(), + }), + ) + .into_response() +} + +fn map_error(error: crate::Error) -> Response { + let status = match error { + crate::Error::PlaylistNotFound(_) | crate::Error::PlaylistDeleted(_) => { + StatusCode::NOT_FOUND + } + crate::Error::PlaylistAlreadyExists(_) | crate::Error::WriteLockHeld(_) => { + StatusCode::CONFLICT + } + crate::Error::CacheEntryNotFound(_) => StatusCode::BAD_REQUEST, + crate::Error::PlaylistNotPersistent(_) => StatusCode::BAD_REQUEST, + crate::Error::CacheError(_) + | crate::Error::PersistenceError(_) + | crate::Error::ManagerNotInitialized + | crate::Error::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + ( + status, + Json(ErrorResponse { + error: format!("{:?}", error), + message: error.to_string(), + }), + ) + .into_response() +} diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs index fb751af0..cc381aba 100644 --- a/pmoplaylist/src/handle/write.rs +++ b/pmoplaylist/src/handle/write.rs @@ -158,6 +158,85 @@ impl WriteHandle { Ok(()) } + /// Supprime un morceau par sa cache_pk. Retourne true si un Ă©lĂ©ment a Ă©tĂ© retirĂ©. + pub async fn remove_track(&self, cache_pk: &str) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + let removed = core.remove_by_cache_pk(cache_pk); + let snapshot = core.snapshot(); + drop(core); + + if removed { + self.playlist.touch().await; + if self.playlist.persistent { + self.save_to_db().await?; + } + + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + } + + Ok(removed) + } + + /// Met Ă  jour la capacitĂ© maximale. + pub async fn set_capacity(&self, max_size: Option) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + core.set_capacity(max_size); + let snapshot = core.snapshot(); + drop(core); + + self.playlist.touch().await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + + Ok(()) + } + + /// Met Ă  jour le TTL par dĂ©faut. + pub async fn set_default_ttl(&self, ttl: Option) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + core.set_default_ttl(ttl); + let snapshot = core.snapshot(); + drop(core); + + self.playlist.touch().await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + let manager = crate::manager::PlaylistManager(); + manager + .rebuild_track_index(&self.playlist.id, &snapshot) + .await; + manager.notify_playlist_changed(&self.playlist.id); + + Ok(()) + } + /// Supprime la playlist dĂ©finitivement pub async fn delete(self) -> Result<()> { if !self.playlist.is_alive() { @@ -213,32 +292,6 @@ impl WriteHandle { Ok(()) } - /// Change la capacitĂ© maximale - pub async fn set_capacity(&self, max_size: Option) -> Result<()> { - if !self.playlist.is_alive() { - return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); - } - - let mut core = self.playlist.core.write().await; - core.set_capacity(max_size); - let snapshot = core.snapshot(); - drop(core); - - self.playlist.touch().await; - - if self.playlist.persistent { - self.save_to_db().await?; - } - - let manager = crate::manager::PlaylistManager(); - manager - .rebuild_track_index(&self.playlist.id, &snapshot) - .await; - manager.notify_playlist_changed(&self.playlist.id); - - Ok(()) - } - /// VĂ©rifie si la playlist contient dĂ©jĂ  un pk pub async fn contains_pk(&self, cache_pk: &str) -> Result { if !self.playlist.is_alive() { @@ -249,32 +302,6 @@ impl WriteHandle { Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk)) } - /// Change le TTL par dĂ©faut - pub async fn set_default_ttl(&self, ttl: Option) -> Result<()> { - if !self.playlist.is_alive() { - return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); - } - - let mut core = self.playlist.core.write().await; - core.set_default_ttl(ttl); - let snapshot = core.snapshot(); - drop(core); - - self.playlist.touch().await; - - if self.playlist.persistent { - self.save_to_db().await?; - } - - let manager = crate::manager::PlaylistManager(); - manager - .rebuild_track_index(&self.playlist.id, &snapshot) - .await; - manager.notify_playlist_changed(&self.playlist.id); - - Ok(()) - } - /// Clone vers une nouvelle playlist persistante pub async fn clone_as_persistent(&self, new_id: String) -> Result { if !self.playlist.is_alive() { diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs index e18285cc..771d03d2 100644 --- a/pmoplaylist/src/lib.rs +++ b/pmoplaylist/src/lib.rs @@ -44,6 +44,8 @@ //! # } //! ``` +#[cfg(feature = "pmoserver")] +pub mod api; mod error; mod handle; mod manager; @@ -59,6 +61,8 @@ mod track; mod config_ext; // RĂ©exports publics +#[cfg(feature = "pmoserver")] +pub use api::playlist_api_router; pub use error::{Error, Result}; pub use handle::{ReadHandle, WriteHandle}; pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager}; diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index 8a91df14..ee033c8f 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -7,14 +7,14 @@ use crate::playlist::{Playlist, PlaylistRole}; use crate::Result; use once_cell::sync::OnceCell; use pmocache::{CacheBroadcastEvent, CacheEvent, CacheSubscription}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::RwLock as StdRwLock; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::sync::broadcast; use tokio::sync::RwLock; @@ -60,6 +60,34 @@ pub struct PlaylistEventEnvelope { pub source_client: Option, } +/// MĂ©tadonnĂ©es de synthĂšse d'une playlist. +#[derive(Debug, Clone)] +pub struct PlaylistOverview { + pub id: String, + pub title: String, + pub role: PlaylistRole, + pub persistent: bool, + pub track_count: usize, + pub max_size: Option, + pub default_ttl: Option, + pub last_change: SystemTime, +} + +/// Informations dĂ©taillĂ©es sur un track rĂ©fĂ©rencĂ© par une playlist. +#[derive(Debug, Clone)] +pub struct PlaylistTrackSnapshot { + pub cache_pk: String, + pub added_at: SystemTime, + pub ttl: Option, +} + +/// Snapshot complet d'une playlist (mĂ©tadonnĂ©es + tracks). +#[derive(Debug, Clone)] +pub struct PlaylistSnapshot { + pub overview: PlaylistOverview, + pub tracks: Vec, +} + /// Gestionnaire central de playlists pub struct PlaylistManager { inner: Arc, @@ -554,6 +582,104 @@ impl PlaylistManager { self.inner.playlists.read().await.contains_key(id) } + /// Retourne les mĂ©tadonnĂ©es complĂštes d'une playlist (charge depuis la DB si nĂ©cessaire). + pub async fn playlist_overview(&self, id: &str) -> Result { + let playlist = self.ensure_playlist_loaded(id).await?; + let title = playlist.title().await; + let role = playlist.role().await; + let persistent = playlist.persistent; + let last_change = playlist.last_change().await; + let core = playlist.core.read().await; + let track_count = core.len(); + let config = core.config.clone(); + + Ok(PlaylistOverview { + id: playlist.id.clone(), + title, + role, + persistent, + track_count, + max_size: config.max_size, + default_ttl: config.default_ttl, + last_change, + }) + } + + /// Retourne un snapshot complet (tracks inclus). + pub async fn playlist_snapshot(&self, id: &str) -> Result { + let overview = self.playlist_overview(id).await?; + let playlist = self.ensure_playlist_loaded(id).await?; + let core = playlist.core.read().await; + let snapshot = core.snapshot(); + drop(core); + + let tracks = snapshot + .into_iter() + .map(|record| PlaylistTrackSnapshot { + cache_pk: record.cache_pk.clone(), + added_at: record.added_at, + ttl: record.ttl, + }) + .collect(); + + Ok(PlaylistSnapshot { overview, tracks }) + } + + /// Retourne les mĂ©tadonnĂ©es de toutes les playlists connues (en mĂ©moire + persistantes). + pub async fn all_playlist_overviews(&self) -> Result> { + let ids = self.collect_all_playlist_ids().await?; + let mut overviews = Vec::with_capacity(ids.len()); + + for id in ids { + match self.playlist_overview(&id).await { + Ok(info) => overviews.push(info), + Err(crate::Error::PlaylistNotFound(_)) | Err(crate::Error::PlaylistDeleted(_)) => { + continue + } + Err(e) => return Err(e), + } + } + + overviews.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(overviews) + } + + async fn collect_all_playlist_ids(&self) -> Result> { + let mut ids: HashSet = { + let playlists = self.inner.playlists.read().await; + playlists.keys().cloned().collect() + }; + + if let Some(persistence) = &self.inner.persistence { + for id in persistence.list_playlist_ids().await? { + ids.insert(id); + } + } + + Ok(ids.into_iter().collect()) + } + + async fn ensure_playlist_loaded(&self, id: &str) -> Result> { + { + let playlists = self.inner.playlists.read().await; + if let Some(playlist) = playlists.get(id) { + if !playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(id.to_string())); + } + return Ok(playlist.clone()); + } + } + + // Charger la playlist depuis la persistance si possible + self.get_read_handle(id).await?; + + let playlists = self.inner.playlists.read().await; + playlists + .get(id) + .cloned() + .ok_or_else(|| crate::Error::PlaylistNotFound(id.to_string())) + } + /// Retourne la rïżœfïżœrence au PersistenceManager pub(crate) fn persistence(&self) -> Option<&Arc> { self.inner.persistence.as_ref() diff --git a/pmoplaylist/src/openapi.rs b/pmoplaylist/src/openapi.rs index b46b6df9..13a43368 100644 --- a/pmoplaylist/src/openapi.rs +++ b/pmoplaylist/src/openapi.rs @@ -7,10 +7,25 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( + crate::api::list_playlists, + crate::api::create_playlist, + crate::api::get_playlist, + crate::api::update_playlist, + crate::api::delete_playlist, + crate::api::add_tracks, + crate::api::flush_tracks, + crate::api::remove_track, crate::sse::playlist_events_sse, ), components( schemas( + crate::api::PlaylistSummaryResponse, + crate::api::PlaylistDetailResponse, + crate::api::PlaylistTrackResponse, + crate::api::CreatePlaylistRequest, + crate::api::UpdatePlaylistRequest, + crate::api::AddTracksRequest, + crate::api::ErrorResponse, crate::sse::EventPayload, crate::sse::EventsQuery, ) diff --git a/pmoplaylist/src/sse.rs b/pmoplaylist/src/sse.rs index b4492a6d..1353a17f 100644 --- a/pmoplaylist/src/sse.rs +++ b/pmoplaylist/src/sse.rs @@ -13,8 +13,6 @@ use axum::{ }; use serde::{Deserialize, Serialize}; #[cfg(feature = "pmoserver")] -use tokio_stream::StreamExt; - #[derive(Debug, Default, Deserialize)] #[cfg_attr(feature = "pmoserver", derive(utoipa::IntoParams, utoipa::ToSchema))] pub struct EventsQuery { diff --git a/pmoupnp/.pmomusic/config.yaml b/pmoupnp/.pmomusic/config.yaml new file mode 100644 index 00000000..ab19de14 --- /dev/null +++ b/pmoupnp/.pmomusic/config.yaml @@ -0,0 +1,16 @@ +host: + http_port: '8080' + cover_cache: + directory: cache_covers + size: 2000 + audio_cache: + directory: cache_audio + size: 500 + logger: + buffer_capacity: 200 + enable_console: true + min_level: INFO +devices: + mediarenderer: + testdevice: + udn: 32779579-36ed-4001-a7c4-b19fccbe2009 diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 2e3e0ad0..a29d1179 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -25,15 +25,15 @@ use std::sync::RwLock; use pmoserver::Server; use utoipa::OpenApi; -use crate::UpnpModel; use crate::devices::errors::DeviceError; use crate::devices::{Device, DeviceInstance, DeviceRegistry}; use crate::ssdp::SsdpServer; use crate::upnp_api::UpnpApiExt; +use crate::UpnpModel; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoverCache; -use pmoutils::{TransportProtocol, find_process_using_port}; +use pmoutils::{find_process_using_port, TransportProtocol}; /// Registre de devices global et thread-safe. /// @@ -318,10 +318,10 @@ impl UpnpServerExt for Server { // API playlists (SSE + OpenAPI) #[cfg(feature = "server")] { - use pmoplaylist::{openapi::ApiDoc, playlist_events_router}; - // SSE /api/playlists/events - self.add_router("/api/playlists", playlist_events_router()) - .await; + use pmoplaylist::{openapi::ApiDoc, playlist_api_router, playlist_events_router}; + // API REST + SSE sous /api/playlists + let router = playlist_api_router().merge(playlist_events_router()); + self.add_router("/api/playlists", router).await; // OpenAPI pour playlists let openapi = ApiDoc::openapi(); self.add_openapi(axum::Router::new(), openapi, "playlists")