use axum::{ Json, Router, extract::{Extension, Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, put}, }; use pinakes_core::{ error::PinakesError, model::{ AuthorInfo, BookMetadata, MediaId, Pagination, ReadingProgress, ReadingStatus, }, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ auth::resolve_user_id, dto::MediaResponse, error::ApiError, state::AppState, }; /// Book metadata response DTO #[derive(Debug, Serialize, Deserialize)] pub struct BookMetadataResponse { pub media_id: Uuid, pub isbn: Option, pub isbn13: Option, pub publisher: Option, pub language: Option, pub page_count: Option, pub publication_date: Option, pub series_name: Option, pub series_index: Option, pub format: Option, pub authors: Vec, pub identifiers: std::collections::HashMap>, } impl From for BookMetadataResponse { fn from(meta: BookMetadata) -> Self { Self { media_id: meta.media_id.0, isbn: meta.isbn, isbn13: meta.isbn13, publisher: meta.publisher, language: meta.language, page_count: meta.page_count, publication_date: meta.publication_date.map(|d| d.to_string()), series_name: meta.series_name, series_index: meta.series_index, format: meta.format, authors: meta .authors .into_iter() .map(AuthorResponse::from) .collect(), identifiers: meta.identifiers, } } } /// Author response DTO #[derive(Debug, Serialize, Deserialize)] pub struct AuthorResponse { pub name: String, pub role: String, pub file_as: Option, pub position: i32, } impl From for AuthorResponse { fn from(author: AuthorInfo) -> Self { Self { name: author.name, role: author.role, file_as: author.file_as, position: author.position, } } } /// Reading progress response DTO #[derive(Debug, Serialize, Deserialize)] pub struct ReadingProgressResponse { pub media_id: Uuid, pub user_id: Uuid, pub current_page: i32, pub total_pages: Option, pub progress_percent: f64, pub last_read_at: String, } impl From for ReadingProgressResponse { fn from(progress: ReadingProgress) -> Self { Self { media_id: progress.media_id.0, user_id: progress.user_id, current_page: progress.current_page, total_pages: progress.total_pages, progress_percent: progress.progress_percent, last_read_at: progress.last_read_at.to_rfc3339(), } } } /// Update reading progress request #[derive(Debug, Deserialize)] pub struct UpdateProgressRequest { pub current_page: i32, } /// Search books query parameters #[derive(Debug, Deserialize)] pub struct SearchBooksQuery { pub isbn: Option, pub author: Option, pub series: Option, pub publisher: Option, pub language: Option, #[serde(default = "default_offset")] pub offset: u64, #[serde(default = "default_limit")] pub limit: u64, } const fn default_offset() -> u64 { 0 } const fn default_limit() -> u64 { 50 } /// Series summary DTO #[derive(Debug, Serialize)] pub struct SeriesSummary { pub name: String, pub book_count: u64, } /// Author summary DTO #[derive(Debug, Serialize)] pub struct AuthorSummary { pub name: String, pub book_count: u64, } /// Get book metadata by media ID pub async fn get_book_metadata( State(state): State, Path(media_id): Path, ) -> Result { let media_id = MediaId(media_id); let metadata = state .storage .get_book_metadata(media_id) .await? .ok_or(ApiError(PinakesError::NotFound( "Book metadata not found".to_string(), )))?; Ok(Json(BookMetadataResponse::from(metadata))) } /// List all books with optional search filters pub async fn list_books( State(state): State, Query(query): Query, ) -> Result { let pagination = Pagination { offset: query.offset, limit: query.limit.min(1000), sort: None, }; let items = state .storage .search_books( query.isbn.as_deref(), query.author.as_deref(), query.series.as_deref(), query.publisher.as_deref(), query.language.as_deref(), &pagination, ) .await?; let roots = state.config.read().await.directories.roots.clone(); let response: Vec = items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(response)) } /// List all series with book counts pub async fn list_series( State(state): State, ) -> Result { let series = state.storage.list_series().await?; let response: Vec = series .into_iter() .map(|(name, count)| { SeriesSummary { name, book_count: count, } }) .collect(); Ok(Json(response)) } /// Get books in a specific series pub async fn get_series_books( State(state): State, Path(series_name): Path, ) -> Result { let items = state.storage.get_series_books(&series_name).await?; let roots = state.config.read().await.directories.roots.clone(); let response: Vec = items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(response)) } /// List all authors with book counts pub async fn list_authors( State(state): State, Query(pagination): Query, ) -> Result { let authors = state.storage.list_all_authors(&pagination).await?; let response: Vec = authors .into_iter() .map(|(name, count)| { AuthorSummary { name, book_count: count, } }) .collect(); Ok(Json(response)) } /// Get books by a specific author pub async fn get_author_books( State(state): State, Path(author_name): Path, Query(pagination): Query, ) -> Result { let items = state .storage .search_books(None, Some(&author_name), None, None, None, &pagination) .await?; let roots = state.config.read().await.directories.roots.clone(); let response: Vec = items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(response)) } /// Get reading progress for a book pub async fn get_reading_progress( State(state): State, Extension(username): Extension, Path(media_id): Path, ) -> Result { let user_id = resolve_user_id(&state.storage, &username).await?; let media_id = MediaId(media_id); let progress = state .storage .get_reading_progress(user_id.0, media_id) .await? .ok_or(ApiError(PinakesError::NotFound( "Reading progress not found".to_string(), )))?; Ok(Json(ReadingProgressResponse::from(progress))) } /// Update reading progress for a book pub async fn update_reading_progress( State(state): State, Extension(username): Extension, Path(media_id): Path, Json(req): Json, ) -> Result { if req.current_page < 0 { return Err(ApiError::bad_request("current_page must be non-negative")); } let user_id = resolve_user_id(&state.storage, &username).await?; let media_id = MediaId(media_id); state .storage .update_reading_progress(user_id.0, media_id, req.current_page) .await?; Ok(StatusCode::NO_CONTENT) } /// Get user's reading list pub async fn get_reading_list( State(state): State, Extension(username): Extension, Query(params): Query, ) -> Result { let user_id = resolve_user_id(&state.storage, &username).await?; let items = state .storage .get_reading_list(user_id.0, params.status) .await?; let roots = state.config.read().await.directories.roots.clone(); let response: Vec = items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(response)) } #[derive(Debug, Deserialize)] pub struct ReadingListQuery { pub status: Option, } /// Build the books router pub fn routes() -> Router { Router::new() // Metadata routes .route("/{id}/metadata", get(get_book_metadata)) // Browse routes .route("/", get(list_books)) .route("/series", get(list_series)) .route("/series/{name}", get(get_series_books)) .route("/authors", get(list_authors)) .route("/authors/{name}/books", get(get_author_books)) // Reading progress routes .route("/{id}/progress", get(get_reading_progress)) .route("/{id}/progress", put(update_reading_progress)) .route("/reading-list", get(get_reading_list)) }