Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
342 lines
8.7 KiB
Rust
342 lines
8.7 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,
|
|
}
|
|
|
|
fn default_offset() -> u64 {
|
|
0
|
|
}
|
|
|
|
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,
|
|
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 response: Vec<MediaResponse> =
|
|
items.into_iter().map(MediaResponse::from).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 response: Vec<MediaResponse> =
|
|
items.into_iter().map(MediaResponse::from).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 response: Vec<MediaResponse> =
|
|
items.into_iter().map(MediaResponse::from).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> {
|
|
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 response: Vec<MediaResponse> =
|
|
items.into_iter().map(MediaResponse::from).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))
|
|
}
|