treewide: complete book management interface
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If5a21f16221f3c56a8008e139f93edc46a6a6964
This commit is contained in:
parent
bda36ac152
commit
2f31242442
23 changed files with 1693 additions and 126 deletions
|
|
@ -100,6 +100,8 @@ pub fn create_router_with_tls(
|
|||
.route("/media/{id}", get(routes::media::get_media))
|
||||
.route("/media/{id}/thumbnail", get(routes::media::get_thumbnail))
|
||||
.route("/media/{media_id}/tags", get(routes::tags::get_media_tags))
|
||||
// Books API
|
||||
.nest("/books", routes::books::routes())
|
||||
.route("/tags", get(routes::tags::list_tags))
|
||||
.route("/tags/{id}", get(routes::tags::get_tag))
|
||||
.route("/collections", get(routes::collections::list_collections))
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ async fn main() -> Result<()> {
|
|||
.server
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map_or(false, |k| !k.is_empty());
|
||||
.is_some_and(|k| !k.is_empty());
|
||||
let has_accounts = !config.accounts.users.is_empty();
|
||||
if !has_api_key && !has_accounts {
|
||||
tracing::error!("⚠️ No authentication method configured!");
|
||||
|
|
@ -425,7 +425,6 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
};
|
||||
();
|
||||
drop(cancel);
|
||||
})
|
||||
},
|
||||
|
|
|
|||
315
crates/pinakes-server/src/routes/books.rs
Normal file
315
crates/pinakes-server/src/routes/books.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, put},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use pinakes_core::{
|
||||
error::PinakesError,
|
||||
model::{AuthorInfo, BookMetadata, MediaId, Pagination, ReadingProgress, ReadingStatus},
|
||||
};
|
||||
|
||||
use crate::{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>,
|
||||
Path(media_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// TODO: Get user_id from auth context
|
||||
let user_id = Uuid::new_v4(); // Placeholder
|
||||
let media_id = MediaId(media_id);
|
||||
|
||||
let progress = state
|
||||
.storage
|
||||
.get_reading_progress(user_id, 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>,
|
||||
Path(media_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateProgressRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// TODO: Get user_id from auth context
|
||||
let user_id = Uuid::new_v4(); // Placeholder
|
||||
let media_id = MediaId(media_id);
|
||||
|
||||
state
|
||||
.storage
|
||||
.update_reading_progress(user_id, 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>,
|
||||
Query(params): Query<ReadingListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// TODO: Get user_id from auth context
|
||||
let user_id = Uuid::new_v4(); // Placeholder
|
||||
|
||||
let items = state
|
||||
.storage
|
||||
.get_reading_list(user_id, 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))
|
||||
}
|
||||
|
|
@ -75,10 +75,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
|||
response.database = Some(db_health);
|
||||
|
||||
// Check filesystem health (root directories)
|
||||
let roots = match state.storage.list_root_dirs().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let roots: Vec<std::path::PathBuf> = state.storage.list_root_dirs().await.unwrap_or_default();
|
||||
let roots_accessible = roots.iter().filter(|r| r.exists()).count();
|
||||
if roots_accessible < roots.len() {
|
||||
response.status = "degraded".to_string();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod analytics;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod books;
|
||||
pub mod collections;
|
||||
pub mod config;
|
||||
pub mod database;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue