treewide: complete book management interface

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If5a21f16221f3c56a8008e139f93edc46a6a6964
This commit is contained in:
raf 2026-02-04 23:14:37 +03:00
commit 2f31242442
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
23 changed files with 1693 additions and 126 deletions

View file

@ -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))

View file

@ -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);
})
},

View 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))
}

View file

@ -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();

View file

@ -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;