pinakes/crates/pinakes-server/src/routes/books.rs
NotAShelf 185e3b562a
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-12 19:41:15 +03:00

357 lines
9.2 KiB
Rust

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<String>,
pub isbn13: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub publication_date: Option<String>,
pub series_name: Option<String>,
pub series_index: Option<f64>,
pub format: Option<String>,
pub authors: Vec<AuthorResponse>,
pub identifiers: std::collections::HashMap<String, Vec<String>>,
}
impl From<BookMetadata> 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<String>,
pub position: i32,
}
impl From<AuthorInfo> 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<i32>,
pub progress_percent: f64,
pub last_read_at: String,
}
impl From<ReadingProgress> 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<String>,
pub author: Option<String>,
pub series: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
#[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<AppState>,
Path(media_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Query(query): Query<SearchBooksQuery>,
) -> Result<impl IntoResponse, ApiError> {
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<MediaResponse> = 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<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let series = state.storage.list_series().await?;
let response: Vec<SeriesSummary> = 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<AppState>,
Path(series_name): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let items = state.storage.get_series_books(&series_name).await?;
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<MediaResponse> = 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<AppState>,
Query(pagination): Query<Pagination>,
) -> Result<impl IntoResponse, ApiError> {
let authors = state.storage.list_all_authors(&pagination).await?;
let response: Vec<AuthorSummary> = 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<AppState>,
Path(author_name): Path<String>,
Query(pagination): Query<Pagination>,
) -> Result<impl IntoResponse, ApiError> {
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<MediaResponse> = 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<AppState>,
Extension(username): Extension<String>,
Path(media_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Extension(username): Extension<String>,
Path(media_id): Path<Uuid>,
Json(req): Json<UpdateProgressRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Extension(username): Extension<String>,
Query(params): Query<ReadingListQuery>,
) -> Result<impl IntoResponse, ApiError> {
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<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(response))
}
#[derive(Debug, Deserialize)]
pub struct ReadingListQuery {
pub status: Option<ReadingStatus>,
}
/// Build the books router
pub fn routes() -> Router<AppState> {
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))
}