pinakes/crates/pinakes-server/src/routes/playlists.rs
NotAShelf 625077f341
pinakes-server: add utoipa annotations to all routes; fix tests
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
2026-03-22 17:58:39 +03:00

366 lines
12 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, State},
};
use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId};
use uuid::Uuid;
use crate::{
auth::resolve_user_id,
dto::{
CreatePlaylistRequest,
MediaResponse,
PlaylistItemRequest,
PlaylistResponse,
ReorderPlaylistRequest,
UpdatePlaylistRequest,
},
error::ApiError,
state::AppState,
};
/// Check whether a user has access to a playlist.
///
/// # Arguments
///
/// * `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<Playlist, ApiError> {
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)
}
#[utoipa::path(
post,
path = "/api/v1/playlists",
tag = "playlists",
request_body = CreatePlaylistRequest,
responses(
(status = 200, description = "Playlist created", body = PlaylistResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_playlist(
State(state): State<AppState>,
Extension(username): Extension<String>,
Json(req): Json<CreatePlaylistRequest>,
) -> Result<Json<PlaylistResponse>, 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)))
}
#[utoipa::path(
get,
path = "/api/v1/playlists",
tag = "playlists",
responses(
(status = 200, description = "List of playlists", body = Vec<PlaylistResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_playlists(
State(state): State<AppState>,
Extension(username): Extension<String>,
) -> Result<Json<Vec<PlaylistResponse>>, 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<PlaylistResponse> = playlists
.into_iter()
.filter(|p| p.is_public || p.owner_id == user_id)
.map(PlaylistResponse::from)
.collect();
Ok(Json(visible))
}
#[utoipa::path(
get,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist details", body = PlaylistResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_playlist(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> Result<Json<PlaylistResponse>, 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)))
}
#[utoipa::path(
patch,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = UpdatePlaylistRequest,
responses(
(status = 200, description = "Playlist updated", body = PlaylistResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_playlist(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePlaylistRequest>,
) -> Result<Json<PlaylistResponse>, 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)))
}
#[utoipa::path(
delete,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_playlist(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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})))
}
#[utoipa::path(
post,
path = "/api/v1/playlists/{id}/items",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = PlaylistItemRequest,
responses(
(status = 200, description = "Item added"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn add_item(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<PlaylistItemRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user_id = resolve_user_id(&state.storage, &username).await?;
check_playlist_access(&state.storage, id, user_id, true).await?;
let position = if let Some(p) = req.position {
p
} else {
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})))
}
#[utoipa::path(
delete,
path = "/api/v1/playlists/{id}/items/{media_id}",
tag = "playlists",
params(
("id" = Uuid, Path, description = "Playlist ID"),
("media_id" = Uuid, Path, description = "Media item ID"),
),
responses(
(status = 200, description = "Item removed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn remove_item(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path((id, media_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, 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})))
}
#[utoipa::path(
get,
path = "/api/v1/playlists/{id}/items",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist items", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn list_items(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<MediaResponse>>, 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?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
#[utoipa::path(
patch,
path = "/api/v1/playlists/{id}/items/reorder",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = ReorderPlaylistRequest,
responses(
(status = 200, description = "Item reordered"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn reorder_item(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<ReorderPlaylistRequest>,
) -> Result<Json<serde_json::Value>, 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})))
}
#[utoipa::path(
post,
path = "/api/v1/playlists/{id}/shuffle",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Shuffled playlist items", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn shuffle_playlist(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<MediaResponse>>, 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());
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}