//! API REST pour la gestion des playlists. use std::sync::Arc; 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 serde_json::Value; 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, #[serde(skip_serializing_if = "Option::is_none")] pub cover_pk: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cover_url: Option, 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, #[serde(skip_serializing_if = "Option::is_none")] pub lazy_pk: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cover_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cover_source: 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 = "abc123")] pub cover_pk: 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>, /// Utiliser `null` explicite pour supprimer la cover, ou omettre pour ne pas modifier. pub cover_pk: 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(); let normalized_cover_pk = normalize_cover_pk(req.cover_pk.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?; if let Some(cover_pk) = normalized_cover_pk.clone() { writer.set_cover_pk(Some(cover_pk)).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?; if let Some(cover_pk) = normalized_cover_pk { writer.set_cover_pk(Some(cover_pk)).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, cover_pk, } = 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?; } if let Some(cover_pk) = cover_pk { let normalized = match cover_pk { Some(value) => normalize_cover_pk(Some(value)), None => None, }; writer.set_cover_pk(normalized).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, audio_cache: Option<&Arc>, ) -> PlaylistTrackResponse { let mut response = 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()), lazy_pk: None, metadata: None, cover_url: None, cover_source: None, }; if let Some(cache) = audio_cache { if let Ok(mut entry) = cache.db.get(&track.cache_pk, true) { if let Some(lazy_pk) = entry.lazy_pk.take() { if lazy_pk != response.cache_pk { response.lazy_pk = Some(lazy_pk); } } if let Some(metadata) = entry.metadata { if let Some((url, source)) = resolve_cover_from_metadata(&metadata) { response.cover_url = Some(url); response.cover_source = Some(source); } response.metadata = Some(metadata); } } } response } fn cover_url_from_pk(pk: &str) -> String { format!("/covers/image/{}/256", pk) } fn normalize_cover_pk(input: Option) -> Option { input.and_then(|pk| { let trimmed = pk.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } fn resolve_cover_from_metadata(metadata: &Value) -> Option<(String, String)> { if let Some(cover_pk) = metadata.get("cover_pk").and_then(Value::as_str) { return Some((cover_url_from_pk(cover_pk), "cover_pk".to_string())); } if let Some(url) = metadata.get("cover_url").and_then(Value::as_str) { return Some((url.to_string(), "cover_url".to_string())); } None } impl From for PlaylistSummaryResponse { fn from(value: PlaylistOverview) -> Self { let cover_pk = value.cover_pk.clone(); Self { id: value.id, title: value.title, role: value.role, persistent: value.persistent, cover_pk: cover_pk.clone(), cover_url: cover_pk.as_deref().map(cover_url_from_pk), 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 audio_cache = crate::manager::audio_cache().ok(); let tracks = value .tracks .iter() .map(|track| playlist_track_to_response(track, audio_cache.as_ref())) .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() }