use axum::Json; use axum::extract::{Extension, Path, State}; use uuid::Uuid; use crate::auth::resolve_user_id; use crate::dto::*; use crate::error::ApiError; use crate::state::AppState; use pinakes_core::model::MediaId; use pinakes_core::playlists::Playlist; use pinakes_core::users::UserId; /// Check whether a user has access to a playlist. /// /// * `require_write` – when `true` only the playlist owner is allowed (for /// mutations such as update, delete, add/remove/reorder items). When `false` /// the playlist must either be public or owned by the requesting user. async fn check_playlist_access( storage: &pinakes_core::storage::DynStorageBackend, playlist_id: Uuid, user_id: UserId, require_write: bool, ) -> Result { let playlist = storage.get_playlist(playlist_id).await.map_err(ApiError)?; if require_write { // Write operations require ownership if playlist.owner_id != user_id { return Err(ApiError(pinakes_core::error::PinakesError::Authorization( "only the playlist owner can modify this playlist".into(), ))); } } else { // Read operations: allow if public or owner if !playlist.is_public && playlist.owner_id != user_id { return Err(ApiError(pinakes_core::error::PinakesError::Authorization( "playlist is private".into(), ))); } } Ok(playlist) } pub async fn create_playlist( State(state): State, Extension(username): Extension, Json(req): Json, ) -> Result, ApiError> { if req.name.is_empty() || req.name.chars().count() > 255 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "playlist name must be 1-255 characters".into(), ), )); } let owner_id = resolve_user_id(&state.storage, &username).await?; let playlist = state .storage .create_playlist( owner_id, &req.name, req.description.as_deref(), req.is_public.unwrap_or(false), req.is_smart.unwrap_or(false), req.filter_query.as_deref(), ) .await?; Ok(Json(PlaylistResponse::from(playlist))) } pub async fn list_playlists( State(state): State, Extension(username): Extension, ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; // Fetch all playlists and filter to only public ones plus the user's own let playlists = state.storage.list_playlists(None).await?; let visible: Vec = playlists .into_iter() .filter(|p| p.is_public || p.owner_id == user_id) .map(PlaylistResponse::from) .collect(); Ok(Json(visible)) } pub async fn get_playlist( State(state): State, Extension(username): Extension, Path(id): Path, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; let playlist = check_playlist_access(&state.storage, id, user_id, false).await?; Ok(Json(PlaylistResponse::from(playlist))) } pub async fn update_playlist( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { if let Some(ref name) = req.name && (name.is_empty() || name.chars().count() > 255) { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "playlist name must be 1-255 characters".into(), ), )); } let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; let playlist = state .storage .update_playlist( id, req.name.as_deref(), req.description.as_deref(), req.is_public, ) .await?; Ok(Json(PlaylistResponse::from(playlist))) } pub async fn delete_playlist( State(state): State, Extension(username): Extension, Path(id): Path, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; state.storage.delete_playlist(id).await?; Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn add_item( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; let position = match req.position { Some(p) => p, None => { let items = state.storage.get_playlist_items(id).await?; items.len() as i32 } }; state .storage .add_to_playlist(id, MediaId(req.media_id), position) .await?; Ok(Json(serde_json::json!({"added": true}))) } pub async fn remove_item( State(state): State, Extension(username): Extension, Path((id, media_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; state .storage .remove_from_playlist(id, MediaId(media_id)) .await?; Ok(Json(serde_json::json!({"removed": true}))) } pub async fn list_items( State(state): State, Extension(username): Extension, Path(id): Path, ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; let items = state.storage.get_playlist_items(id).await?; Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } pub async fn reorder_item( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; state .storage .reorder_playlist(id, MediaId(req.media_id), req.new_position) .await?; Ok(Json(serde_json::json!({"reordered": true}))) } pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, Path(id): Path, ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); Ok(Json(items.into_iter().map(MediaResponse::from).collect())) }