diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 9f45da41..7468ab65 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -356,6 +356,38 @@ The following paths are exempt from rate limiting: v1::handlers::mark_series_as_read, v1::handlers::mark_series_as_unread, + // Want to read (per-user queue) endpoints + v1::handlers::list_want_to_read, + v1::handlers::add_want_to_read, + v1::handlers::remove_want_to_read_series, + v1::handlers::remove_want_to_read_book, + + // Collections endpoints + v1::handlers::list_collections, + v1::handlers::create_collection, + v1::handlers::get_collection, + v1::handlers::update_collection, + v1::handlers::delete_collection, + v1::handlers::get_collection_series, + v1::handlers::add_collection_series, + v1::handlers::remove_collection_series, + v1::handlers::reorder_collection_series, + v1::handlers::get_collection_thumbnail, + v1::handlers::get_series_collections, + + // Read lists endpoints + v1::handlers::list_readlists, + v1::handlers::create_readlist, + v1::handlers::get_readlist, + v1::handlers::update_readlist, + v1::handlers::delete_readlist, + v1::handlers::get_readlist_books, + v1::handlers::add_readlist_books, + v1::handlers::remove_readlist_book, + v1::handlers::reorder_readlist_books, + v1::handlers::get_readlist_thumbnail, + v1::handlers::get_book_readlists, + // Bulk operations endpoints v1::handlers::bulk_mark_books_as_read, v1::handlers::bulk_mark_books_as_unread, @@ -563,6 +595,10 @@ The following paths are exempt from rate limiting: opds::handlers::catalog::list_libraries, opds::handlers::catalog::library_series, opds::handlers::catalog::series_books, + opds::handlers::catalog::list_collections, + opds::handlers::catalog::collection_series, + opds::handlers::catalog::list_readlists, + opds::handlers::catalog::readlist_books, opds::handlers::search::opensearch_descriptor, opds::handlers::search::search, opds::handlers::pse::book_pages, @@ -573,6 +609,10 @@ The following paths are exempt from rate limiting: opds2::handlers::catalog::libraries, opds2::handlers::catalog::library_series, opds2::handlers::catalog::series_books, + opds2::handlers::catalog::list_collections, + opds2::handlers::catalog::collection_series, + opds2::handlers::catalog::list_readlists, + opds2::handlers::catalog::readlist_books, opds2::handlers::catalog::recent, opds2::handlers::search::search, @@ -605,7 +645,15 @@ The following paths are exempt from rate limiting: // Komga stub endpoints (empty responses for third-party app compatibility) komga::handlers::list_collections, + komga::handlers::get_collection, + komga::handlers::get_collection_series, + komga::handlers::get_collection_thumbnail, + komga::handlers::get_series_collections, komga::handlers::list_readlists, + komga::handlers::get_readlist, + komga::handlers::get_readlist_books, + komga::handlers::get_readlist_thumbnail, + komga::handlers::get_book_readlists, komga::handlers::list_genres, komga::handlers::list_tags, komga::handlers::list_authors_v2, @@ -917,6 +965,28 @@ The following paths are exempt from rate limiting: v1::dto::ReadProgressListResponse, v1::dto::MarkReadResponse, + // Want to read (per-user queue) DTOs + v1::dto::WantToReadEntryDto, + v1::dto::WantToReadListResponse, + v1::dto::AddWantToReadRequest, + v1::dto::WantToReadItemType, + + // Collections DTOs + v1::dto::CollectionDto, + v1::dto::CollectionListResponse, + v1::dto::CreateCollectionRequest, + v1::dto::UpdateCollectionRequest, + v1::dto::AddSeriesToCollectionRequest, + v1::dto::ReorderCollectionSeriesRequest, + + // Read lists DTOs + v1::dto::ReadListDto, + v1::dto::ReadListListResponse, + v1::dto::CreateReadListRequest, + v1::dto::UpdateReadListRequest, + v1::dto::AddBooksToReadListRequest, + v1::dto::ReorderReadListBooksRequest, + // Bulk operations DTOs v1::dto::BulkBooksRequest, v1::dto::BulkAnalyzeBooksRequest, diff --git a/crates/codex-api/src/routes/komga/handlers/books.rs b/crates/codex-api/src/routes/komga/handlers/books.rs index be1b498f..33e777fb 100644 --- a/crates/codex-api/src/routes/komga/handlers/books.rs +++ b/crates/codex-api/src/routes/komga/handlers/books.rs @@ -789,7 +789,10 @@ pub async fn download_book_file( // ============================================================================ /// Get series title from series metadata or series name -async fn get_series_title(state: &Arc, series_id: Uuid) -> Result { +pub(crate) async fn get_series_title( + state: &Arc, + series_id: Uuid, +) -> Result { if let Some(metadata) = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch series metadata: {}", e)))? diff --git a/crates/codex-api/src/routes/komga/handlers/collections.rs b/crates/codex-api/src/routes/komga/handlers/collections.rs new file mode 100644 index 00000000..ebb5a068 --- /dev/null +++ b/crates/codex-api/src/routes/komga/handlers/collections.rs @@ -0,0 +1,226 @@ +//! Komga-compatible collection endpoints (read-only). +//! +//! Backs the `KomgaCollectionDto` shape third-party Komga apps expect, sourced +//! from real Codex collections. Member series are filtered through the +//! requesting user's sharing-tag visibility. + +use super::super::dto::pagination::KomgaPage; +use super::super::dto::series::KomgaSeriesDto; +use super::super::dto::stubs::{KomgaCollectionDto, StubPaginationQuery}; +use super::series::build_series_dto; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + response::Redirect, +}; +use codex_db::repositories::{CollectionRepository, visibility::SeriesVisibility}; +use std::sync::Arc; +use uuid::Uuid; + +fn parse_id(value: &str) -> Result { + Uuid::parse_str(value).map_err(|_| ApiError::NotFound("Collection not found".to_string())) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {e}")))?; + Ok(filter.to_visibility()) +} + +async fn build_collection_dto( + state: &AuthState, + model: codex_db::entities::collections::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let members = CollectionRepository::get_series(&state.db, model.id, vis) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection series: {e}")))?; + Ok(KomgaCollectionDto { + id: model.id.to_string(), + name: model.name, + ordered: model.ordered, + series_ids: members.iter().map(|s| s.id.to_string()).collect(), + created_date: model.created_at.to_rfc3339(), + last_modified_date: model.updated_at.to_rfc3339(), + filtered: false, + }) +} + +/// List collections (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/collections", + responses((status = 200, body = KomgaPage), (status = 401)), + params(("prefix" = String, Path, description = "Komga API prefix")), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn list_collections( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Query(query): Query, +) -> Result>, ApiError> { + require_permission!(auth, Permission::SeriesRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list collections: {e}")))?; + let total = collections.len() as i64; + + let page = query.page.max(0); + let size = query.size.clamp(1, 500); + let start = (page as usize).saturating_mul(size as usize); + let page_models: Vec<_> = collections + .into_iter() + .skip(start) + .take(size as usize) + .collect(); + + let mut content = Vec::with_capacity(page_models.len()); + for model in page_models { + content.push(build_collection_dto(&state, model, vis.as_ref()).await?); + } + Ok(Json(KomgaPage::new(content, page, size, total))) +} + +/// Get a collection (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/collections/{collection_id}", + responses((status = 200, body = KomgaCollectionDto), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("collection_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_collection( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(collection_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::SeriesRead)?; + let id = parse_id(&collection_id)?; + let model = CollectionRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {e}")))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json( + build_collection_dto(&state, model, vis.as_ref()).await?, + )) +} + +/// Get the series in a collection (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/collections/{collection_id}/series", + responses((status = 200, body = KomgaPage), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("collection_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_collection_series( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(collection_id): Path, + Query(query): Query, +) -> Result>, ApiError> { + require_permission!(auth, Permission::SeriesRead)?; + let id = parse_id(&collection_id)?; + if CollectionRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {e}")))? + .is_none() + { + return Err(ApiError::NotFound("Collection not found".to_string())); + } + + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, id, vis.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection series: {e}")))?; + let total = members.len() as i64; + + let page = query.page.max(0); + let size = query.size.clamp(1, 500); + let start = (page as usize).saturating_mul(size as usize); + let page_members: Vec<_> = members + .into_iter() + .skip(start) + .take(size as usize) + .collect(); + + let mut content = Vec::with_capacity(page_members.len()); + for series in page_members { + content.push(build_series_dto(&state, &series, Some(auth.user_id)).await?); + } + Ok(Json(KomgaPage::new(content, page, size, total))) +} + +/// Get a collection's thumbnail (redirects to the first visible member series). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/collections/{collection_id}/thumbnail", + responses((status = 307), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("collection_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_collection_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(collection_id): Path, +) -> Result { + auth.require_permission(&Permission::SeriesRead)?; + let id = parse_id(&collection_id)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, id, vis.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection series: {e}")))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Collection has no visible series".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/series/{}/thumbnail", + first.id + ))) +} + +/// List the collections that contain a series (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/series/{series_id}/collections", + responses((status = 200, body = Vec)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("series_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_series_collections( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(series_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::SeriesRead)?; + let sid = Uuid::parse_str(&series_id) + .map_err(|_| ApiError::NotFound("Series not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::get_collections_for_series(&state.db, sid) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collections: {e}")))?; + let mut out = Vec::with_capacity(collections.len()); + for model in collections { + out.push(build_collection_dto(&state, model, vis.as_ref()).await?); + } + Ok(Json(out)) +} diff --git a/crates/codex-api/src/routes/komga/handlers/mod.rs b/crates/codex-api/src/routes/komga/handlers/mod.rs index 563acc10..aa4124c4 100644 --- a/crates/codex-api/src/routes/komga/handlers/mod.rs +++ b/crates/codex-api/src/routes/komga/handlers/mod.rs @@ -4,10 +4,12 @@ //! Handlers are implemented in separate modules by resource type. pub mod books; +pub mod collections; pub mod libraries; pub mod manifest; pub mod pages; pub mod read_progress; +pub mod readlists; pub mod series; pub mod stubs; pub mod users; @@ -17,6 +19,10 @@ pub use books::{ download_book_file, get_book, get_book_thumbnail, get_books_ondeck, get_next_book, get_previous_book, search_books, }; +pub use collections::{ + get_collection, get_collection_series, get_collection_thumbnail, get_series_collections, + list_collections, +}; pub use libraries::{get_library, get_library_thumbnail, list_libraries}; pub use manifest::{get_epub_manifest, get_epub_resource}; pub use pages::{get_page, get_page_thumbnail, list_pages}; @@ -24,13 +30,16 @@ pub use read_progress::{ delete_progress, get_progression, mark_series_as_read, mark_series_as_unread, put_progression, update_progress, }; +pub use readlists::{ + get_book_readlists, get_readlist, get_readlist_books, get_readlist_thumbnail, list_readlists, +}; pub use series::{ get_series, get_series_books, get_series_new, get_series_thumbnail, get_series_updated, list_series, search_series, }; pub use stubs::{ - list_age_ratings, list_authors_v2, list_collections, list_genres, list_languages, - list_publishers, list_readlists, list_series_release_dates, list_tags, + list_age_ratings, list_authors_v2, list_genres, list_languages, list_publishers, + list_series_release_dates, list_tags, }; pub use users::get_current_user; @@ -44,6 +53,12 @@ pub use books::{ }; #[doc(hidden)] #[allow(unused_imports)] +pub use collections::{ + __path_get_collection, __path_get_collection_series, __path_get_collection_thumbnail, + __path_get_series_collections, __path_list_collections, +}; +#[doc(hidden)] +#[allow(unused_imports)] pub use libraries::{__path_get_library, __path_get_library_thumbnail, __path_list_libraries}; #[doc(hidden)] #[allow(unused_imports)] @@ -59,6 +74,12 @@ pub use read_progress::{ }; #[doc(hidden)] #[allow(unused_imports)] +pub use readlists::{ + __path_get_book_readlists, __path_get_readlist, __path_get_readlist_books, + __path_get_readlist_thumbnail, __path_list_readlists, +}; +#[doc(hidden)] +#[allow(unused_imports)] pub use series::{ __path_get_series, __path_get_series_books, __path_get_series_new, __path_get_series_thumbnail, __path_get_series_updated, __path_list_series, __path_search_series, @@ -66,9 +87,8 @@ pub use series::{ #[doc(hidden)] #[allow(unused_imports)] pub use stubs::{ - __path_list_age_ratings, __path_list_authors_v2, __path_list_collections, __path_list_genres, - __path_list_languages, __path_list_publishers, __path_list_readlists, - __path_list_series_release_dates, __path_list_tags, + __path_list_age_ratings, __path_list_authors_v2, __path_list_genres, __path_list_languages, + __path_list_publishers, __path_list_series_release_dates, __path_list_tags, }; #[doc(hidden)] #[allow(unused_imports)] diff --git a/crates/codex-api/src/routes/komga/handlers/readlists.rs b/crates/codex-api/src/routes/komga/handlers/readlists.rs new file mode 100644 index 00000000..ef853fa2 --- /dev/null +++ b/crates/codex-api/src/routes/komga/handlers/readlists.rs @@ -0,0 +1,250 @@ +//! Komga-compatible read list endpoints (read-only). +//! +//! Backs the `KomgaReadListDto` shape third-party Komga apps expect, sourced +//! from real Codex read lists. Member books are filtered through the requesting +//! user's sharing-tag visibility. + +use super::super::dto::book::KomgaBookDto; +use super::super::dto::pagination::KomgaPage; +use super::super::dto::stubs::{KomgaReadListDto, StubPaginationQuery}; +use super::books::get_series_title; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + response::Redirect, +}; +use codex_db::repositories::{ + BookMetadataRepository, ReadListRepository, ReadProgressRepository, + visibility::SeriesVisibility, +}; +use std::sync::Arc; +use uuid::Uuid; + +fn parse_id(value: &str) -> Result { + Uuid::parse_str(value).map_err(|_| ApiError::NotFound("Read list not found".to_string())) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {e}")))?; + Ok(filter.to_visibility()) +} + +async fn build_readlist_dto( + state: &AuthState, + model: codex_db::entities::read_lists::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let members = ReadListRepository::get_books(&state.db, model.id, vis) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list books: {e}")))?; + Ok(KomgaReadListDto { + id: model.id.to_string(), + name: model.name, + summary: model.summary.unwrap_or_default(), + ordered: model.ordered, + book_ids: members.iter().map(|b| b.id.to_string()).collect(), + created_date: model.created_at.to_rfc3339(), + last_modified_date: model.updated_at.to_rfc3339(), + filtered: false, + }) +} + +/// List read lists (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/readlists", + responses((status = 200, body = KomgaPage), (status = 401)), + params(("prefix" = String, Path, description = "Komga API prefix")), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn list_readlists( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Query(query): Query, +) -> Result>, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list read lists: {e}")))?; + let total = read_lists.len() as i64; + + let page = query.page.max(0); + let size = query.size.clamp(1, 500); + let start = (page as usize).saturating_mul(size as usize); + let page_models: Vec<_> = read_lists + .into_iter() + .skip(start) + .take(size as usize) + .collect(); + + let mut content = Vec::with_capacity(page_models.len()); + for model in page_models { + content.push(build_readlist_dto(&state, model, vis.as_ref()).await?); + } + Ok(Json(KomgaPage::new(content, page, size, total))) +} + +/// Get a read list (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/readlists/{read_list_id}", + responses((status = 200, body = KomgaReadListDto), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("read_list_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_readlist( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(read_list_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + let id = parse_id(&read_list_id)?; + let model = ReadListRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {e}")))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(build_readlist_dto(&state, model, vis.as_ref()).await?)) +} + +/// Get the books in a read list (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/readlists/{read_list_id}/books", + responses((status = 200, body = KomgaPage), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("read_list_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_readlist_books( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(read_list_id): Path, + Query(query): Query, +) -> Result>, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + let id = parse_id(&read_list_id)?; + if ReadListRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {e}")))? + .is_none() + { + return Err(ApiError::NotFound("Read list not found".to_string())); + } + + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, id, vis.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list books: {e}")))?; + let total = members.len() as i64; + + let page = query.page.max(0); + let size = query.size.clamp(1, 500); + let start = (page as usize).saturating_mul(size as usize); + let page_members: Vec<_> = members + .into_iter() + .skip(start) + .take(size as usize) + .collect(); + + let book_ids: Vec = page_members.iter().map(|b| b.id).collect(); + let metadata_map = BookMetadataRepository::get_by_book_ids(&state.db, &book_ids) + .await + .unwrap_or_default(); + let progress_map = + ReadProgressRepository::get_for_user_books(&state.db, auth.user_id, &book_ids) + .await + .unwrap_or_default(); + + let mut content = Vec::with_capacity(page_members.len()); + for book in page_members { + let series_title = get_series_title(&state, book.series_id).await?; + let meta = metadata_map.get(&book.id); + let book_number = meta + .and_then(|m| m.number) + .map(|d| d.to_string().parse::().unwrap_or(1)) + .unwrap_or(1); + let progress = progress_map.get(&book.id); + content.push(KomgaBookDto::from_codex_with_metadata( + &book, + &series_title, + book_number, + progress, + meta, + )); + } + Ok(Json(KomgaPage::new(content, page, size, total))) +} + +/// Get a read list's thumbnail (redirects to the first visible member book). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/readlists/{read_list_id}/thumbnail", + responses((status = 307), (status = 404)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("read_list_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_readlist_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(read_list_id): Path, +) -> Result { + auth.require_permission(&Permission::BooksRead)?; + let id = parse_id(&read_list_id)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, id, vis.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list books: {e}")))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Read list has no visible books".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/books/{}/thumbnail", + first.id + ))) +} + +/// List the read lists that contain a book (Komga-compatible). +#[utoipa::path( + get, + path = "/{prefix}/api/v1/books/{book_id}/readlists", + responses((status = 200, body = Vec)), + params(("prefix" = String, Path, description = "Komga API prefix"), ("book_id" = String, Path)), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "Komga" +)] +pub async fn get_book_readlists( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(book_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + let bid = + Uuid::parse_str(&book_id).map_err(|_| ApiError::NotFound("Book not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::get_read_lists_for_book(&state.db, bid) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read lists: {e}")))?; + let mut out = Vec::with_capacity(read_lists.len()); + for model in read_lists { + out.push(build_readlist_dto(&state, model, vis.as_ref()).await?); + } + Ok(Json(out)) +} diff --git a/crates/codex-api/src/routes/komga/handlers/series.rs b/crates/codex-api/src/routes/komga/handlers/series.rs index 5dfa2b81..d2b612b6 100644 --- a/crates/codex-api/src/routes/komga/handlers/series.rs +++ b/crates/codex-api/src/routes/komga/handlers/series.rs @@ -745,7 +745,7 @@ pub async fn get_series_books( // ============================================================================ /// Build a KomgaSeriesDto from a series entity -async fn build_series_dto( +pub(crate) async fn build_series_dto( state: &Arc, series: &codex_db::entities::series::Model, user_id: Option, diff --git a/crates/codex-api/src/routes/komga/handlers/stubs.rs b/crates/codex-api/src/routes/komga/handlers/stubs.rs index 72d925d2..6bd039e3 100644 --- a/crates/codex-api/src/routes/komga/handlers/stubs.rs +++ b/crates/codex-api/src/routes/komga/handlers/stubs.rs @@ -3,94 +3,17 @@ //! These handlers return empty results for endpoints that Komic expects //! but Codex doesn't fully support. This prevents 404 errors in the client. -use super::super::dto::pagination::KomgaPage; use super::super::dto::series::KomgaAuthorDto; -use super::super::dto::stubs::{KomgaCollectionDto, KomgaReadListDto, StubPaginationQuery}; use crate::require_permission; use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use axum::{ - Json, - extract::{Query, State}, -}; +use axum::{Json, extract::State}; use codex_db::repositories::{GenreRepository, TagRepository}; use std::sync::Arc; -/// List collections (stub - always returns empty) -/// -/// Komga collections are user-created groupings of series. -/// Codex doesn't support this feature, so we return empty results. -/// -/// ## Endpoint -/// `GET /{prefix}/api/v1/collections` -/// -/// ## Authentication -/// - Bearer token (JWT) -/// - Basic Auth -/// - API Key -#[utoipa::path( - get, - path = "/{prefix}/api/v1/collections", - responses( - (status = 200, description = "Empty list of collections", body = KomgaPage), - (status = 401, description = "Unauthorized"), - ), - params( - ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), - ), - security( - ("jwt_bearer" = []), - ("api_key" = []) - ), - tag = "Komga" -)] -pub async fn list_collections( - FlexibleAuthContext(auth): FlexibleAuthContext, - Query(query): Query, -) -> Result>, ApiError> { - require_permission!(auth, Permission::SeriesRead)?; - Ok(Json(KomgaPage::new(vec![], query.page, query.size, 0))) -} - -/// List read lists (stub - always returns empty) -/// -/// Komga read lists are user-created lists of books to read. -/// Codex doesn't support this feature, so we return empty results. -/// -/// ## Endpoint -/// `GET /{prefix}/api/v1/readlists` -/// -/// ## Authentication -/// - Bearer token (JWT) -/// - Basic Auth -/// - API Key -#[utoipa::path( - get, - path = "/{prefix}/api/v1/readlists", - responses( - (status = 200, description = "Empty list of read lists", body = KomgaPage), - (status = 401, description = "Unauthorized"), - ), - params( - ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), - ), - security( - ("jwt_bearer" = []), - ("api_key" = []) - ), - tag = "Komga" -)] -pub async fn list_readlists( - FlexibleAuthContext(auth): FlexibleAuthContext, - Query(query): Query, -) -> Result>, ApiError> { - require_permission!(auth, Permission::SeriesRead)?; - Ok(Json(KomgaPage::new(vec![], query.page, query.size, 0))) -} - /// List genres /// /// Returns all genres in the library. diff --git a/crates/codex-api/src/routes/komga/routes/collections.rs b/crates/codex-api/src/routes/komga/routes/collections.rs new file mode 100644 index 00000000..1f81e387 --- /dev/null +++ b/crates/codex-api/src/routes/komga/routes/collections.rs @@ -0,0 +1,28 @@ +//! Komga-compatible collection routes (read-only). + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{Router, routing::get}; +use std::sync::Arc; + +/// Collection routes shared between Komga API v1 and v2. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/collections", get(handlers::list_collections)) + .route( + "/collections/{collection_id}", + get(handlers::get_collection), + ) + .route( + "/collections/{collection_id}/series", + get(handlers::get_collection_series), + ) + .route( + "/collections/{collection_id}/thumbnail", + get(handlers::get_collection_thumbnail), + ) + .route( + "/series/{series_id}/collections", + get(handlers::get_series_collections), + ) +} diff --git a/crates/codex-api/src/routes/komga/routes/mod.rs b/crates/codex-api/src/routes/komga/routes/mod.rs index fc9ffe91..41915fff 100644 --- a/crates/codex-api/src/routes/komga/routes/mod.rs +++ b/crates/codex-api/src/routes/komga/routes/mod.rs @@ -4,9 +4,11 @@ //! Routes are organized by resource type and composed into separate v1 and v2 routers. mod books; +mod collections; mod libraries; mod pages; mod read_progress; +mod readlists; mod series; mod stubs; mod users; @@ -36,6 +38,9 @@ fn shared_routes(state: Arc) -> Router> { .merge(read_progress::routes(state.clone())) // User routes .merge(users::routes(state.clone())) + // Collections + read lists (real data, read-only) + .merge(collections::routes(state.clone())) + .merge(readlists::routes(state.clone())) } /// Create the Komga-compatible API v1 router diff --git a/crates/codex-api/src/routes/komga/routes/readlists.rs b/crates/codex-api/src/routes/komga/routes/readlists.rs new file mode 100644 index 00000000..64b47922 --- /dev/null +++ b/crates/codex-api/src/routes/komga/routes/readlists.rs @@ -0,0 +1,25 @@ +//! Komga-compatible read list routes (read-only). + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{Router, routing::get}; +use std::sync::Arc; + +/// Read list routes shared between Komga API v1 and v2. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/readlists", get(handlers::list_readlists)) + .route("/readlists/{read_list_id}", get(handlers::get_readlist)) + .route( + "/readlists/{read_list_id}/books", + get(handlers::get_readlist_books), + ) + .route( + "/readlists/{read_list_id}/thumbnail", + get(handlers::get_readlist_thumbnail), + ) + .route( + "/books/{book_id}/readlists", + get(handlers::get_book_readlists), + ) +} diff --git a/crates/codex-api/src/routes/komga/routes/stubs.rs b/crates/codex-api/src/routes/komga/routes/stubs.rs index f0ff705b..96b6aa35 100644 --- a/crates/codex-api/src/routes/komga/routes/stubs.rs +++ b/crates/codex-api/src/routes/komga/routes/stubs.rs @@ -20,8 +20,6 @@ use std::sync::Arc; /// - `GET /age-ratings` - List age ratings (always empty) pub fn routes_v1(_state: Arc) -> Router> { Router::new() - .route("/collections", get(handlers::list_collections)) - .route("/readlists", get(handlers::list_readlists)) .route("/genres", get(handlers::list_genres)) .route("/tags", get(handlers::list_tags)) .route("/languages", get(handlers::list_languages)) diff --git a/crates/codex-api/src/routes/opds/handlers/catalog.rs b/crates/codex-api/src/routes/opds/handlers/catalog.rs index c36dba91..2d1cb671 100644 --- a/crates/codex-api/src/routes/opds/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds/handlers/catalog.rs @@ -12,8 +12,9 @@ use axum::{ }; use chrono::Utc; use codex_db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, + BookMetadataRepository, BookRepository, CollectionRepository, LibraryRepository, + ReadListRepository, ReadProgressRepository, SeriesMetadataRepository, SeriesRepository, + SettingsRepository, }; use serde::Deserialize; use std::sync::Arc; @@ -131,6 +132,22 @@ pub async fn root_catalog( format!("{}/recent", base_url), "Recent Additions", )), + ) + .add_entry( + OpdsEntry::new("urn:uuid:codex-collections", "Collections", now) + .with_content("text", "Browse collections of series") + .add_link(OpdsLink::subsection_link( + format!("{}/collections", base_url), + "Collections", + )), + ) + .add_entry( + OpdsEntry::new("urn:uuid:codex-readlists", "Read Lists", now) + .with_content("text", "Browse ordered reading lists") + .add_link(OpdsLink::subsection_link( + format!("{}/readlists", base_url), + "Read Lists", + )), ); let xml = feed @@ -345,6 +362,343 @@ pub async fn library_series( Ok(OpdsResponse(xml)) } +/// List collections (navigation feed) +#[utoipa::path( + get, + path = "/opds/collections", + responses( + (status = 200, description = "OPDS collections feed", content_type = "application/atom+xml"), + (status = 403, description = "Forbidden"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS" +)] +pub async fn list_collections( + State(state): State>, + auth: AuthContext, +) -> Result { + require_permission!(auth, Permission::SeriesRead)?; + + let now = Utc::now(); + let base_url = "/opds"; + let app_name = SettingsRepository::get_app_name(&state.db).await; + + let collections = CollectionRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collections: {}", e)))?; + + let mut feed = OpdsFeed::with_author( + "urn:uuid:codex-collections", + "Collections", + now, + false, + &app_name, + ) + .add_link(OpdsLink::self_link(format!("{}/collections", base_url))) + .add_link(OpdsLink::start_link(base_url.to_string())) + .add_link(OpdsLink::up_link(base_url.to_string(), "Home")); + + for collection in collections { + let entry = OpdsEntry::new( + format!("urn:uuid:collection-{}", collection.id), + collection.name.clone(), + collection.updated_at, + ) + .with_content("text", format!("Browse series in {}", collection.name)) + .add_link(OpdsLink::subsection_link( + format!("{}/collections/{}", base_url, collection.id), + collection.name.clone(), + )) + .add_link(OpdsLink::thumbnail_link(format!( + "/api/v1/collections/{}/thumbnail", + collection.id + ))) + .add_link(OpdsLink::cover_link(format!( + "/api/v1/collections/{}/thumbnail", + collection.id + ))); + feed = feed.add_entry(entry); + } + + let xml = feed + .to_xml() + .map_err(|e| ApiError::Internal(format!("Failed to serialize OPDS feed: {}", e)))?; + + Ok(OpdsResponse(xml)) +} + +/// List the series in a collection (navigation feed) +#[utoipa::path( + get, + path = "/opds/collections/{collection_id}", + params(("collection_id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "OPDS collection series feed", content_type = "application/atom+xml"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Collection not found"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS" +)] +pub async fn collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result { + require_permission!(auth, Permission::SeriesRead)?; + + let now = Utc::now(); + let base_url = "/opds"; + let app_name = SettingsRepository::get_app_name(&state.db).await; + + let collection = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; + let visibility = content_filter.to_visibility(); + + let series_list = + CollectionRepository::get_series(&state.db, collection_id, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection series: {}", e)))?; + + let mut feed = OpdsFeed::with_author( + format!("urn:uuid:collection-{}", collection_id), + format!("{} - Series", collection.name), + now, + false, + &app_name, + ) + .add_link(OpdsLink::self_link(format!( + "{}/collections/{}", + base_url, collection_id + ))) + .add_link(OpdsLink::start_link(base_url.to_string())) + .add_link(OpdsLink::up_link( + format!("{}/collections", base_url), + "Collections", + )); + + for series in series_list { + let series_name = SeriesMetadataRepository::get_by_series_id(&state.db, series.id) + .await + .ok() + .flatten() + .map(|m| m.title) + .unwrap_or_else(|| "Unknown Series".to_string()); + + let entry = OpdsEntry::new( + format!("urn:uuid:series-{}", series.id), + series_name.clone(), + series.updated_at, + ) + .add_link(OpdsLink::subsection_link( + format!("{}/series/{}", base_url, series.id), + series_name.clone(), + )) + .add_link(OpdsLink::thumbnail_link(format!( + "/api/v1/series/{}/thumbnail", + series.id + ))) + .add_link(OpdsLink::cover_link(format!( + "/api/v1/series/{}/thumbnail", + series.id + ))); + feed = feed.add_entry(entry); + } + + let xml = feed + .to_xml() + .map_err(|e| ApiError::Internal(format!("Failed to serialize OPDS feed: {}", e)))?; + + Ok(OpdsResponse(xml)) +} + +/// List read lists (navigation feed) +#[utoipa::path( + get, + path = "/opds/readlists", + responses( + (status = 200, description = "OPDS read lists feed", content_type = "application/atom+xml"), + (status = 403, description = "Forbidden"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS" +)] +pub async fn list_readlists( + State(state): State>, + auth: AuthContext, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + + let now = Utc::now(); + let base_url = "/opds"; + let app_name = SettingsRepository::get_app_name(&state.db).await; + + let read_lists = ReadListRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read lists: {}", e)))?; + + let mut feed = OpdsFeed::with_author( + "urn:uuid:codex-readlists", + "Read Lists", + now, + false, + &app_name, + ) + .add_link(OpdsLink::self_link(format!("{}/readlists", base_url))) + .add_link(OpdsLink::start_link(base_url.to_string())) + .add_link(OpdsLink::up_link(base_url.to_string(), "Home")); + + for read_list in read_lists { + let entry = OpdsEntry::new( + format!("urn:uuid:readlist-{}", read_list.id), + read_list.name.clone(), + read_list.updated_at, + ) + .with_content( + "text", + read_list + .summary + .clone() + .unwrap_or_else(|| format!("Books in {}", read_list.name)), + ) + .add_link(OpdsLink::subsection_link( + format!("{}/readlists/{}", base_url, read_list.id), + read_list.name.clone(), + )) + .add_link(OpdsLink::thumbnail_link(format!( + "/api/v1/readlists/{}/thumbnail", + read_list.id + ))) + .add_link(OpdsLink::cover_link(format!( + "/api/v1/readlists/{}/thumbnail", + read_list.id + ))); + feed = feed.add_entry(entry); + } + + let xml = feed + .to_xml() + .map_err(|e| ApiError::Internal(format!("Failed to serialize OPDS feed: {}", e)))?; + + Ok(OpdsResponse(xml)) +} + +/// List the books in a read list (acquisition feed) +#[utoipa::path( + get, + path = "/opds/readlists/{read_list_id}", + params(("read_list_id" = Uuid, Path, description = "Read list ID")), + responses( + (status = 200, description = "OPDS read list books feed", content_type = "application/atom+xml"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Read list not found"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS" +)] +pub async fn readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + + let now = Utc::now(); + let base_url = "/opds"; + let app_name = SettingsRepository::get_app_name(&state.db).await; + + let read_list = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; + let visibility = content_filter.to_visibility(); + + let books = ReadListRepository::get_books(&state.db, read_list_id, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list books: {}", e)))?; + + let mut feed = OpdsFeed::with_author( + format!("urn:uuid:readlist-{}", read_list_id), + format!("{} - Books", read_list.name), + now, + true, // Include PSE namespace + &app_name, + ) + .add_link(OpdsLink::self_link(format!( + "{}/readlists/{}", + base_url, read_list_id + ))) + .add_link(OpdsLink::start_link(base_url.to_string())) + .add_link(OpdsLink::up_link( + format!("{}/readlists", base_url), + "Read Lists", + )); + + for book in books { + let title = BookMetadataRepository::get_by_book_id(&state.db, book.id) + .await + .ok() + .flatten() + .and_then(|m| m.title) + .unwrap_or_else(|| "Untitled".to_string()); + + let mime_type = match book.format.as_str() { + "cbz" | "zip" => "application/zip", + "cbr" | "rar" => "application/x-rar-compressed", + "epub" => "application/epub+zip", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }; + + let last_read = + ReadProgressRepository::get_by_user_and_book(&state.db, auth.user_id, book.id) + .await + .ok() + .flatten() + .map(|progress| progress.current_page as u32); + + let entry = OpdsEntry::new( + format!("urn:uuid:book-{}", book.id), + title.clone(), + book.updated_at, + ) + .add_link(OpdsLink::acquisition_link( + format!("/api/v1/books/{}/file", book.id), + mime_type, + )) + .add_link(OpdsLink::pse_stream_link( + format!("{}/books/{}/pages", base_url, book.id), + book.page_count as u32, + last_read, + )) + .add_link(OpdsLink::thumbnail_link(format!( + "/api/v1/books/{}/thumbnail", + book.id + ))) + .add_link(OpdsLink::cover_link(format!( + "/api/v1/books/{}/thumbnail", + book.id + ))); + feed = feed.add_entry(entry); + } + + let xml = feed + .to_xml() + .map_err(|e| ApiError::Internal(format!("Failed to serialize OPDS feed: {}", e)))?; + + Ok(OpdsResponse(xml)) +} + /// List books in a series /// /// Returns an acquisition feed with all books in the specified series diff --git a/crates/codex-api/src/routes/opds/routes.rs b/crates/codex-api/src/routes/opds/routes.rs index 79b0bda1..7599df23 100644 --- a/crates/codex-api/src/routes/opds/routes.rs +++ b/crates/codex-api/src/routes/opds/routes.rs @@ -3,8 +3,9 @@ //! Defines all OPDS 1.2 catalog routes. use super::handlers::{ - book_page_image, book_pages, library_series, list_libraries, opensearch_descriptor, - root_catalog, search, series_books, + book_page_image, book_pages, collection_series, library_series, list_collections, + list_libraries, list_readlists, opensearch_descriptor, readlist_books, root_catalog, search, + series_books, }; use crate::extractors::AppState; use axum::{Router, routing::get}; @@ -17,6 +18,10 @@ pub fn create_router(state: Arc) -> Router { .route("/libraries", get(list_libraries)) .route("/libraries/{library_id}", get(library_series)) .route("/series/{series_id}", get(series_books)) + .route("/collections", get(list_collections)) + .route("/collections/{collection_id}", get(collection_series)) + .route("/readlists", get(list_readlists)) + .route("/readlists/{read_list_id}", get(readlist_books)) .route("/books/{book_id}/pages", get(book_pages)) .route("/books/{book_id}/pages/{page_number}", get(book_page_image)) .route("/search.xml", get(opensearch_descriptor)) diff --git a/crates/codex-api/src/routes/opds2/handlers/catalog.rs b/crates/codex-api/src/routes/opds2/handlers/catalog.rs index 1ae13154..c2acd00f 100644 --- a/crates/codex-api/src/routes/opds2/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds2/handlers/catalog.rs @@ -16,8 +16,9 @@ use axum::{ response::{IntoResponse, Response}, }; use codex_db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, + BookMetadataRepository, BookRepository, CollectionRepository, LibraryRepository, + ReadListRepository, ReadProgressRepository, SeriesMetadataRepository, SeriesRepository, + SettingsRepository, }; use std::sync::Arc; use uuid::Uuid; @@ -78,6 +79,8 @@ pub async fn root( vec![ Opds2Link::navigation_link(format!("{}/libraries", base_url), "All Libraries"), Opds2Link::new_link(format!("{}/recent", base_url), "Recent Additions"), + Opds2Link::navigation_link(format!("{}/collections", base_url), "Collections"), + Opds2Link::navigation_link(format!("{}/readlists", base_url), "Read Lists"), ], ) .with_subtitle("Digital library server for comics, manga, and ebooks"); @@ -493,3 +496,250 @@ pub async fn recent( Ok(Opds2Response(feed)) } + +/// List collections (OPDS 2.0 navigation feed) +#[utoipa::path( + get, + path = "/opds/v2/collections", + responses( + (status = 200, description = "OPDS 2.0 collections feed", content_type = "application/opds+json", body = Opds2Feed), + (status = 403, description = "Forbidden"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS 2.0" +)] +pub async fn list_collections( + State(state): State>, + auth: AuthContext, +) -> Result { + require_permission!(auth, Permission::SeriesRead)?; + let base_url = "/opds/v2"; + + let collections = CollectionRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collections: {}", e)))?; + + let nav_links: Vec = collections + .iter() + .map(|c| { + Opds2Link::navigation_link(format!("{}/collections/{}", base_url, c.id), c.name.clone()) + }) + .collect(); + + let feed = Opds2Feed::navigation( + "Collections", + vec![ + Opds2Link::self_link(format!("{}/collections", base_url)), + Opds2Link::start_link(base_url), + Opds2Link::up_link(base_url, "Home"), + ], + nav_links, + ); + + Ok(Opds2Response(feed)) +} + +/// List the series in a collection (OPDS 2.0 navigation feed) +#[utoipa::path( + get, + path = "/opds/v2/collections/{collection_id}", + params(("collection_id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "OPDS 2.0 collection series feed", content_type = "application/opds+json", body = Opds2Feed), + (status = 403, description = "Forbidden"), + (status = 404, description = "Collection not found"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS 2.0" +)] +pub async fn collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result { + require_permission!(auth, Permission::SeriesRead)?; + let base_url = "/opds/v2"; + + let collection = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; + let visibility = content_filter.to_visibility(); + + let series_list = + CollectionRepository::get_series(&state.db, collection_id, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch collection series: {}", e)))?; + + let mut nav_links = Vec::with_capacity(series_list.len()); + for series in &series_list { + let series_name = SeriesMetadataRepository::get_by_series_id(&state.db, series.id) + .await + .ok() + .flatten() + .map(|m| m.title) + .unwrap_or_else(|| "Unknown Series".to_string()); + nav_links.push(Opds2Link::navigation_link( + format!("{}/series/{}", base_url, series.id), + series_name, + )); + } + + let feed = Opds2Feed::navigation( + format!("{} - Series", collection.name), + vec![ + Opds2Link::self_link(format!("{}/collections/{}", base_url, collection_id)), + Opds2Link::start_link(base_url), + Opds2Link::up_link(format!("{}/collections", base_url), "Collections"), + ], + nav_links, + ); + + Ok(Opds2Response(feed)) +} + +/// List read lists (OPDS 2.0 navigation feed) +#[utoipa::path( + get, + path = "/opds/v2/readlists", + responses( + (status = 200, description = "OPDS 2.0 read lists feed", content_type = "application/opds+json", body = Opds2Feed), + (status = 403, description = "Forbidden"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS 2.0" +)] +pub async fn list_readlists( + State(state): State>, + auth: AuthContext, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + let base_url = "/opds/v2"; + + let read_lists = ReadListRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read lists: {}", e)))?; + + let nav_links: Vec = read_lists + .iter() + .map(|r| { + Opds2Link::navigation_link(format!("{}/readlists/{}", base_url, r.id), r.name.clone()) + }) + .collect(); + + let feed = Opds2Feed::navigation( + "Read Lists", + vec![ + Opds2Link::self_link(format!("{}/readlists", base_url)), + Opds2Link::start_link(base_url), + Opds2Link::up_link(base_url, "Home"), + ], + nav_links, + ); + + Ok(Opds2Response(feed)) +} + +/// List the books in a read list (OPDS 2.0 publications feed) +#[utoipa::path( + get, + path = "/opds/v2/readlists/{read_list_id}", + params(("read_list_id" = Uuid, Path, description = "Read list ID")), + responses( + (status = 200, description = "OPDS 2.0 read list books feed", content_type = "application/opds+json", body = Opds2Feed), + (status = 403, description = "Forbidden"), + (status = 404, description = "Read list not found"), + ), + security(("jwt_bearer" = []), ("api_key" = [])), + tag = "OPDS 2.0" +)] +pub async fn readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + let base_url = "/opds/v2"; + + let read_list = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; + let visibility = content_filter.to_visibility(); + + let books = ReadListRepository::get_books(&state.db, read_list_id, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch read list books: {}", e)))?; + + let user_id = auth.user_id; + let mut publications: Vec = Vec::with_capacity(books.len()); + for book in &books { + let book_meta = BookMetadataRepository::get_by_book_id(&state.db, book.id) + .await + .ok() + .flatten(); + let title = book_meta + .as_ref() + .and_then(|m| m.title.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + + let mime_type = match book.format.as_str() { + "cbz" | "zip" => "application/zip", + "cbr" | "rar" => "application/x-rar-compressed", + "epub" => "application/epub+zip", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }; + + let metadata = PublicationMetadata::new(title) + .with_identifier(format!("urn:uuid:{}", book.id)) + .with_modified(book.updated_at) + .with_page_count(book.page_count); + + let mut publication = Publication::new(metadata) + .add_link(Opds2Link::acquisition_link( + format!("/api/v1/books/{}/file", book.id), + mime_type, + )) + .add_image(ImageLink::thumbnail(format!( + "/api/v1/books/{}/thumbnail", + book.id + ))); + + if let Ok(Some(progress)) = + ReadProgressRepository::get_by_user_and_book(&state.db, user_id, book.id).await + { + publication = publication.with_reading_progress(ReadingProgress::new( + progress.current_page, + book.page_count, + progress.completed, + Some(progress.updated_at), + )); + } + + publications.push(publication); + } + + let total = publications.len() as i64; + + let feed = Opds2Feed::publications( + format!("{} - Books", read_list.name), + vec![ + Opds2Link::self_link(format!("{}/readlists/{}", base_url, read_list_id)), + Opds2Link::start_link(base_url), + Opds2Link::up_link(format!("{}/readlists", base_url), "Read Lists"), + ], + publications, + ) + .with_pagination(total, total as i32, 1); + + Ok(Opds2Response(feed)) +} diff --git a/crates/codex-api/src/routes/opds2/routes.rs b/crates/codex-api/src/routes/opds2/routes.rs index b4b83a88..db0b3954 100644 --- a/crates/codex-api/src/routes/opds2/routes.rs +++ b/crates/codex-api/src/routes/opds2/routes.rs @@ -2,7 +2,10 @@ //! //! Defines all OPDS 2.0 catalog routes (JSON-based). -use super::handlers::{libraries, library_series, recent, root, search, series_books}; +use super::handlers::{ + collection_series, libraries, library_series, list_collections, list_readlists, readlist_books, + recent, root, search, series_books, +}; use crate::extractors::AuthState; use axum::{Router, routing::get}; use std::sync::Arc; @@ -16,6 +19,10 @@ pub fn create_router(state: Arc) -> Router { .route("/libraries", get(libraries)) .route("/libraries/{library_id}", get(library_series)) .route("/series/{series_id}", get(series_books)) + .route("/collections", get(list_collections)) + .route("/collections/{collection_id}", get(collection_series)) + .route("/readlists", get(list_readlists)) + .route("/readlists/{read_list_id}", get(readlist_books)) .route("/recent", get(recent)) .route("/search", get(search)) .with_state(state) diff --git a/crates/codex-api/src/routes/v1/dto/book.rs b/crates/codex-api/src/routes/v1/dto/book.rs index 344bf8ea..8c69e6fa 100644 --- a/crates/codex-api/src/routes/v1/dto/book.rs +++ b/crates/codex-api/src/routes/v1/dto/book.rs @@ -512,6 +512,14 @@ pub struct BookDto { #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 42.5)] pub chapter: Option, + + /// Whether the requesting user has this book in their want-to-read queue. + /// + /// `None` when not computed for this response; populated on book list and + /// detail endpoints that have a user context. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Book list response @@ -2115,6 +2123,11 @@ pub struct FullBookResponse { /// When the book was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this book in their want-to-read queue. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } // ============================================================================= diff --git a/crates/codex-api/src/routes/v1/dto/collection.rs b/crates/codex-api/src/routes/v1/dto/collection.rs new file mode 100644 index 00000000..9bda159b --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/collection.rs @@ -0,0 +1,85 @@ +//! DTOs for collections (shared, ordered groupings of series). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// A collection of series. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CollectionDto { + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + #[schema(example = "Batman")] + pub name: String, + /// When true, members are kept in manual order; otherwise sorted by title. + #[schema(example = false)] + pub ordered: bool, + /// Number of member series visible to the requesting user. + #[schema(example = 12)] + pub series_count: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl CollectionDto { + pub fn from_model(model: codex_db::entities::collections::Model, series_count: u64) -> Self { + Self { + id: model.id, + name: model.name, + ordered: model.ordered, + series_count, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +/// List of collections. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CollectionListResponse { + pub items: Vec, + #[schema(example = 3)] + pub total: usize, +} + +/// Request to create a collection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateCollectionRequest { + #[schema(example = "Batman")] + pub name: String, + /// Defaults to `false` (members sorted by title). + #[serde(default)] + #[schema(example = false)] + pub ordered: bool, +} + +/// Request to update a collection. Absent fields are left unchanged. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCollectionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordered: Option, +} + +/// Request to add one or more series to a collection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesToCollectionRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001"]))] + pub series_ids: Vec, +} + +/// Request to set the manual order of a collection's series. IDs not currently +/// members are ignored; omitted members keep their existing position. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReorderCollectionSeriesRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440002", "550e8400-e29b-41d4-a716-446655440001"]))] + pub series_ids: Vec, +} diff --git a/crates/codex-api/src/routes/v1/dto/mod.rs b/crates/codex-api/src/routes/v1/dto/mod.rs index 8f25040a..3e477c44 100644 --- a/crates/codex-api/src/routes/v1/dto/mod.rs +++ b/crates/codex-api/src/routes/v1/dto/mod.rs @@ -8,6 +8,7 @@ pub mod auth; pub mod book; pub mod bulk_metadata; pub mod cleanup; +pub mod collection; pub mod common; pub mod duplicates; pub mod filter; @@ -24,6 +25,7 @@ pub mod pdf_cache; pub mod plugin_storage; pub mod plugins; pub mod read_progress; +pub mod readlist; pub mod recommendations; pub mod release; pub mod scan; @@ -37,6 +39,7 @@ pub mod tracking; pub mod user; pub mod user_plugins; pub mod user_preferences; +pub mod want_to_read; pub use access_group::*; pub use api_key::*; @@ -45,6 +48,7 @@ pub use book::*; #[allow(unused_imports)] pub use bulk_metadata::*; pub use cleanup::*; +pub use collection::*; pub use common::*; pub use duplicates::*; pub use filter::*; @@ -61,6 +65,7 @@ pub use pdf_cache::*; pub use plugin_storage::*; pub use plugins::*; pub use read_progress::*; +pub use readlist::*; #[allow(unused_imports)] pub use recommendations::*; #[allow(unused_imports)] @@ -79,3 +84,4 @@ pub use user::*; #[allow(unused_imports)] pub use user_plugins::*; pub use user_preferences::*; +pub use want_to_read::*; diff --git a/crates/codex-api/src/routes/v1/dto/readlist.rs b/crates/codex-api/src/routes/v1/dto/readlist.rs new file mode 100644 index 00000000..f667dbe4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/readlist.rs @@ -0,0 +1,115 @@ +//! DTOs for read lists (shared, ordered groupings of books across series). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Deserialize a nullable field into a "double option" so the handler can tell +/// "field absent" (`None` → leave unchanged) from "field present and null" +/// (`Some(None)` → clear). Without this, serde collapses an explicit `null` +/// into the outer `None`. +fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + +/// A read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadListDto { + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + #[schema(example = "Civil War")] + pub name: String, + /// Optional description (Komga read lists carry a summary). + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// When true, members are kept in manual reading order; otherwise sorted by + /// release date. + #[schema(example = true)] + pub ordered: bool, + /// Number of member books visible to the requesting user. + #[schema(example = 24)] + pub book_count: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ReadListDto { + pub fn from_model(model: codex_db::entities::read_lists::Model, book_count: u64) -> Self { + Self { + id: model.id, + name: model.name, + summary: model.summary, + ordered: model.ordered, + book_count, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +/// List of read lists. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadListListResponse { + pub items: Vec, + #[schema(example = 3)] + pub total: usize, +} + +/// Request to create a read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateReadListRequest { + #[schema(example = "Civil War")] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Defaults to `true` (manual reading order). + #[serde(default = "default_true")] + #[schema(example = true)] + pub ordered: bool, +} + +fn default_true() -> bool { + true +} + +/// Request to update a read list. Absent fields are left unchanged. To clear the +/// summary, send `summary: null` explicitly. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateReadListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// `Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it. + #[serde( + default, + deserialize_with = "double_option", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordered: Option, +} + +/// Request to add one or more books to a read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddBooksToReadListRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001"]))] + pub book_ids: Vec, +} + +/// Request to set the manual order of a read list's books. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReorderReadListBooksRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440002", "550e8400-e29b-41d4-a716-446655440001"]))] + pub book_ids: Vec, +} diff --git a/crates/codex-api/src/routes/v1/dto/series.rs b/crates/codex-api/src/routes/v1/dto/series.rs index 6256bffd..21c24ceb 100644 --- a/crates/codex-api/src/routes/v1/dto/series.rs +++ b/crates/codex-api/src/routes/v1/dto/series.rs @@ -144,6 +144,14 @@ pub struct SeriesDto { /// When the series was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this series in their want-to-read queue. + /// + /// `None` when not computed for this response (e.g. list endpoints that + /// don't enrich it); populated on the series detail endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Series list response @@ -1290,6 +1298,11 @@ pub struct FullSeriesResponse { /// When the series was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this series in their want-to-read queue. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Request to update metadata lock states diff --git a/crates/codex-api/src/routes/v1/dto/want_to_read.rs b/crates/codex-api/src/routes/v1/dto/want_to_read.rs new file mode 100644 index 00000000..33bd95a0 --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/want_to_read.rs @@ -0,0 +1,94 @@ +//! DTOs for the per-user want-to-read queue. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +/// What a want-to-read entry points at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum WantToReadItemType { + Series, + Book, +} + +/// A single entry in a user's want-to-read queue. Exactly one of `series_id` / +/// `book_id` is populated, matching `item_type`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WantToReadEntryDto { + /// Queue entry ID. + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + /// Whether this entry flags a series or a book. + pub item_type: WantToReadItemType, + /// The flagged series (set when `item_type` is `series`). + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + /// The flagged book (set when `item_type` is `book`). + #[serde(skip_serializing_if = "Option::is_none")] + pub book_id: Option, + /// When the entry was added to the queue. + #[schema(example = "2026-06-15T18:45:00Z")] + pub added_at: DateTime, +} + +impl From for WantToReadEntryDto { + fn from(model: codex_db::entities::want_to_read::Model) -> Self { + let item_type = if model.series_id.is_some() { + WantToReadItemType::Series + } else { + WantToReadItemType::Book + }; + Self { + id: model.id, + item_type, + series_id: model.series_id, + book_id: model.book_id, + added_at: model.added_at, + } + } +} + +/// A user's want-to-read queue. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WantToReadListResponse { + /// Queue entries. + pub items: Vec, + /// Total number of entries. + #[schema(example = 7)] + pub total: usize, +} + +/// Request to add an entry to the queue. Exactly one of `series_id` / `book_id` +/// must be provided. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddWantToReadRequest { + /// Flag a series. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub series_id: Option, + /// Flag a book. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub book_id: Option, +} + +/// Query parameters for listing the queue. +#[derive(Debug, Default, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +#[into_params(parameter_in = Query)] +pub struct WantToReadListQuery { + /// Sort by add time. Accepts `added_at:asc` for oldest-first; any other + /// value (or omitted) yields newest-first (`added_at:desc`). + #[param(example = "added_at:desc")] + pub sort: Option, +} + +impl WantToReadListQuery { + /// Whether to sort ascending (oldest-first). Defaults to descending. + pub fn ascending(&self) -> bool { + matches!(self.sort.as_deref(), Some("added_at:asc") | Some("asc")) + } +} diff --git a/crates/codex-api/src/routes/v1/handlers/books.rs b/crates/codex-api/src/routes/v1/handlers/books.rs index 95c5381e..fddb1a37 100644 --- a/crates/codex-api/src/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -237,6 +237,12 @@ pub async fn books_to_dtos( .map(|(book_id, model)| (book_id, model.into())) .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = + codex_db::repositories::WantToReadRepository::book_ids_in_queue(db, user_id, &book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?; + // Convert books to DTOs let dtos = books .into_iter() @@ -311,6 +317,7 @@ pub async fn books_to_dtos( reading_direction, volume, chapter, + want_to_read: Some(want_to_read_ids.contains(&book.id)), } }) .collect(); @@ -348,6 +355,12 @@ pub async fn books_to_full_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = + codex_db::repositories::WantToReadRepository::book_ids_in_queue(db, user_id, &book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?; + // Fetch all related data in parallel, bounded so a single request cannot // hold one pool connection per query and exhaust the pool. let limiter = crate::db_batch::fan_out_limiter(crate::db_batch::configured_fan_out()); @@ -716,6 +729,7 @@ pub async fn books_to_full_dtos_batched( tags: book_tags, created_at: book.created_at, updated_at: book.updated_at, + want_to_read: Some(want_to_read_ids.contains(&book.id)), }); } diff --git a/crates/codex-api/src/routes/v1/handlers/collections.rs b/crates/codex-api/src/routes/v1/handlers/collections.rs new file mode 100644 index 00000000..8a1eeb09 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/collections.rs @@ -0,0 +1,464 @@ +//! Handlers for collections (shared, ordered groupings of series). +//! +//! Reads require `CollectionsRead` (granted to every role); create/modify +//! require `CollectionsWrite`; delete requires `CollectionsDelete` (write/delete +//! are in the Maintainer bundle). Member lists and counts are filtered through +//! the requesting user's sharing-tag visibility. + +use super::super::dto::{ + AddSeriesToCollectionRequest, CollectionDto, CollectionListResponse, CreateCollectionRequest, + ReorderCollectionSeriesRequest, SeriesDto, UpdateCollectionRequest, +}; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::Redirect, +}; +use codex_db::repositories::{ + CollectionRepository, SeriesRepository, visibility::SeriesVisibility, +}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_collections, + create_collection, + get_collection, + update_collection, + delete_collection, + get_collection_series, + add_collection_series, + remove_collection_series, + reorder_collection_series, + get_collection_thumbnail, + get_series_collections, + ), + components(schemas( + CollectionDto, + CollectionListResponse, + CreateCollectionRequest, + UpdateCollectionRequest, + AddSeriesToCollectionRequest, + ReorderCollectionSeriesRequest, + )), + tags( + (name = "Collections", description = "Shared, ordered groupings of series") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct CollectionsApi; + +fn internal(context: &str) -> impl Fn(E) -> ApiError + '_ { + move |e| ApiError::Internal(format!("{context}: {e}")) +} + +/// Build a CollectionDto with the requesting user's visible member count. +async fn collection_dto( + db: &sea_orm::DatabaseConnection, + model: codex_db::entities::collections::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let count = CollectionRepository::count_series(db, model.id, vis) + .await + .map_err(internal("Failed to count collection series"))?; + Ok(CollectionDto::from_model(model, count)) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(internal("Failed to load content filter"))?; + Ok(filter.to_visibility()) +} + +/// List all collections. +#[utoipa::path( + get, + path = "/api/v1/collections", + responses( + (status = 200, description = "Collections", body = CollectionListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn list_collections( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::list_all(&state.db) + .await + .map_err(internal("Failed to list collections"))?; + + let mut items = Vec::with_capacity(collections.len()); + for model in collections { + items.push(collection_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(CollectionListResponse { items, total })) +} + +/// Create a collection. +#[utoipa::path( + post, + path = "/api/v1/collections", + request_body = CreateCollectionRequest, + responses( + (status = 201, description = "Created", body = CollectionDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 409, description = "A collection with that name already exists"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn create_collection( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + + let name = request.name.trim(); + if name.is_empty() { + return Err(ApiError::BadRequest( + "Collection name cannot be empty".to_string(), + )); + } + if CollectionRepository::get_by_name(&state.db, name) + .await + .map_err(internal("Failed to check collection name"))? + .is_some() + { + return Err(ApiError::Conflict(format!( + "A collection named '{name}' already exists" + ))); + } + + let model = CollectionRepository::create(&state.db, name, request.ordered) + .await + .map_err(internal("Failed to create collection"))?; + Ok(( + StatusCode::CREATED, + Json(CollectionDto::from_model(model, 0)), + )) +} + +/// Get a collection. +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}", + responses( + (status = 200, description = "Collection", body = CollectionDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let model = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Update a collection (rename / toggle ordered). +#[utoipa::path( + patch, + path = "/api/v1/collections/{collection_id}", + request_body = UpdateCollectionRequest, + responses( + (status = 200, description = "Updated", body = CollectionDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 409, description = "Name already in use"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn update_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + + if let Some(ref new_name) = request.name { + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return Err(ApiError::BadRequest( + "Collection name cannot be empty".to_string(), + )); + } + if let Some(existing) = CollectionRepository::get_by_name(&state.db, trimmed) + .await + .map_err(internal("Failed to check collection name"))? + && existing.id != collection_id + { + return Err(ApiError::Conflict(format!( + "A collection named '{trimmed}' already exists" + ))); + } + } + + let model = CollectionRepository::update( + &state.db, + collection_id, + request.name.as_deref().map(str::trim), + request.ordered, + ) + .await + .map_err(internal("Failed to update collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Delete a collection. +#[utoipa::path( + delete, + path = "/api/v1/collections/{collection_id}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn delete_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result { + require_permission!(auth, Permission::CollectionsDelete)?; + let deleted = CollectionRepository::delete(&state.db, collection_id) + .await + .map_err(internal("Failed to delete collection"))?; + if !deleted { + return Err(ApiError::NotFound("Collection not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Get the series in a collection (visibility-filtered, in stored order). +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}/series", + responses( + (status = 200, description = "Member series", body = [SeriesDto]), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + ensure_collection_exists(&state, collection_id).await?; + + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, collection_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch collection series"))?; + + let dtos = + super::series::series_to_dtos_batched(&state.db, members, Some(auth.user_id)).await?; + Ok(Json(dtos)) +} + +/// Add one or more series to a collection. +#[utoipa::path( + post, + path = "/api/v1/collections/{collection_id}/series", + request_body = AddSeriesToCollectionRequest, + responses( + (status = 200, description = "Updated collection", body = CollectionDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Collection or series not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn add_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + let model = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + for series_id in &request.series_ids { + if SeriesRepository::get_by_id(&state.db, *series_id) + .await + .map_err(internal("Failed to look up series"))? + .is_none() + { + return Err(ApiError::NotFound(format!("Series {series_id} not found"))); + } + CollectionRepository::add_series(&state.db, collection_id, *series_id) + .await + .map_err(internal("Failed to add series to collection"))?; + } + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Remove a series from a collection. +#[utoipa::path( + delete, + path = "/api/v1/collections/{collection_id}/series/{series_id}", + responses( + (status = 204, description = "Removed (or was not a member)"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn remove_collection_series( + State(state): State>, + auth: AuthContext, + Path((collection_id, series_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_permission!(auth, Permission::CollectionsWrite)?; + CollectionRepository::remove_series(&state.db, collection_id, series_id) + .await + .map_err(internal("Failed to remove series from collection"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Set the manual order of a collection's series. +#[utoipa::path( + put, + path = "/api/v1/collections/{collection_id}/series", + request_body = ReorderCollectionSeriesRequest, + responses( + (status = 204, description = "Reordered"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn reorder_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result { + require_permission!(auth, Permission::CollectionsWrite)?; + ensure_collection_exists(&state, collection_id).await?; + CollectionRepository::reorder(&state.db, collection_id, &request.series_ids) + .await + .map_err(internal("Failed to reorder collection series"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Get a collection's thumbnail (the first visible member series' cover). +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}/thumbnail", + responses( + (status = 307, description = "Redirect to the first member series thumbnail"), + (status = 404, description = "No visible member series"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(collection_id): Path, +) -> Result { + auth.require_permission(&Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, collection_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch collection series"))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Collection has no visible series".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/series/{}/thumbnail", + first.id + ))) +} + +/// List the collections that contain a given series. +#[utoipa::path( + get, + path = "/api/v1/series/{series_id}/collections", + responses( + (status = 200, description = "Collections containing the series", body = CollectionListResponse), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_series_collections( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::get_collections_for_series(&state.db, series_id) + .await + .map_err(internal("Failed to fetch collections for series"))?; + + let mut items = Vec::with_capacity(collections.len()); + for model in collections { + items.push(collection_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(CollectionListResponse { items, total })) +} + +async fn ensure_collection_exists(state: &AuthState, collection_id: Uuid) -> Result<(), ApiError> { + if CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .is_none() + { + return Err(ApiError::NotFound("Collection not found".to_string())); + } + Ok(()) +} diff --git a/crates/codex-api/src/routes/v1/handlers/mod.rs b/crates/codex-api/src/routes/v1/handlers/mod.rs index d7db3245..7934482e 100644 --- a/crates/codex-api/src/routes/v1/handlers/mod.rs +++ b/crates/codex-api/src/routes/v1/handlers/mod.rs @@ -46,6 +46,7 @@ pub mod books; pub mod bulk; pub mod bulk_metadata; pub mod cleanup; +pub mod collections; pub mod duplicates; pub mod events; pub mod filesystem; @@ -63,6 +64,7 @@ pub mod plugin_actions; pub mod plugin_storage; pub mod plugins; pub mod read_progress; +pub mod readlists; pub mod recommendations; pub mod releases; pub mod scan; @@ -77,11 +79,13 @@ pub mod tracking; pub mod user_plugins; pub mod user_preferences; pub mod users; +pub mod want_to_read; pub use auth::*; pub use books::*; pub use bulk::*; pub use bulk_metadata::*; +pub use collections::*; pub use duplicates::*; pub use events::*; pub use filesystem::*; @@ -90,6 +94,8 @@ pub use libraries::*; pub use metrics::*; pub use pages::*; pub use read_progress::*; +pub use readlists::*; pub use scan::*; pub use series::*; pub use users::*; +pub use want_to_read::*; diff --git a/crates/codex-api/src/routes/v1/handlers/readlists.rs b/crates/codex-api/src/routes/v1/handlers/readlists.rs new file mode 100644 index 00000000..f1a95aa3 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/readlists.rs @@ -0,0 +1,460 @@ +//! Handlers for read lists (shared, ordered groupings of books across series). +//! +//! Reads require `ReadListsRead` (granted to every role); create/modify require +//! `ReadListsWrite`; delete requires `ReadListsDelete` (write/delete are in the +//! Maintainer bundle). Member lists and counts are filtered through the +//! requesting user's sharing-tag visibility. + +use super::super::dto::{ + AddBooksToReadListRequest, BookDto, CreateReadListRequest, ReadListDto, ReadListListResponse, + ReorderReadListBooksRequest, UpdateReadListRequest, +}; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::Redirect, +}; +use codex_db::repositories::{BookRepository, ReadListRepository, visibility::SeriesVisibility}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_readlists, + create_readlist, + get_readlist, + update_readlist, + delete_readlist, + get_readlist_books, + add_readlist_books, + remove_readlist_book, + reorder_readlist_books, + get_readlist_thumbnail, + get_book_readlists, + ), + components(schemas( + ReadListDto, + ReadListListResponse, + CreateReadListRequest, + UpdateReadListRequest, + AddBooksToReadListRequest, + ReorderReadListBooksRequest, + )), + tags( + (name = "Read Lists", description = "Shared, ordered groupings of books across series") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct ReadListsApi; + +fn internal(context: &str) -> impl Fn(E) -> ApiError + '_ { + move |e| ApiError::Internal(format!("{context}: {e}")) +} + +async fn readlist_dto( + db: &sea_orm::DatabaseConnection, + model: codex_db::entities::read_lists::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let count = ReadListRepository::count_books(db, model.id, vis) + .await + .map_err(internal("Failed to count read list books"))?; + Ok(ReadListDto::from_model(model, count)) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(internal("Failed to load content filter"))?; + Ok(filter.to_visibility()) +} + +async fn ensure_readlist_exists(state: &AuthState, read_list_id: Uuid) -> Result<(), ApiError> { + if ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .is_none() + { + return Err(ApiError::NotFound("Read list not found".to_string())); + } + Ok(()) +} + +/// List all read lists. +#[utoipa::path( + get, + path = "/api/v1/readlists", + responses( + (status = 200, description = "Read lists", body = ReadListListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn list_readlists( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::list_all(&state.db) + .await + .map_err(internal("Failed to list read lists"))?; + + let mut items = Vec::with_capacity(read_lists.len()); + for model in read_lists { + items.push(readlist_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(ReadListListResponse { items, total })) +} + +/// Create a read list. +#[utoipa::path( + post, + path = "/api/v1/readlists", + request_body = CreateReadListRequest, + responses( + (status = 201, description = "Created", body = ReadListDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 409, description = "A read list with that name already exists"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn create_readlist( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + + let name = request.name.trim(); + if name.is_empty() { + return Err(ApiError::BadRequest( + "Read list name cannot be empty".to_string(), + )); + } + if ReadListRepository::get_by_name(&state.db, name) + .await + .map_err(internal("Failed to check read list name"))? + .is_some() + { + return Err(ApiError::Conflict(format!( + "A read list named '{name}' already exists" + ))); + } + + let model = + ReadListRepository::create(&state.db, name, request.summary.as_deref(), request.ordered) + .await + .map_err(internal("Failed to create read list"))?; + Ok((StatusCode::CREATED, Json(ReadListDto::from_model(model, 0)))) +} + +/// Get a read list. +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}", + responses( + (status = 200, description = "Read list", body = ReadListDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let model = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Update a read list (rename / edit summary / toggle ordered). +#[utoipa::path( + patch, + path = "/api/v1/readlists/{read_list_id}", + request_body = UpdateReadListRequest, + responses( + (status = 200, description = "Updated", body = ReadListDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 409, description = "Name already in use"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn update_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + + if let Some(ref new_name) = request.name { + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return Err(ApiError::BadRequest( + "Read list name cannot be empty".to_string(), + )); + } + if let Some(existing) = ReadListRepository::get_by_name(&state.db, trimmed) + .await + .map_err(internal("Failed to check read list name"))? + && existing.id != read_list_id + { + return Err(ApiError::Conflict(format!( + "A read list named '{trimmed}' already exists" + ))); + } + } + + let summary = request.summary.as_ref().map(|inner| inner.as_deref()); + let model = ReadListRepository::update( + &state.db, + read_list_id, + request.name.as_deref().map(str::trim), + summary, + request.ordered, + ) + .await + .map_err(internal("Failed to update read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Delete a read list. +#[utoipa::path( + delete, + path = "/api/v1/readlists/{read_list_id}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn delete_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result { + require_permission!(auth, Permission::ReadListsDelete)?; + let deleted = ReadListRepository::delete(&state.db, read_list_id) + .await + .map_err(internal("Failed to delete read list"))?; + if !deleted { + return Err(ApiError::NotFound("Read list not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Get the books in a read list (visibility-filtered, in stored order). +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}/books", + responses( + (status = 200, description = "Member books", body = [BookDto]), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + ensure_readlist_exists(&state, read_list_id).await?; + + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, read_list_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch read list books"))?; + + let dtos = super::books::books_to_dtos(&state.db, auth.user_id, members).await?; + Ok(Json(dtos)) +} + +/// Add one or more books to a read list. +#[utoipa::path( + post, + path = "/api/v1/readlists/{read_list_id}/books", + request_body = AddBooksToReadListRequest, + responses( + (status = 200, description = "Updated read list", body = ReadListDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Read list or book not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn add_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + let model = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + for book_id in &request.book_ids { + if BookRepository::get_by_id(&state.db, *book_id) + .await + .map_err(internal("Failed to look up book"))? + .is_none() + { + return Err(ApiError::NotFound(format!("Book {book_id} not found"))); + } + ReadListRepository::add_book(&state.db, read_list_id, *book_id) + .await + .map_err(internal("Failed to add book to read list"))?; + } + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Remove a book from a read list. +#[utoipa::path( + delete, + path = "/api/v1/readlists/{read_list_id}/books/{book_id}", + responses( + (status = 204, description = "Removed (or was not a member)"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn remove_readlist_book( + State(state): State>, + auth: AuthContext, + Path((read_list_id, book_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_permission!(auth, Permission::ReadListsWrite)?; + ReadListRepository::remove_book(&state.db, read_list_id, book_id) + .await + .map_err(internal("Failed to remove book from read list"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Set the manual order of a read list's books. +#[utoipa::path( + put, + path = "/api/v1/readlists/{read_list_id}/books", + request_body = ReorderReadListBooksRequest, + responses( + (status = 204, description = "Reordered"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn reorder_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result { + require_permission!(auth, Permission::ReadListsWrite)?; + ensure_readlist_exists(&state, read_list_id).await?; + ReadListRepository::reorder(&state.db, read_list_id, &request.book_ids) + .await + .map_err(internal("Failed to reorder read list books"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Get a read list's thumbnail (the first visible member book's cover). +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}/thumbnail", + responses( + (status = 307, description = "Redirect to the first member book thumbnail"), + (status = 404, description = "No visible member books"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(read_list_id): Path, +) -> Result { + auth.require_permission(&Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, read_list_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch read list books"))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Read list has no visible books".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/books/{}/thumbnail", + first.id + ))) +} + +/// List the read lists that contain a given book. +#[utoipa::path( + get, + path = "/api/v1/books/{book_id}/readlists", + responses( + (status = 200, description = "Read lists containing the book", body = ReadListListResponse), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_book_readlists( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::get_read_lists_for_book(&state.db, book_id) + .await + .map_err(internal("Failed to fetch read lists for book"))?; + + let mut items = Vec::with_capacity(read_lists.len()); + for model in read_lists { + items.push(readlist_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(ReadListListResponse { items, total })) +} diff --git a/crates/codex-api/src/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs index 200f276f..f4fd5711 100644 --- a/crates/codex-api/src/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -42,6 +42,7 @@ use codex_db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesCoversRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, + WantToReadRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; use codex_services::release::upstream_gap::{UpstreamGap, UpstreamGapInputs, compute_upstream_gap}; @@ -210,6 +211,16 @@ async fn series_to_dto( external_ids: &external_ids, }); + // Per-user want-to-read flag (only when we know the requesting user). + let want_to_read = match user_id { + Some(uid) => Some( + WantToReadRepository::is_series_in_queue(db, uid, series.id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check want-to-read: {}", e)))?, + ), + None => None, + }; + Ok(SeriesDto { id: series.id, library_id: series.library_id, @@ -233,6 +244,7 @@ async fn series_to_dto( upstream_gap_provider, created_at: series.created_at, updated_at: series.updated_at, + want_to_read, }) } @@ -240,7 +252,7 @@ async fn series_to_dto( /// model using a single batched query per related table, rather than the /// per-row fan-out the single-row helper does. Order of `series_list` is /// preserved in the output. -async fn series_to_dtos_batched( +pub(crate) async fn series_to_dtos_batched( db: &DatabaseConnection, series_list: Vec, user_id: Option, @@ -259,6 +271,14 @@ async fn series_to_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = match user_id { + Some(uid) => WantToReadRepository::series_ids_in_queue(db, uid, &series_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?, + None => std::collections::HashSet::new(), + }; + // Bound how many of these related-table queries run at once so a single // request cannot hold one pool connection per query and exhaust the pool. let limiter = crate::db_batch::fan_out_limiter(crate::db_batch::configured_fan_out()); @@ -403,6 +423,7 @@ async fn series_to_dtos_batched( upstream_gap_provider, created_at: series.created_at, updated_at: series.updated_at, + want_to_read: user_id.map(|_| want_to_read_ids.contains(&series.id)), }); } @@ -433,6 +454,14 @@ async fn series_to_full_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = match user_id { + Some(uid) => WantToReadRepository::series_ids_in_queue(db, uid, &series_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?, + None => std::collections::HashSet::new(), + }; + // Fetch all related data using batched queries, but bound how many run at // once so a single request cannot hold one pool connection per query and // exhaust the pool under concurrent load. @@ -753,6 +782,7 @@ async fn series_to_full_dtos_batched( external_ids: ext_id_dtos, created_at: series.created_at, updated_at: series.updated_at, + want_to_read: user_id.map(|_| want_to_read_ids.contains(&series.id)), }); } diff --git a/crates/codex-api/src/routes/v1/handlers/want_to_read.rs b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs new file mode 100644 index 00000000..3d0d29c4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs @@ -0,0 +1,174 @@ +//! Handlers for the per-user want-to-read queue. +//! +//! The queue is personal: every handler scopes to `auth.user_id`. Being +//! authenticated is sufficient (no extra permission) — a user only ever manages +//! their own queue. + +use super::super::dto::{ + AddWantToReadRequest, WantToReadEntryDto, WantToReadItemType, WantToReadListQuery, + WantToReadListResponse, +}; +use crate::{AppState, error::ApiError, extractors::AuthContext}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use codex_db::repositories::{BookRepository, SeriesRepository, WantToReadRepository}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_want_to_read, + add_want_to_read, + remove_want_to_read_series, + remove_want_to_read_book, + ), + components(schemas( + WantToReadEntryDto, + WantToReadListResponse, + AddWantToReadRequest, + WantToReadItemType, + )), + tags( + (name = "Want to Read", description = "Per-user want-to-read queue endpoints") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct WantToReadApi; + +/// List the authenticated user's want-to-read queue. +#[utoipa::path( + get, + path = "/api/v1/want-to-read", + params(WantToReadListQuery), + responses( + (status = 200, description = "Queue retrieved", body = WantToReadListResponse), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn list_want_to_read( + State(state): State>, + auth: AuthContext, + Query(query): Query, +) -> Result, ApiError> { + let entries = WantToReadRepository::list(&state.db, auth.user_id, query.ascending()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list want-to-read queue: {e}")))?; + + let total = entries.len(); + let items = entries.into_iter().map(WantToReadEntryDto::from).collect(); + Ok(Json(WantToReadListResponse { items, total })) +} + +/// Add a series or book to the authenticated user's queue. +/// +/// Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging +/// something already queued returns the existing entry. +#[utoipa::path( + post, + path = "/api/v1/want-to-read", + request_body = AddWantToReadRequest, + responses( + (status = 201, description = "Entry added (or already present)", body = WantToReadEntryDto), + (status = 400, description = "Must provide exactly one of seriesId / bookId"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Series or book not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn add_want_to_read( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let entry = match (request.series_id, request.book_id) { + (Some(series_id), None) => { + SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to look up series: {e}")))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + WantToReadRepository::add_series(&state.db, auth.user_id, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to add series: {e}")))? + } + (None, Some(book_id)) => { + BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to look up book: {e}")))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + WantToReadRepository::add_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to add book: {e}")))? + } + _ => { + return Err(ApiError::BadRequest( + "Exactly one of seriesId or bookId must be provided".to_string(), + )); + } + }; + + Ok((StatusCode::CREATED, Json(entry.into()))) +} + +/// Remove a series from the authenticated user's queue. +#[utoipa::path( + delete, + path = "/api/v1/want-to-read/series/{series_id}", + responses( + (status = 204, description = "Removed (or was not present)"), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn remove_want_to_read_series( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, +) -> Result { + WantToReadRepository::remove_series(&state.db, auth.user_id, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to remove series: {e}")))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Remove a book from the authenticated user's queue. +#[utoipa::path( + delete, + path = "/api/v1/want-to-read/books/{book_id}", + responses( + (status = 204, description = "Removed (or was not present)"), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn remove_want_to_read_book( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, +) -> Result { + WantToReadRepository::remove_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to remove book: {e}")))?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/codex-api/src/routes/v1/routes/collections.rs b/crates/codex-api/src/routes/v1/routes/collections.rs new file mode 100644 index 00000000..37f1f73d --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/collections.rs @@ -0,0 +1,56 @@ +//! Collection routes +//! +//! Shared, ordered groupings of series. Reads are available to all roles; +//! create/modify/delete are gated by the collection permissions. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, patch, post, put}, +}; +use std::sync::Arc; + +/// Create collection routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/collections", get(handlers::list_collections)) + .route("/collections", post(handlers::create_collection)) + .route( + "/collections/{collection_id}", + get(handlers::get_collection), + ) + .route( + "/collections/{collection_id}", + patch(handlers::update_collection), + ) + .route( + "/collections/{collection_id}", + delete(handlers::delete_collection), + ) + .route( + "/collections/{collection_id}/series", + get(handlers::get_collection_series), + ) + .route( + "/collections/{collection_id}/series", + post(handlers::add_collection_series), + ) + .route( + "/collections/{collection_id}/series", + put(handlers::reorder_collection_series), + ) + .route( + "/collections/{collection_id}/series/{series_id}", + delete(handlers::remove_collection_series), + ) + .route( + "/collections/{collection_id}/thumbnail", + get(handlers::get_collection_thumbnail), + ) + // Reverse lookup: collections that contain a given series. + .route( + "/series/{series_id}/collections", + get(handlers::get_series_collections), + ) +} diff --git a/crates/codex-api/src/routes/v1/routes/mod.rs b/crates/codex-api/src/routes/v1/routes/mod.rs index fb29d7c6..13d9c099 100644 --- a/crates/codex-api/src/routes/v1/routes/mod.rs +++ b/crates/codex-api/src/routes/v1/routes/mod.rs @@ -6,11 +6,13 @@ mod admin; mod auth; mod books; +mod collections; mod libraries; mod misc; mod observability; mod oidc; mod plugins; +mod readlists; mod recommendations; mod releases; mod series; @@ -19,6 +21,7 @@ mod tasks; mod user; mod user_plugins; mod users; +mod want_to_read; use crate::extractors::AppState; use axum::Router; @@ -45,6 +48,9 @@ pub fn create_router(state: Arc) -> Router { .merge(recommendations::routes(state.clone())) .merge(releases::routes(state.clone())) .merge(observability::routes(state.clone())) + .merge(want_to_read::routes(state.clone())) + .merge(collections::routes(state.clone())) + .merge(readlists::routes(state.clone())) // Apply state to all routes .with_state(state) } diff --git a/crates/codex-api/src/routes/v1/routes/readlists.rs b/crates/codex-api/src/routes/v1/routes/readlists.rs new file mode 100644 index 00000000..f989a1c0 --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/readlists.rs @@ -0,0 +1,53 @@ +//! Read list routes +//! +//! Shared, ordered groupings of books across series. Reads are available to all +//! roles; create/modify/delete are gated by the read-list permissions. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, patch, post, put}, +}; +use std::sync::Arc; + +/// Create read list routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/readlists", get(handlers::list_readlists)) + .route("/readlists", post(handlers::create_readlist)) + .route("/readlists/{read_list_id}", get(handlers::get_readlist)) + .route( + "/readlists/{read_list_id}", + patch(handlers::update_readlist), + ) + .route( + "/readlists/{read_list_id}", + delete(handlers::delete_readlist), + ) + .route( + "/readlists/{read_list_id}/books", + get(handlers::get_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books", + post(handlers::add_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books", + put(handlers::reorder_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books/{book_id}", + delete(handlers::remove_readlist_book), + ) + .route( + "/readlists/{read_list_id}/thumbnail", + get(handlers::get_readlist_thumbnail), + ) + // Reverse lookup: read lists that contain a given book. + .route( + "/books/{book_id}/readlists", + get(handlers::get_book_readlists), + ) +} diff --git a/crates/codex-api/src/routes/v1/routes/want_to_read.rs b/crates/codex-api/src/routes/v1/routes/want_to_read.rs new file mode 100644 index 00000000..0dc34cf4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/want_to_read.rs @@ -0,0 +1,27 @@ +//! Want-to-read routes +//! +//! Per-user on-deck queue. All routes require authentication; each scopes to the +//! authenticated user. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, post}, +}; +use std::sync::Arc; + +/// Create want-to-read routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/want-to-read", get(handlers::list_want_to_read)) + .route("/want-to-read", post(handlers::add_want_to_read)) + .route( + "/want-to-read/series/{series_id}", + delete(handlers::remove_want_to_read_series), + ) + .route( + "/want-to-read/books/{book_id}", + delete(handlers::remove_want_to_read_book), + ) +} diff --git a/crates/codex-db/src/entities/books.rs b/crates/codex-db/src/entities/books.rs index 776793ad..bb1f8dbe 100644 --- a/crates/codex-db/src/entities/books.rs +++ b/crates/codex-db/src/entities/books.rs @@ -69,6 +69,11 @@ pub enum Relation { BookGenres, #[sea_orm(has_many = "super::book_tags::Entity")] BookTags, + // Read list membership and per-user want-to-read flags. + #[sea_orm(has_many = "super::read_list_books::Entity")] + ReadListBooks, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -131,4 +136,26 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadListBooks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + +// Many-to-many: books belong to many read lists via the read_list_books join. +impl Related for Entity { + fn to() -> RelationDef { + super::read_list_books::Relation::ReadList.def() + } + fn via() -> Option { + Some(super::read_list_books::Relation::Book.def().rev()) + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/collection_series.rs b/crates/codex-db/src/entities/collection_series.rs new file mode 100644 index 00000000..2a32a07f --- /dev/null +++ b/crates/codex-db/src/entities/collection_series.rs @@ -0,0 +1,54 @@ +//! `SeaORM` Entity for collection_series junction table +//! +//! Ordered membership linking collections to series (many-to-many). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "collection_series")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub collection_id: Uuid, + pub series_id: Uuid, + /// Honored only when the parent collection's `ordered` flag is true. + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::CollectionId", + to = "super::collections::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Collection, + #[sea_orm( + belongs_to = "super::series::Entity", + from = "Column::SeriesId", + to = "super::series::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Series, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Collection.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Series.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/collections.rs b/crates/codex-db/src/entities/collections.rs new file mode 100644 index 00000000..26c90efa --- /dev/null +++ b/crates/codex-db/src/entities/collections.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity for collections table +//! +//! A collection is a shared, named grouping of series (Komga-style). Membership +//! and order live in the `collection_series` junction. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "collections")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub name: String, + #[sea_orm(unique)] + pub normalized_name: String, + /// false => members sorted by series title; true => use `position`. + pub ordered: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::collection_series::Entity")] + CollectionSeries, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CollectionSeries.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::collection_series::Relation::Series.def() + } + fn via() -> Option { + Some(super::collection_series::Relation::Collection.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/mod.rs b/crates/codex-db/src/entities/mod.rs index 3a2c78e9..23d7f176 100644 --- a/crates/codex-db/src/entities/mod.rs +++ b/crates/codex-db/src/entities/mod.rs @@ -78,3 +78,10 @@ pub mod access_group_oidc_mappings; pub mod access_group_sharing_tags; pub mod access_groups; pub mod user_access_groups; + +// Collections (series), read lists (books), and the per-user want-to-read queue +pub mod collection_series; +pub mod collections; +pub mod read_list_books; +pub mod read_lists; +pub mod want_to_read; diff --git a/crates/codex-db/src/entities/prelude.rs b/crates/codex-db/src/entities/prelude.rs index cfc02564..b801a7b1 100644 --- a/crates/codex-db/src/entities/prelude.rs +++ b/crates/codex-db/src/entities/prelude.rs @@ -75,3 +75,15 @@ pub use super::access_group_sharing_tags::Entity as AccessGroupSharingTags; pub use super::access_groups::Entity as AccessGroups; #[allow(unused_imports)] pub use super::user_access_groups::Entity as UserAccessGroups; + +// Collections, read lists, and the per-user want-to-read queue +#[allow(unused_imports)] +pub use super::collection_series::Entity as CollectionSeries; +#[allow(unused_imports)] +pub use super::collections::Entity as Collections; +#[allow(unused_imports)] +pub use super::read_list_books::Entity as ReadListBooks; +#[allow(unused_imports)] +pub use super::read_lists::Entity as ReadLists; +#[allow(unused_imports)] +pub use super::want_to_read::Entity as WantToRead; diff --git a/crates/codex-db/src/entities/read_list_books.rs b/crates/codex-db/src/entities/read_list_books.rs new file mode 100644 index 00000000..d5f7e978 --- /dev/null +++ b/crates/codex-db/src/entities/read_list_books.rs @@ -0,0 +1,54 @@ +//! `SeaORM` Entity for read_list_books junction table +//! +//! Ordered membership linking read lists to books (many-to-many). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "read_list_books")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub read_list_id: Uuid, + pub book_id: Uuid, + /// Honored only when the parent read list's `ordered` flag is true. + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::read_lists::Entity", + from = "Column::ReadListId", + to = "super::read_lists::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + ReadList, + #[sea_orm( + belongs_to = "super::books::Entity", + from = "Column::BookId", + to = "super::books::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Book, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadList.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Book.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/read_lists.rs b/crates/codex-db/src/entities/read_lists.rs new file mode 100644 index 00000000..74a4f85f --- /dev/null +++ b/crates/codex-db/src/entities/read_lists.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity for read_lists table +//! +//! A read list is a shared, ordered grouping of books across series (Komga-style +//! "playlist for books"). Membership and order live in the `read_list_books` +//! junction. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "read_lists")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub name: String, + #[sea_orm(unique)] + pub normalized_name: String, + #[sea_orm(column_type = "Text", nullable)] + pub summary: Option, + /// true (default) => manual reading order; false => sort members by release date. + pub ordered: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::read_list_books::Entity")] + ReadListBooks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadListBooks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::read_list_books::Relation::Book.def() + } + fn via() -> Option { + Some(super::read_list_books::Relation::ReadList.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/series.rs b/crates/codex-db/src/entities/series.rs index c750c4b2..d8df34fa 100644 --- a/crates/codex-db/src/entities/series.rs +++ b/crates/codex-db/src/entities/series.rs @@ -68,6 +68,11 @@ pub enum Relation { // Release ledger entries for this series. #[sea_orm(has_many = "super::release_ledger::Entity")] ReleaseLedger, + // Collection membership and per-user want-to-read flags. + #[sea_orm(has_many = "super::collection_series::Entity")] + CollectionSeries, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -194,4 +199,26 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::CollectionSeries.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + +// Many-to-many: series belong to many collections via the collection_series join. +impl Related for Entity { + fn to() -> RelationDef { + super::collection_series::Relation::Collection.def() + } + fn via() -> Option { + Some(super::collection_series::Relation::Series.def().rev()) + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/users.rs b/crates/codex-db/src/entities/users.rs index 97602cbd..8af30e27 100644 --- a/crates/codex-db/src/entities/users.rs +++ b/crates/codex-db/src/entities/users.rs @@ -45,6 +45,8 @@ pub enum Relation { UserPlugins, #[sea_orm(has_many = "super::user_access_groups::Entity")] UserAccessGroups, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -86,6 +88,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + impl Related for Entity { fn to() -> RelationDef { super::user_access_groups::Relation::AccessGroup.def() diff --git a/crates/codex-db/src/entities/want_to_read.rs b/crates/codex-db/src/entities/want_to_read.rs new file mode 100644 index 00000000..7632e91c --- /dev/null +++ b/crates/codex-db/src/entities/want_to_read.rs @@ -0,0 +1,70 @@ +//! `SeaORM` Entity for want_to_read table +//! +//! Per-user, flat on-deck queue. Each row flags exactly one series OR one book +//! the user intends to read (enforced by a DB CHECK constraint). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "want_to_read")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + /// Set when this entry flags a series; mutually exclusive with `book_id`. + pub series_id: Option, + /// Set when this entry flags a book; mutually exclusive with `series_id`. + pub book_id: Option, + pub added_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Users, + #[sea_orm( + belongs_to = "super::series::Entity", + from = "Column::SeriesId", + to = "super::series::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Series, + #[sea_orm( + belongs_to = "super::books::Entity", + from = "Column::BookId", + to = "super::books::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Books, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Series.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Books.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/repositories/collection.rs b/crates/codex-db/src/repositories/collection.rs new file mode 100644 index 00000000..e3e63e5d --- /dev/null +++ b/crates/codex-db/src/repositories/collection.rs @@ -0,0 +1,432 @@ +//! Repository for collections and the collection_series junction. +//! +//! Collections are shared, named groupings of series. Membership order is held +//! by the `position` column on the junction; whether it is honored (vs. an +//! alphabetical fallback) is decided by the caller based on the collection's +//! `ordered` flag. + +#![allow(dead_code)] + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, + PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{ + collection_series, collection_series::Entity as CollectionSeries, collections, + collections::Entity as Collections, series, series::Entity as Series, +}; +use crate::repositories::visibility::{SeriesVisibility, visibility_predicate}; + +/// Repository for collection operations. +pub struct CollectionRepository; + +impl CollectionRepository { + /// Get a collection by ID. + pub async fn get_by_id( + db: &DatabaseConnection, + id: Uuid, + ) -> Result> { + Ok(Collections::find_by_id(id).one(db).await?) + } + + /// Get a collection by (case-insensitive) name. + pub async fn get_by_name( + db: &DatabaseConnection, + name: &str, + ) -> Result> { + let normalized = name.trim().to_lowercase(); + Ok(Collections::find() + .filter(collections::Column::NormalizedName.eq(normalized)) + .one(db) + .await?) + } + + /// List all collections sorted by name. + pub async fn list_all(db: &DatabaseConnection) -> Result> { + Ok(Collections::find() + .order_by_asc(collections::Column::Name) + .all(db) + .await?) + } + + /// Total number of collections. + pub async fn count(db: &DatabaseConnection) -> Result { + Ok(Collections::find().count(db).await?) + } + + /// Create a new collection. Fails if the (normalized) name already exists. + pub async fn create( + db: &DatabaseConnection, + name: &str, + ordered: bool, + ) -> Result { + let now = Utc::now(); + let model = collections::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.trim().to_string()), + normalized_name: Set(name.trim().to_lowercase()), + ordered: Set(ordered), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(model.insert(db).await?) + } + + /// Update a collection's name and/or ordered flag. Returns `None` if the + /// collection does not exist. + pub async fn update( + db: &DatabaseConnection, + id: Uuid, + name: Option<&str>, + ordered: Option, + ) -> Result> { + let Some(existing) = Collections::find_by_id(id).one(db).await? else { + return Ok(None); + }; + let mut active = existing.into_active_model(); + if let Some(name) = name { + active.name = Set(name.trim().to_string()); + active.normalized_name = Set(name.trim().to_lowercase()); + } + if let Some(ordered) = ordered { + active.ordered = Set(ordered); + } + active.updated_at = Set(Utc::now()); + Ok(Some(active.update(db).await?)) + } + + /// Delete a collection (cascades its membership rows). Returns whether a row + /// was removed. + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let result = Collections::delete_by_id(id).exec(db).await?; + Ok(result.rows_affected > 0) + } + + /// Add a series to a collection at the end of the order. Idempotent: if the + /// series is already a member, returns the existing link unchanged. + pub async fn add_series( + db: &DatabaseConnection, + collection_id: Uuid, + series_id: Uuid, + ) -> Result { + if let Some(existing) = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(series_id)) + .one(db) + .await? + { + return Ok(existing); + } + + let position = Self::next_position(db, collection_id).await?; + let link = collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(collection_id), + series_id: Set(series_id), + position: Set(position), + created_at: Set(Utc::now()), + }; + Ok(link.insert(db).await?) + } + + /// Remove a series from a collection. Returns whether a row was removed. + pub async fn remove_series( + db: &DatabaseConnection, + collection_id: Uuid, + series_id: Uuid, + ) -> Result { + let result = CollectionSeries::delete_many() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(series_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Set explicit positions for the given series in the order provided. Series + /// not currently members are skipped. + pub async fn reorder( + db: &DatabaseConnection, + collection_id: Uuid, + ordered_series_ids: &[Uuid], + ) -> Result<()> { + for (idx, series_id) in ordered_series_ids.iter().enumerate() { + if let Some(link) = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(*series_id)) + .one(db) + .await? + { + let mut active = link.into_active_model(); + active.position = Set(idx as i32); + active.update(db).await?; + } + } + Ok(()) + } + + /// Get the member series of a collection in stored order (by position, then + /// insertion time), filtered by the caller's visibility. + pub async fn get_series( + db: &DatabaseConnection, + collection_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result> { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(vec![]); + } + + let mut query = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .order_by_asc(collection_series::Column::Position) + .order_by_asc(collection_series::Column::CreatedAt); + if let Some(vis) = vis + && let Some(expr) = visibility_predicate(collection_series::Column::SeriesId, vis) + { + query = query.filter(expr); + } + + let ordered_ids: Vec = query + .all(db) + .await? + .into_iter() + .map(|l| l.series_id) + .collect(); + if ordered_ids.is_empty() { + return Ok(vec![]); + } + + let series_models = Series::find() + .filter(series::Column::Id.is_in(ordered_ids.clone())) + .all(db) + .await?; + let by_id: HashMap = + series_models.into_iter().map(|s| (s.id, s)).collect(); + + Ok(ordered_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect()) + } + + /// Count the visible member series of a collection. + pub async fn count_series( + db: &DatabaseConnection, + collection_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(0); + } + let mut query = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)); + if let Some(vis) = vis + && let Some(expr) = visibility_predicate(collection_series::Column::SeriesId, vis) + { + query = query.filter(expr); + } + Ok(query.count(db).await?) + } + + /// Get the collections that contain a given series, sorted by name. + pub async fn get_collections_for_series( + db: &DatabaseConnection, + series_id: Uuid, + ) -> Result> { + let collection_ids: Vec = CollectionSeries::find() + .filter(collection_series::Column::SeriesId.eq(series_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.collection_id) + .collect(); + if collection_ids.is_empty() { + return Ok(vec![]); + } + Ok(Collections::find() + .filter(collections::Column::Id.is_in(collection_ids)) + .order_by_asc(collections::Column::Name) + .all(db) + .await?) + } + + /// Next position value for a new member (max existing + 1, or 0 when empty). + async fn next_position(db: &DatabaseConnection, collection_id: Uuid) -> Result { + let positions: Vec = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.position) + .collect(); + Ok(positions.into_iter().max().map(|m| m + 1).unwrap_or(0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; + + async fn lib_and_series(db: &DatabaseConnection) -> (Uuid, Vec) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let mut series = Vec::new(); + for name in ["Alpha", "Bravo", "Charlie"] { + series.push( + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap(), + ); + } + (library.id, series) + } + + #[tokio::test] + async fn test_create_update_delete() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let coll = CollectionRepository::create(conn, " Batman ", false) + .await + .unwrap(); + assert_eq!(coll.name, "Batman"); + assert_eq!(coll.normalized_name, "batman"); + assert!(!coll.ordered); + + let found = CollectionRepository::get_by_name(conn, "BATMAN") + .await + .unwrap(); + assert_eq!(found.unwrap().id, coll.id); + + let updated = CollectionRepository::update(conn, coll.id, Some("Dark Knight"), Some(true)) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.name, "Dark Knight"); + assert!(updated.ordered); + + assert!(CollectionRepository::delete(conn, coll.id).await.unwrap()); + assert!( + CollectionRepository::get_by_id(conn, coll.id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn test_membership_add_dedupe_and_order() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_lib, series) = lib_and_series(conn).await; + + let coll = CollectionRepository::create(conn, "Coll", true) + .await + .unwrap(); + + // Add in a deliberate order. + for s in &series { + CollectionRepository::add_series(conn, coll.id, s.id) + .await + .unwrap(); + } + // Re-adding is idempotent (no duplicate, same row). + let again = CollectionRepository::add_series(conn, coll.id, series[0].id) + .await + .unwrap(); + assert_eq!(again.position, 0); + + let members = CollectionRepository::get_series(conn, coll.id, None) + .await + .unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, series[0].id); + assert_eq!(members[2].id, series[2].id); + + // Reverse the order and re-read. + let reversed: Vec = series.iter().rev().map(|s| s.id).collect(); + CollectionRepository::reorder(conn, coll.id, &reversed) + .await + .unwrap(); + let members = CollectionRepository::get_series(conn, coll.id, None) + .await + .unwrap(); + assert_eq!(members[0].id, series[2].id); + assert_eq!(members[2].id, series[0].id); + + // Remove one. + assert!( + CollectionRepository::remove_series(conn, coll.id, series[1].id) + .await + .unwrap() + ); + assert_eq!( + CollectionRepository::count_series(conn, coll.id, None) + .await + .unwrap(), + 2 + ); + } + + #[tokio::test] + async fn test_visibility_filtering_and_containers() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_lib, series) = lib_and_series(conn).await; + + let coll = CollectionRepository::create(conn, "Coll", false) + .await + .unwrap(); + for s in &series { + CollectionRepository::add_series(conn, coll.id, s.id) + .await + .unwrap(); + } + + // Exclude the middle series for this viewer. + let vis = SeriesVisibility { + excluded_series_ids: vec![series[1].id], + allowed_series_ids: None, + }; + let visible = CollectionRepository::get_series(conn, coll.id, Some(&vis)) + .await + .unwrap(); + assert_eq!(visible.len(), 2); + assert!(visible.iter().all(|s| s.id != series[1].id)); + assert_eq!( + CollectionRepository::count_series(conn, coll.id, Some(&vis)) + .await + .unwrap(), + 2 + ); + + // Empty whitelist => nothing visible. + let empty = SeriesVisibility { + excluded_series_ids: vec![], + allowed_series_ids: Some(vec![]), + }; + assert!( + CollectionRepository::get_series(conn, coll.id, Some(&empty)) + .await + .unwrap() + .is_empty() + ); + + // Containers-for-series lookup. + let containers = CollectionRepository::get_collections_for_series(conn, series[0].id) + .await + .unwrap(); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].id, coll.id); + } +} diff --git a/crates/codex-db/src/repositories/mod.rs b/crates/codex-db/src/repositories/mod.rs index 1b7522ac..ffd5a7e2 100644 --- a/crates/codex-db/src/repositories/mod.rs +++ b/crates/codex-db/src/repositories/mod.rs @@ -5,6 +5,7 @@ pub mod book_covers; pub mod book_duplicates; pub mod book_external_id; pub mod book_external_links; +pub mod collection; pub mod email_verification_token; pub mod external_link; pub mod external_rating; @@ -17,6 +18,7 @@ pub mod metrics; pub mod page; pub mod plugin_failures; pub mod plugins; +pub mod read_list; pub mod read_progress; pub mod refresh_token; pub mod release_ledger; @@ -37,6 +39,7 @@ pub mod task_metrics; pub mod user; pub mod user_preferences; pub mod user_series_rating; +pub mod want_to_read; // Sharing tags for content access control pub mod sharing_tag; @@ -68,6 +71,7 @@ pub use book_covers::BookCoversRepository; pub use book_duplicates::BookDuplicatesRepository; pub use book_external_id::BookExternalIdRepository; pub use book_external_links::BookExternalLinkRepository; +pub use collection::CollectionRepository; pub use email_verification_token::EmailVerificationTokenRepository; pub use external_link::ExternalLinkRepository; pub use external_rating::ExternalRatingRepository; @@ -80,6 +84,7 @@ pub use metrics::MetricsRepository; pub use page::PageRepository; pub use plugin_failures::{FailureContext, PluginFailuresRepository}; pub use plugins::PluginsRepository; +pub use read_list::ReadListRepository; pub use read_progress::ReadProgressRepository; #[allow(unused_imports)] pub use refresh_token::{NewRefreshToken, RefreshTokenRepository}; @@ -107,6 +112,7 @@ pub use task::TaskRepository; pub use user::{UserListFilter, UserRepository}; pub use user_preferences::UserPreferencesRepository; pub use user_series_rating::UserSeriesRatingRepository; +pub use want_to_read::WantToReadRepository; // Sharing tags pub use sharing_tag::SharingTagRepository; diff --git a/crates/codex-db/src/repositories/read_list.rs b/crates/codex-db/src/repositories/read_list.rs new file mode 100644 index 00000000..f6af30d5 --- /dev/null +++ b/crates/codex-db/src/repositories/read_list.rs @@ -0,0 +1,429 @@ +//! Repository for read lists and the read_list_books junction. +//! +//! Read lists are shared, ordered groupings of books across series. Membership +//! order is held by the `position` column on the junction; whether it is honored +//! (vs. a release-date fallback) is decided by the caller based on the read +//! list's `ordered` flag. + +#![allow(dead_code)] + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, + PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{ + books, books::Entity as Books, read_list_books, read_list_books::Entity as ReadListBooks, + read_lists, read_lists::Entity as ReadLists, +}; +use crate::repositories::visibility::{SeriesVisibility, apply_book_visibility}; + +/// Repository for read list operations. +pub struct ReadListRepository; + +impl ReadListRepository { + /// Get a read list by ID. + pub async fn get_by_id(db: &DatabaseConnection, id: Uuid) -> Result> { + Ok(ReadLists::find_by_id(id).one(db).await?) + } + + /// Get a read list by (case-insensitive) name. + pub async fn get_by_name( + db: &DatabaseConnection, + name: &str, + ) -> Result> { + let normalized = name.trim().to_lowercase(); + Ok(ReadLists::find() + .filter(read_lists::Column::NormalizedName.eq(normalized)) + .one(db) + .await?) + } + + /// List all read lists sorted by name. + pub async fn list_all(db: &DatabaseConnection) -> Result> { + Ok(ReadLists::find() + .order_by_asc(read_lists::Column::Name) + .all(db) + .await?) + } + + /// Total number of read lists. + pub async fn count(db: &DatabaseConnection) -> Result { + Ok(ReadLists::find().count(db).await?) + } + + /// Create a new read list. Fails if the (normalized) name already exists. + pub async fn create( + db: &DatabaseConnection, + name: &str, + summary: Option<&str>, + ordered: bool, + ) -> Result { + let now = Utc::now(); + let model = read_lists::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.trim().to_string()), + normalized_name: Set(name.trim().to_lowercase()), + summary: Set(summary.map(|s| s.to_string())), + ordered: Set(ordered), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(model.insert(db).await?) + } + + /// Update a read list's name, summary, and/or ordered flag. Returns `None` + /// if the read list does not exist. `summary = Some(None)` clears it. + pub async fn update( + db: &DatabaseConnection, + id: Uuid, + name: Option<&str>, + summary: Option>, + ordered: Option, + ) -> Result> { + let Some(existing) = ReadLists::find_by_id(id).one(db).await? else { + return Ok(None); + }; + let mut active = existing.into_active_model(); + if let Some(name) = name { + active.name = Set(name.trim().to_string()); + active.normalized_name = Set(name.trim().to_lowercase()); + } + if let Some(summary) = summary { + active.summary = Set(summary.map(|s| s.to_string())); + } + if let Some(ordered) = ordered { + active.ordered = Set(ordered); + } + active.updated_at = Set(Utc::now()); + Ok(Some(active.update(db).await?)) + } + + /// Delete a read list (cascades its membership rows). Returns whether a row + /// was removed. + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let result = ReadLists::delete_by_id(id).exec(db).await?; + Ok(result.rows_affected > 0) + } + + /// Add a book to a read list at the end of the order. Idempotent. + pub async fn add_book( + db: &DatabaseConnection, + read_list_id: Uuid, + book_id: Uuid, + ) -> Result { + if let Some(existing) = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(book_id)) + .one(db) + .await? + { + return Ok(existing); + } + + let position = Self::next_position(db, read_list_id).await?; + let link = read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(read_list_id), + book_id: Set(book_id), + position: Set(position), + created_at: Set(Utc::now()), + }; + Ok(link.insert(db).await?) + } + + /// Remove a book from a read list. Returns whether a row was removed. + pub async fn remove_book( + db: &DatabaseConnection, + read_list_id: Uuid, + book_id: Uuid, + ) -> Result { + let result = ReadListBooks::delete_many() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(book_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Set explicit positions for the given books in the order provided. Books + /// not currently members are skipped. + pub async fn reorder( + db: &DatabaseConnection, + read_list_id: Uuid, + ordered_book_ids: &[Uuid], + ) -> Result<()> { + for (idx, book_id) in ordered_book_ids.iter().enumerate() { + if let Some(link) = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(*book_id)) + .one(db) + .await? + { + let mut active = link.into_active_model(); + active.position = Set(idx as i32); + active.update(db).await?; + } + } + Ok(()) + } + + /// Get the member books of a read list in stored order (by position, then + /// insertion time), filtered by the caller's (series-based) visibility. + pub async fn get_books( + db: &DatabaseConnection, + read_list_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result> { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(vec![]); + } + + let ordered_ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .order_by_asc(read_list_books::Column::Position) + .order_by_asc(read_list_books::Column::CreatedAt) + .all(db) + .await? + .into_iter() + .map(|l| l.book_id) + .collect(); + if ordered_ids.is_empty() { + return Ok(vec![]); + } + + // Visibility is series-based; apply it to the books query. + let query = apply_book_visibility( + Books::find().filter(books::Column::Id.is_in(ordered_ids.clone())), + vis, + ); + let by_id: HashMap = query + .all(db) + .await? + .into_iter() + .map(|b| (b.id, b)) + .collect(); + + Ok(ordered_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect()) + } + + /// Count the visible member books of a read list. + pub async fn count_books( + db: &DatabaseConnection, + read_list_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(0); + } + let ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.book_id) + .collect(); + if ids.is_empty() { + return Ok(0); + } + let query = apply_book_visibility(Books::find().filter(books::Column::Id.is_in(ids)), vis); + Ok(query.count(db).await?) + } + + /// Get the read lists that contain a given book, sorted by name. + pub async fn get_read_lists_for_book( + db: &DatabaseConnection, + book_id: Uuid, + ) -> Result> { + let read_list_ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::BookId.eq(book_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.read_list_id) + .collect(); + if read_list_ids.is_empty() { + return Ok(vec![]); + } + Ok(ReadLists::find() + .filter(read_lists::Column::Id.is_in(read_list_ids)) + .order_by_asc(read_lists::Column::Name) + .all(db) + .await?) + } + + /// Next position value for a new member (max existing + 1, or 0 when empty). + async fn next_position(db: &DatabaseConnection, read_list_id: Uuid) -> Result { + let positions: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.position) + .collect(); + Ok(positions.into_iter().max().map(|m| m + 1).unwrap_or(0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::entities::series; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; + + async fn make_book(db: &DatabaseConnection, series_id: Uuid, library_id: Uuid) -> books::Model { + let book = books::Model { + id: Uuid::new_v4(), + series_id, + library_id, + path: format!("/test/{}.cbz", Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + BookRepository::create(db, &book, None).await.unwrap() + } + + async fn setup(db: &DatabaseConnection) -> (series::Model, Vec) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let mut books = Vec::new(); + for _ in 0..3 { + books.push(make_book(db, series.id, library.id).await); + } + (series, books) + } + + #[tokio::test] + async fn test_create_with_summary_and_update() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let rl = ReadListRepository::create(conn, "Civil War", Some("Crossover"), true) + .await + .unwrap(); + assert_eq!(rl.summary.as_deref(), Some("Crossover")); + assert!(rl.ordered); + + let updated = ReadListRepository::update(conn, rl.id, None, Some(None), Some(false)) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.summary, None); + assert!(!updated.ordered); + + assert!(ReadListRepository::delete(conn, rl.id).await.unwrap()); + } + + #[tokio::test] + async fn test_membership_order_and_reorder() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_series, books) = setup(conn).await; + + let rl = ReadListRepository::create(conn, "List", None, true) + .await + .unwrap(); + for b in &books { + ReadListRepository::add_book(conn, rl.id, b.id) + .await + .unwrap(); + } + // Idempotent re-add. + ReadListRepository::add_book(conn, rl.id, books[0].id) + .await + .unwrap(); + + let members = ReadListRepository::get_books(conn, rl.id, None) + .await + .unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, books[0].id); + + let reversed: Vec = books.iter().rev().map(|b| b.id).collect(); + ReadListRepository::reorder(conn, rl.id, &reversed) + .await + .unwrap(); + let members = ReadListRepository::get_books(conn, rl.id, None) + .await + .unwrap(); + assert_eq!(members[0].id, books[2].id); + + // Containers-for-book lookup. + let lists = ReadListRepository::get_read_lists_for_book(conn, books[0].id) + .await + .unwrap(); + assert_eq!(lists.len(), 1); + assert_eq!(lists[0].id, rl.id); + } + + #[tokio::test] + async fn test_book_visibility_filtering() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (series, books) = setup(conn).await; + + let rl = ReadListRepository::create(conn, "List", None, true) + .await + .unwrap(); + for b in &books { + ReadListRepository::add_book(conn, rl.id, b.id) + .await + .unwrap(); + } + + // Hiding the whole series hides all its books from this viewer. + let vis = SeriesVisibility { + excluded_series_ids: vec![series.id], + allowed_series_ids: None, + }; + assert!( + ReadListRepository::get_books(conn, rl.id, Some(&vis)) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + ReadListRepository::count_books(conn, rl.id, Some(&vis)) + .await + .unwrap(), + 0 + ); + // Without the filter, all three are visible. + assert_eq!( + ReadListRepository::count_books(conn, rl.id, None) + .await + .unwrap(), + 3 + ); + } +} diff --git a/crates/codex-db/src/repositories/want_to_read.rs b/crates/codex-db/src/repositories/want_to_read.rs new file mode 100644 index 00000000..1a1c2b89 --- /dev/null +++ b/crates/codex-db/src/repositories/want_to_read.rs @@ -0,0 +1,329 @@ +//! Repository for the per-user want-to-read queue. +//! +//! Each row flags exactly one series OR one book a user intends to read. The +//! queue is personal: every method scopes to a `user_id`. + +#![allow(dead_code)] + +use std::collections::HashSet; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{want_to_read, want_to_read::Entity as WantToRead}; + +/// Repository for want-to-read operations. +pub struct WantToReadRepository; + +impl WantToReadRepository { + /// Flag a series for a user. Idempotent: returns the existing row if already + /// queued. + pub async fn add_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + if let Some(existing) = WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .one(db) + .await? + { + return Ok(existing); + } + let model = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + series_id: Set(Some(series_id)), + book_id: Set(None), + added_at: Set(Utc::now()), + }; + Ok(model.insert(db).await?) + } + + /// Flag a book for a user. Idempotent. + pub async fn add_book( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + if let Some(existing) = WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .one(db) + .await? + { + return Ok(existing); + } + let model = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + series_id: Set(None), + book_id: Set(Some(book_id)), + added_at: Set(Utc::now()), + }; + Ok(model.insert(db).await?) + } + + /// Remove a series from a user's queue. Returns whether a row was removed. + pub async fn remove_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + let result = WantToRead::delete_many() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Remove a book from a user's queue. Returns whether a row was removed. + pub async fn remove_book( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + let result = WantToRead::delete_many() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// List a user's queue ordered by when each entry was added. + pub async fn list( + db: &DatabaseConnection, + user_id: Uuid, + ascending: bool, + ) -> Result> { + let query = WantToRead::find().filter(want_to_read::Column::UserId.eq(user_id)); + let query = if ascending { + query.order_by_asc(want_to_read::Column::AddedAt) + } else { + query.order_by_desc(want_to_read::Column::AddedAt) + }; + Ok(query.all(db).await?) + } + + /// Whether a series is in the user's queue. + pub async fn is_series_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .one(db) + .await? + .is_some()) + } + + /// Whether a book is in the user's queue. + pub async fn is_book_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .one(db) + .await? + .is_some()) + } + + /// Of the given series IDs, return the subset in the user's queue. Batch + /// helper for enriching series DTOs with a `wantToRead` flag. + pub async fn series_ids_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + series_ids: &[Uuid], + ) -> Result> { + if series_ids.is_empty() { + return Ok(HashSet::new()); + } + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.is_in(series_ids.to_vec())) + .all(db) + .await? + .into_iter() + .filter_map(|r| r.series_id) + .collect()) + } + + /// Of the given book IDs, return the subset in the user's queue. Batch + /// helper for enriching book DTOs with a `wantToRead` flag. + pub async fn book_ids_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + book_ids: &[Uuid], + ) -> Result> { + if book_ids.is_empty() { + return Ok(HashSet::new()); + } + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.is_in(book_ids.to_vec())) + .all(db) + .await? + .into_iter() + .filter_map(|r| r.book_id) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::entities::{books, users}; + use crate::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, + }; + use crate::test_helpers::create_test_db; + + async fn make_user(db: &DatabaseConnection, name: &str) -> users::Model { + let now = Utc::now(); + let model = users::Model { + id: Uuid::new_v4(), + username: name.to_string(), + email: format!("{name}@test.test"), + password_hash: "hash".to_string(), + role: "reader".to_string(), + is_active: true, + email_verified: false, + permissions: serde_json::json!([]), + created_at: now, + updated_at: now, + last_login_at: None, + }; + UserRepository::create(db, &model).await.unwrap() + } + + async fn make_series_and_book(db: &DatabaseConnection) -> (Uuid, Uuid) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let book = books::Model { + id: Uuid::new_v4(), + series_id: series.id, + library_id: library.id, + path: "/test/book.cbz".to_string(), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + let book = BookRepository::create(db, &book, None).await.unwrap(); + (series.id, book.id) + } + + #[tokio::test] + async fn test_add_remove_and_idempotency() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let user = make_user(conn, "alice").await; + let (series_id, book_id) = make_series_and_book(conn).await; + + WantToReadRepository::add_series(conn, user.id, series_id) + .await + .unwrap(); + // Idempotent. + WantToReadRepository::add_series(conn, user.id, series_id) + .await + .unwrap(); + WantToReadRepository::add_book(conn, user.id, book_id) + .await + .unwrap(); + + let queue = WantToReadRepository::list(conn, user.id, false) + .await + .unwrap(); + assert_eq!(queue.len(), 2); + + assert!( + WantToReadRepository::is_series_in_queue(conn, user.id, series_id) + .await + .unwrap() + ); + assert!( + WantToReadRepository::is_book_in_queue(conn, user.id, book_id) + .await + .unwrap() + ); + + assert!( + WantToReadRepository::remove_series(conn, user.id, series_id) + .await + .unwrap() + ); + assert!( + !WantToReadRepository::is_series_in_queue(conn, user.id, series_id) + .await + .unwrap() + ); + assert_eq!( + WantToReadRepository::list(conn, user.id, false) + .await + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn test_per_user_isolation_and_batch_lookup() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let alice = make_user(conn, "alice").await; + let bob = make_user(conn, "bob").await; + let (series_id, _book_id) = make_series_and_book(conn).await; + + WantToReadRepository::add_series(conn, alice.id, series_id) + .await + .unwrap(); + + // Bob's queue is unaffected. + assert!( + !WantToReadRepository::is_series_in_queue(conn, bob.id, series_id) + .await + .unwrap() + ); + + let in_queue = WantToReadRepository::series_ids_in_queue(conn, alice.id, &[series_id]) + .await + .unwrap(); + assert!(in_queue.contains(&series_id)); + + let bob_in_queue = WantToReadRepository::series_ids_in_queue(conn, bob.id, &[series_id]) + .await + .unwrap(); + assert!(bob_in_queue.is_empty()); + } +} diff --git a/crates/codex-models/src/permissions.rs b/crates/codex-models/src/permissions.rs index e31848f1..e4cdf96d 100644 --- a/crates/codex-models/src/permissions.rs +++ b/crates/codex-models/src/permissions.rs @@ -91,6 +91,16 @@ pub enum Permission { BooksWrite, BooksDelete, + // Collections (shared groupings of series) + CollectionsRead, + CollectionsWrite, + CollectionsDelete, + + // Read lists (shared groupings of books) + ReadListsRead, + ReadListsWrite, + ReadListsDelete, + // Pages (image serving) PagesRead, @@ -134,6 +144,12 @@ impl Permission { Permission::BooksRead => "books:read", Permission::BooksWrite => "books:write", Permission::BooksDelete => "books:delete", + Permission::CollectionsRead => "collections:read", + Permission::CollectionsWrite => "collections:write", + Permission::CollectionsDelete => "collections:delete", + Permission::ReadListsRead => "readlists:read", + Permission::ReadListsWrite => "readlists:write", + Permission::ReadListsDelete => "readlists:delete", Permission::PagesRead => "pages:read", Permission::ProgressRead => "progress:read", Permission::ProgressWrite => "progress:write", @@ -166,6 +182,12 @@ impl FromStr for Permission { "books:read" => Ok(Permission::BooksRead), "books:write" => Ok(Permission::BooksWrite), "books:delete" => Ok(Permission::BooksDelete), + "collections:read" => Ok(Permission::CollectionsRead), + "collections:write" => Ok(Permission::CollectionsWrite), + "collections:delete" => Ok(Permission::CollectionsDelete), + "readlists:read" => Ok(Permission::ReadListsRead), + "readlists:write" => Ok(Permission::ReadListsWrite), + "readlists:delete" => Ok(Permission::ReadListsDelete), "pages:read" => Ok(Permission::PagesRead), "progress:read" => Ok(Permission::ProgressRead), "progress:write" => Ok(Permission::ProgressWrite), @@ -230,6 +252,9 @@ lazy_static::lazy_static! { // Progress tracking set.insert(Permission::ProgressRead); set.insert(Permission::ProgressWrite); + // Browse shared collections and read lists (management is gated separately) + set.insert(Permission::CollectionsRead); + set.insert(Permission::ReadListsRead); // Own API keys set.insert(Permission::ApiKeysRead); set.insert(Permission::ApiKeysWrite); @@ -256,6 +281,11 @@ lazy_static::lazy_static! { // Books (full control) set.insert(Permission::BooksWrite); set.insert(Permission::BooksDelete); + // Collections + read lists (create/modify/delete shared groupings) + set.insert(Permission::CollectionsWrite); + set.insert(Permission::CollectionsDelete); + set.insert(Permission::ReadListsWrite); + set.insert(Permission::ReadListsDelete); // Tasks (view and manage) set.insert(Permission::TasksRead); set.insert(Permission::TasksWrite); @@ -370,15 +400,21 @@ mod tests { // Reader can track reading progress assert!(READER_PERMISSIONS.contains(&Permission::ProgressRead)); assert!(READER_PERMISSIONS.contains(&Permission::ProgressWrite)); + // Reader can browse shared collections and read lists + assert!(READER_PERMISSIONS.contains(&Permission::CollectionsRead)); + assert!(READER_PERMISSIONS.contains(&Permission::ReadListsRead)); // Reader cannot modify content assert!(!READER_PERMISSIONS.contains(&Permission::BooksWrite)); assert!(!READER_PERMISSIONS.contains(&Permission::SeriesWrite)); assert!(!READER_PERMISSIONS.contains(&Permission::LibrariesWrite)); + // Reader cannot manage (write/delete) collections or read lists + assert!(!READER_PERMISSIONS.contains(&Permission::CollectionsWrite)); + assert!(!READER_PERMISSIONS.contains(&Permission::ReadListsWrite)); // Reader cannot manage users or system assert!(!READER_PERMISSIONS.contains(&Permission::UsersRead)); assert!(!READER_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(READER_PERMISSIONS.len(), 10); + assert_eq!(READER_PERMISSIONS.len(), 12); } #[test] @@ -401,11 +437,16 @@ mod tests { assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksDelete)); // Maintainer can manage tasks assert!(MAINTAINER_PERMISSIONS.contains(&Permission::TasksWrite)); + // Maintainer can manage collections and read lists + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::CollectionsWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::CollectionsDelete)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::ReadListsWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::ReadListsDelete)); // Maintainer cannot manage users or system admin assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::UsersRead)); assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(MAINTAINER_PERMISSIONS.len(), 17); + assert_eq!(MAINTAINER_PERMISSIONS.len(), 23); } #[test] @@ -429,7 +470,7 @@ mod tests { // Admin has system admin assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(ADMIN_PERMISSIONS.len(), 23); // All permissions + assert_eq!(ADMIN_PERMISSIONS.len(), 29); // All permissions } // ============== UserRole tests ============== diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 348387f2..718ff693 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -5295,6 +5295,49 @@ ] } }, + "/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List the read lists that contain a given book.", + "operationId": "get_book_readlists", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read lists containing the book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/books/{book_id}/retry": { "post": { "tags": [ @@ -5510,6 +5553,461 @@ ] } }, + "/api/v1/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List all collections.", + "operationId": "list_collections", + "responses": { + "200": { + "description": "Collections", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Create a collection.", + "operationId": "create_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A collection with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection.", + "operationId": "get_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Collections" + ], + "summary": "Delete a collection.", + "operationId": "delete_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Collections" + ], + "summary": "Update a collection (rename / toggle ordered).", + "operationId": "update_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get the series in a collection (visibility-filtered, in stored order).", + "operationId": "get_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member series", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Collections" + ], + "summary": "Set the manual order of a collection's series.", + "operationId": "reorder_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderCollectionSeriesRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Add one or more series to a collection.", + "operationId": "add_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesToCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection or series not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series/{series_id}": { + "delete": { + "tags": [ + "Collections" + ], + "summary": "Remove a series from a collection.", + "operationId": "remove_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection's thumbnail (the first visible member series' cover).", + "operationId": "get_collection_thumbnail", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member series thumbnail" + }, + "404": { + "description": "No visible member series" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/duplicates": { "get": { "tags": [ @@ -8240,6 +8738,461 @@ ] } }, + "/api/v1/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List all read lists.", + "operationId": "list_readlists", + "responses": { + "200": { + "description": "Read lists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Create a read list.", + "operationId": "create_readlist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A read list with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list.", + "operationId": "get_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Delete a read list.", + "operationId": "delete_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Read Lists" + ], + "summary": "Update a read list (rename / edit summary / toggle ordered).", + "operationId": "update_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get the books in a read list (visibility-filtered, in stored order).", + "operationId": "get_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Read Lists" + ], + "summary": "Set the manual order of a read list's books.", + "operationId": "reorder_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderReadListBooksRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Add one or more books to a read list.", + "operationId": "add_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBooksToReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books/{book_id}": { + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Remove a book from a read list.", + "operationId": "remove_readlist_book", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list's thumbnail (the first visible member book's cover).", + "operationId": "get_readlist_thumbnail", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member book thumbnail" + }, + "404": { + "description": "No visible member books" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources": { "get": { "tags": [ @@ -11423,6 +12376,49 @@ ] } }, + "/api/v1/series/{series_id}/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List the collections that contain a given series.", + "operationId": "get_series_collections", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collections containing the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/series/{series_id}/cover": { "post": { "tags": [ @@ -16429,6 +17425,169 @@ ] } }, + "/api/v1/want-to-read": { + "get": { + "tags": [ + "Want to Read" + ], + "summary": "List the authenticated user's want-to-read queue.", + "operationId": "list_want_to_read", + "parameters": [ + { + "name": "sort", + "in": "query", + "description": "Sort by add time. Accepts `added_at:asc` for oldest-first; any other\nvalue (or omitted) yields newest-first (`added_at:desc`).", + "required": false, + "schema": { + "type": "string" + }, + "example": "added_at:desc" + } + ], + "responses": { + "200": { + "description": "Queue retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Want to Read" + ], + "summary": "Add a series or book to the authenticated user's queue.", + "description": "Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging\nsomething already queued returns the existing entry.", + "operationId": "add_want_to_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWantToReadRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry added (or already present)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadEntryDto" + } + } + } + }, + "400": { + "description": "Must provide exactly one of seriesId / bookId" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Series or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/books/{book_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a book from the authenticated user's queue.", + "operationId": "remove_want_to_read_book", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/series/{series_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a series from the authenticated user's queue.", + "operationId": "remove_want_to_read_series", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -16574,6 +17733,77 @@ ] } }, + "/opds/collections": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List collections (navigation feed)", + "operationId": "opds_list_collections", + "responses": { + "200": { + "description": "OPDS collections feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/collections/{collection_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List the series in a collection (navigation feed)", + "operationId": "opds_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS collection series feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/libraries": { "get": { "tags": [ @@ -16667,6 +17897,77 @@ ] } }, + "/opds/readlists": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List read lists (navigation feed)", + "operationId": "opds_list_readlists", + "responses": { + "200": { + "description": "OPDS read lists feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/readlists/{read_list_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List the books in a read list (acquisition feed)", + "operationId": "opds_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "description": "Read list ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS read list books feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/search": { "get": { "tags": [ @@ -16810,6 +18111,85 @@ ] } }, + "/opds/v2/collections": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List collections (OPDS 2.0 navigation feed)", + "operationId": "opds2_list_collections", + "responses": { + "200": { + "description": "OPDS 2.0 collections feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/collections/{collection_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List the series in a collection (OPDS 2.0 navigation feed)", + "operationId": "opds2_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 collection series feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/v2/libraries": { "get": { "tags": [ @@ -16911,6 +18291,85 @@ ] } }, + "/opds/v2/readlists": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List read lists (OPDS 2.0 navigation feed)", + "operationId": "opds2_list_readlists", + "responses": { + "200": { + "description": "OPDS 2.0 read lists feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/readlists/{read_list_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List the books in a read list (OPDS 2.0 publications feed)", + "operationId": "opds2_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "description": "Read list ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 read list books feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/v2/recent": { "get": { "tags": [ @@ -17815,6 +19274,57 @@ ] } }, + "/{prefix}/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List the read lists that contain a book (Komga-compatible).", + "operationId": "komga_get_book_readlists", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaReadListDto" + } + } + } + } + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/{prefix}/api/v1/books/{book_id}/thumbnail": { "get": { "tags": [ @@ -17873,14 +19383,13 @@ "tags": [ "Komga" ], - "summary": "List collections (stub - always returns empty)", - "description": "Komga collections are user-created groupings of series.\nCodex doesn't support this feature, so we return empty results.\n\n## Endpoint\n`GET /{prefix}/api/v1/collections`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "summary": "List collections (Komga-compatible).", "operationId": "komga_list_collections", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -17889,7 +19398,7 @@ ], "responses": { "200": { - "description": "Empty list of collections", + "description": "", "content": { "application/json": { "schema": { @@ -17899,7 +19408,7 @@ } }, "401": { - "description": "Unauthorized" + "description": "" } }, "security": [ @@ -17912,19 +19421,26 @@ ] } }, - "/{prefix}/api/v1/genres": { + "/{prefix}/api/v1/collections/{collection_id}": { "get": { "tags": [ "Komga" ], - "summary": "List genres", - "description": "Returns all genres in the library.\n\n## Endpoint\n`GET /{prefix}/api/v1/genres`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_genres", + "summary": "Get a collection (Komga-compatible).", + "operationId": "komga_get_collection", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", "required": true, "schema": { "type": "string" @@ -17933,20 +19449,17 @@ ], "responses": { "200": { - "description": "List of all genres", + "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/components/schemas/KomgaCollectionDto" } } } }, - "401": { - "description": "Unauthorized" + "404": { + "description": "" } }, "security": [ @@ -17959,14 +19472,109 @@ ] } }, - "/{prefix}/api/v1/languages": { + "/{prefix}/api/v1/collections/{collection_id}/series": { "get": { "tags": [ "Komga" ], - "summary": "List languages (stub - always returns empty array)", - "description": "Returns all languages in the library.\nCurrently returns empty as Codex doesn't aggregate languages separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/languages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_languages", + "summary": "Get the series in a collection (Komga-compatible).", + "operationId": "komga_get_collection_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a collection's thumbnail (redirects to the first visible member series).", + "operationId": "komga_get_collection_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "307": { + "description": "" + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/genres": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List genres", + "description": "Returns all genres in the library.\n\n## Endpoint\n`GET /{prefix}/api/v1/genres`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_genres", "parameters": [ { "name": "prefix", @@ -17980,7 +19588,54 @@ ], "responses": { "200": { - "description": "Empty list of languages", + "description": "List of all genres", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/languages": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List languages (stub - always returns empty array)", + "description": "Returns all languages in the library.\nCurrently returns empty as Codex doesn't aggregate languages separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/languages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_languages", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Empty list of languages", "content": { "application/json": { "schema": { @@ -18215,14 +19870,13 @@ "tags": [ "Komga" ], - "summary": "List read lists (stub - always returns empty)", - "description": "Komga read lists are user-created lists of books to read.\nCodex doesn't support this feature, so we return empty results.\n\n## Endpoint\n`GET /{prefix}/api/v1/readlists`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "summary": "List read lists (Komga-compatible).", "operationId": "komga_list_readlists", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -18231,7 +19885,7 @@ ], "responses": { "200": { - "description": "Empty list of read lists", + "description": "", "content": { "application/json": { "schema": { @@ -18241,7 +19895,153 @@ } }, "401": { - "description": "Unauthorized" + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a read list (Komga-compatible).", + "operationId": "komga_get_readlist", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaReadListDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get the books in a read list (Komga-compatible).", + "operationId": "komga_get_readlist_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a read list's thumbnail (redirects to the first visible member book).", + "operationId": "komga_get_readlist_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "307": { + "description": "" + }, + "404": { + "description": "" } }, "security": [ @@ -18466,14 +20266,263 @@ ] } }, - "/{prefix}/api/v1/series/new": { + "/{prefix}/api/v1/series/new": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently added series", + "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_new", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently added series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/release-dates": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List series release dates (stub - always returns empty array)", + "description": "Returns all release dates used by series in the library.\nCurrently returns empty as Codex doesn't aggregate release dates separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/release-dates`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_series_release_dates", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Empty list of release dates", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/updated": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently updated series", + "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_updated", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently updated series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}": { "get": { "tags": [ "Komga" ], - "summary": "Get recently added series", - "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_new", + "summary": "Get series by ID", + "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series", "parameters": [ { "name": "prefix", @@ -18485,76 +20534,32 @@ } }, { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of recently added series", + "description": "Series details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + "$ref": "#/components/schemas/KomgaSeriesDto" } } } }, "401": { "description": "Unauthorized" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -18567,14 +20572,14 @@ ] } }, - "/{prefix}/api/v1/series/release-dates": { + "/{prefix}/api/v1/series/{series_id}/books": { "get": { "tags": [ "Komga" ], - "summary": "List series release dates (stub - always returns empty array)", - "description": "Returns all release dates used by series in the library.\nCurrently returns empty as Codex doesn't aggregate release dates separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/release-dates`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_series_release_dates", + "summary": "Get books in a series", + "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_books", "parameters": [ { "name": "prefix", @@ -18584,52 +20589,15 @@ "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "Empty list of release dates", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - } - }, - "/{prefix}/api/v1/series/updated": { - "get": { - "tags": [ - "Komga" - ], - "summary": "Get recently updated series", - "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_updated", - "parameters": [ - { - "name": "prefix", + "name": "series_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -18692,65 +20660,11 @@ ], "responses": { "200": { - "description": "Paginated list of recently updated series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/{prefix}/api/v1/series/{series_id}": { - "get": { - "tags": [ - "Komga" - ], - "summary": "Get series by ID", - "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "Series details", + "description": "Paginated list of books in series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaSeriesDto" + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" } } } @@ -18772,19 +20686,18 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/books": { + "/{prefix}/api/v1/series/{series_id}/collections": { "get": { "tags": [ "Komga" ], - "summary": "Get books in a series", - "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_books", + "summary": "List the collections that contain a series (Komga-compatible).", + "operationId": "komga_get_series_collections", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -18793,87 +20706,25 @@ { "name": "series_id", "in": "path", - "description": "Series ID", "required": true, "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "type": "string" } } ], "responses": { "200": { - "description": "Paginated list of books in series", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaCollectionDto" + } } } } - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -19490,6 +21341,25 @@ } } }, + "AddBooksToReadListRequest": { + "type": "object", + "description": "Request to add one or more books to a read list.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddSeriesGenreRequest": { "type": "object", "description": "Request to add a single genre to a series", @@ -19518,6 +21388,47 @@ } } }, + "AddSeriesToCollectionRequest": { + "type": "object", + "description": "Request to add one or more series to a collection.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, + "AddWantToReadRequest": { + "type": "object", + "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", + "properties": { + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a series." + } + } + }, "AdjacentBooksResponse": { "type": "object", "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", @@ -20605,6 +22516,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -23823,6 +25742,70 @@ } } }, + "CollectionDto": { + "type": "object", + "description": "A collection of series.", + "required": [ + "id", + "name", + "ordered", + "seriesCount", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual order; otherwise sorted by title.", + "example": false + }, + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of member series visible to the requesting user.", + "example": 12, + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CollectionListResponse": { + "type": "object", + "description": "List of collections.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ConfigFieldDto": { "type": "object", "description": "Configuration field definition for documenting plugin config options", @@ -24101,6 +26084,24 @@ } } }, + "CreateCollectionRequest": { + "type": "object", + "description": "Request to create a collection.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `false` (members sorted by title).", + "example": false + } + } + }, "CreateExternalLinkRequest": { "type": "object", "description": "Request to create or update an external link for a series", @@ -24533,6 +26534,30 @@ } } }, + "CreateReadListRequest": { + "type": "object", + "description": "Request to create a read list.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `true` (manual reading order).", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateSeriesAliasRequest": { "type": "object", "required": [ @@ -26983,6 +29008,14 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.", + "example": false } } }, @@ -27365,6 +29398,14 @@ "format": "int64", "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", "example": 14 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.", + "example": false } } }, @@ -31906,6 +33947,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -32600,6 +34649,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -35414,6 +37471,77 @@ } } }, + "ReadListDto": { + "type": "object", + "description": "A read list.", + "required": [ + "id", + "name", + "ordered", + "bookCount", + "createdAt", + "updatedAt" + ], + "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Number of member books visible to the requesting user.", + "example": 24, + "minimum": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual reading order; otherwise sorted by\nrelease date.", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Optional description (Komga read lists carry a summary)." + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadListListResponse": { + "type": "object", + "description": "List of read lists.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ReadProgressListResponse": { "type": "object", "description": "Response containing a list of reading progress records", @@ -36302,6 +38430,46 @@ } } }, + "ReorderCollectionSeriesRequest": { + "type": "object", + "description": "Request to set the manual order of a collection's series. IDs not currently\nmembers are ignored; omitted members keep their existing position.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, + "ReorderReadListBooksRequest": { + "type": "object", + "description": "Request to set the manual order of a read list's books.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -37932,6 +40100,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -41478,6 +43654,24 @@ } } }, + "UpdateCollectionRequest": { + "type": "object", + "description": "Request to update a collection. Absent fields are left unchanged.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateFilterPresetRequest": { "type": "object", "description": "Request body for updating an existing filter preset.\n\nTreated as a full replacement of the mutable fields. `scope` and `target`\nare immutable since the condition is validated against them on create.", @@ -41966,6 +44160,31 @@ } } }, + "UpdateReadListRequest": { + "type": "object", + "description": "Request to update a read list. Absent fields are left unchanged. To clear the\nsummary, send `summary: null` explicitly.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "`Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it." + } + } + }, "UpdateReleaseLedgerEntryRequest": { "type": "object", "description": "PATCH payload for ledger row state transitions.\n\nOnly `state` is patchable from the API today; the rest of the row is\nsource-controlled. `state` is validated against the canonical set:\n`announced` | `dismissed` | `marked_acquired` | `hidden`.", @@ -42934,6 +45153,80 @@ "description": "User information" } } + }, + "WantToReadEntryDto": { + "type": "object", + "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", + "required": [ + "id", + "itemType", + "addedAt" + ], + "properties": { + "addedAt": { + "type": "string", + "format": "date-time", + "description": "When the entry was added to the queue.", + "example": "2026-06-15T18:45:00Z" + }, + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged book (set when `item_type` is `book`)." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Queue entry ID.", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "itemType": { + "$ref": "#/components/schemas/WantToReadItemType", + "description": "Whether this entry flags a series or a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged series (set when `item_type` is `series`)." + } + } + }, + "WantToReadItemType": { + "type": "string", + "description": "What a want-to-read entry points at.", + "enum": [ + "series", + "book" + ] + }, + "WantToReadListResponse": { + "type": "object", + "description": "A user's want-to-read queue.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WantToReadEntryDto" + }, + "description": "Queue entries." + }, + "total": { + "type": "integer", + "description": "Total number of entries.", + "example": 7, + "minimum": 0 + } + } } }, "securitySchemes": { diff --git a/docs/docs/collections-readlists.md b/docs/docs/collections-readlists.md new file mode 100644 index 00000000..3f136b22 --- /dev/null +++ b/docs/docs/collections-readlists.md @@ -0,0 +1,62 @@ +--- +--- + +# Want to Read, Collections & Read Lists + +Codex gives you three ways to group and queue what you read. They look similar but answer different questions: + +| Feature | Holds | Scope | Ordered? | Answers | +|---------|-------|-------|----------|---------| +| **Want to Read** | series **and** books | per-user (private) | by date added | "I want to get to this later" | +| **Collections** | series | shared (library-wide) | optional | "these series belong together" | +| **Read Lists** | books (across series) | shared (library-wide) | yes (default) | "read these issues in this order" | + +## Want to Read + +A personal on-deck queue. Flag any series or book you intend to read, and it shows up on the **Want to Read** page in the sidebar. The queue is private to you: other users never see it. + +- **Add / remove** with the bookmark button on a series or book detail page. Filled = in your queue. +- **Sort** the queue newest-first or oldest-first from the page header. + +There's no manual reordering, no shared state, and no permission to configure: it's yours, and every signed-in user has one. + +## Collections + +A **collection** is a shared, named grouping of **series** (a "Batman" collection, a publisher line, a themed shelf). Everyone on the server sees the same collections, and a series can belong to many of them. + +- **Browse** all collections from **Collections** in the sidebar; open one to see its member series. +- **Create / edit / delete** requires the `collections:write` / `collections:delete` permission (see below). Use **New Collection** on the Collections page. +- **Add a series** from its detail page via the "Add to collection" menu, which can also create a new collection inline. +- **Order**: a collection can be *ordered* (members kept in a manual order, reorder with the up/down controls on the detail page) or unordered (sorted by title). + +## Read Lists + +A **read list** is a shared, ordered grouping of **books** that can span multiple series. This is the comics-native concept: a crossover event like *Civil War* pulls specific issues from many series into one reading order. + +- **Browse** all read lists from **Read Lists** in the sidebar; open one to see its books in order. +- **Create / edit / delete** requires the `readlists:write` / `readlists:delete` permission. A read list also has an optional **summary**. +- **Add a book** from its detail page via the "Add to read list" menu (create-new inline supported). +- **Order**: read lists default to *ordered* (manual reading order); reorder with the up/down controls. Turn ordering off to sort members by release date instead. + +## Who can manage them + +Browsing is available to everyone (it's part of the **reader** role). Creating, editing, and deleting collections and read lists is gated by dedicated permissions, granted to **maintainer** and **admin** by default: + +| Permission | Grants | +|------------|--------| +| `collections:read` / `readlists:read` | Browse (all roles) | +| `collections:write` / `readlists:write` | Create / rename / edit / add / remove / reorder | +| `collections:delete` / `readlists:delete` | Delete the collection / read list | + +You can grant the write/delete permissions to an individual reader via **Settings → Users** if you want a non-maintainer curator. Deleting a collection or read list never deletes the underlying series or books. + +## Visibility + +Members are filtered by your sharing-tag access: a shared collection or read list may reference series or books you can't see, and those are hidden from you (and excluded from the counts) without affecting other users. + +## Third-party apps & e-readers + +Collections and read lists are exposed read-only to external clients: + +- **Komga-compatible apps** (e.g. Komic) see them through the [Komga API](./third-party-apps.md) once it's enabled. +- **OPDS readers** can browse both from the [OPDS catalog](./opds.md) (1.2 and 2.0). diff --git a/docs/docs/opds.md b/docs/docs/opds.md index d1d2c005..ac159e7c 100644 --- a/docs/docs/opds.md +++ b/docs/docs/opds.md @@ -37,8 +37,14 @@ The root catalog provides navigation links to browse your library. | `/opds/series/{id}` | Books in a specific series | | `/opds/libraries` | Browse by library | | `/opds/libraries/{id}` | Books in a specific library | +| `/opds/collections` | Browse [collections](./collections-readlists.md) of series | +| `/opds/collections/{id}` | Series in a specific collection | +| `/opds/readlists` | Browse [read lists](./collections-readlists.md) | +| `/opds/readlists/{id}` | Books in a specific read list | | `/opds/search?q={query}` | Search books | +The same feeds are available in OPDS 2.0 (JSON) under `/opds/v2`, including `/opds/v2/collections` and `/opds/v2/readlists`. + ## Authentication OPDS endpoints require authentication. Use one of these methods: diff --git a/docs/docs/third-party-apps.md b/docs/docs/third-party-apps.md index ea14a2ad..795d6d6e 100644 --- a/docs/docs/third-party-apps.md +++ b/docs/docs/third-party-apps.md @@ -163,6 +163,30 @@ When enabled, the following Komga-compatible endpoints are available at `/{prefi | `/books/{id}/next` | GET | Get next book in series | | `/books/{id}/previous` | GET | Get previous book in series | +### Collections + +Shared groupings of series. Read-only over the Komga API; create/manage them in the Codex web UI. See [Collections & Read Lists](./collections-readlists.md). + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/collections` | GET | List collections (paginated) | +| `/collections/{id}` | GET | Get a collection (with member series IDs) | +| `/collections/{id}/series` | GET | List the series in a collection | +| `/collections/{id}/thumbnail` | GET | Collection thumbnail (first member cover) | +| `/series/{id}/collections` | GET | Collections containing a series | + +### Read Lists + +Shared, ordered groupings of books across series. Read-only over the Komga API. + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/readlists` | GET | List read lists (paginated) | +| `/readlists/{id}` | GET | Get a read list (with member book IDs) | +| `/readlists/{id}/books` | GET | List the books in a read list | +| `/readlists/{id}/thumbnail` | GET | Read list thumbnail (first member cover) | +| `/books/{id}/readlists` | GET | Read lists containing a book | + ### Pages | Endpoint | Method | Description | @@ -265,8 +289,7 @@ The Komga-compatible API has some limitations compared to the native Komga serve ### Not Supported - **Metadata editing** - The API is read-only -- **Collections** - Komga collections are not implemented -- **Read lists** - Komga read lists are not implemented +- **Collection / read list editing** - Collections and read lists are exposed read-only; create and manage them in the Codex web UI - **Full search syntax** - Only basic search is supported - **Oneshot detection** - The `oneshot` field is not included in responses - **WebPub manifests** - Not implemented @@ -293,7 +316,6 @@ The Komga API uses Basic Auth, which transmits credentials in base64 encoding (n The following features may be added in future versions: -- Collections/read lists support - Metadata editing (if requested) - More apps compatibility testing - WebPub manifest support diff --git a/docs/docs/users/permissions.md b/docs/docs/users/permissions.md index 4957d355..dc06c14d 100644 --- a/docs/docs/users/permissions.md +++ b/docs/docs/users/permissions.md @@ -31,6 +31,7 @@ The default role for new users. Readers can: - Browse libraries, series, and books - Read books and view pages - Track reading progress +- Browse collections and read lists - Manage their own API keys - Check system health @@ -46,6 +47,7 @@ For users who manage content but not system settings. Maintainers can do everyth - Create and modify libraries (but not delete them) - Full series management (create, edit, delete) - Full book management (create, edit, delete) +- Create, edit, and delete collections and read lists - View and manage background tasks **Additional Permissions (7 more, 15 total):** @@ -94,6 +96,19 @@ Full system access for server administrators. Admins can do everything Maintaine | `BooksWrite` | Update book metadata, mark as read | | `BooksDelete` | Delete books | +### Collection & Read List Permissions + +Shared groupings of series (collections) and books (read lists). See [Collections & Read Lists](../collections-readlists.md). Read is part of the Reader role; write/delete are part of Maintainer. + +| Permission | Description | +|------------|-------------| +| `CollectionsRead` | Browse collections | +| `CollectionsWrite` | Create, rename, and manage collection members | +| `CollectionsDelete` | Delete collections | +| `ReadListsRead` | Browse read lists | +| `ReadListsWrite` | Create, edit, and manage read list members | +| `ReadListsDelete` | Delete read lists | + ### Page Permissions | Permission | Description | diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ec19474b..bdfb655d 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -39,6 +39,7 @@ const sidebars: SidebarsConfig = { "book-metadata", "series-metadata", "series-management", + "collections-readlists", "custom-metadata", "release-tracking", "tsundoku", diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 52e2362a..b2c9a719 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -195,6 +195,13 @@ mod m20260607_000094_add_user_plugin_sync_unique_index; mod m20260608_000095_create_plugin_data; mod m20260609_000096_add_plugin_log_level; +// Collections: shared, ordered groupings of series + their join table +pub mod m20260615_000097_create_collections; +// Read lists: shared, ordered groupings of books across series + their join table +pub mod m20260615_000098_create_read_lists; +// Want to Read: per-user flat queue of series and/or books +pub mod m20260615_000099_create_want_to_read; + pub struct Migrator; #[async_trait::async_trait] @@ -361,6 +368,12 @@ impl MigratorTrait for Migrator { Box::new(m20260608_000095_create_plugin_data::Migration), // Per-plugin log-level override column on `plugins` Box::new(m20260609_000096_add_plugin_log_level::Migration), + // Collections: shared, ordered groupings of series + collection_series join + Box::new(m20260615_000097_create_collections::Migration), + // Read lists: shared, ordered groupings of books + read_list_books join + Box::new(m20260615_000098_create_read_lists::Migration), + // Want to Read: per-user flat queue of series and/or books + Box::new(m20260615_000099_create_want_to_read::Migration), ] } } diff --git a/migration/src/m20260615_000097_create_collections.rs b/migration/src/m20260615_000097_create_collections.rs new file mode 100644 index 00000000..2f2d6b86 --- /dev/null +++ b/migration/src/m20260615_000097_create_collections.rs @@ -0,0 +1,177 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Collections: shared, named groupings of series (Komga-style). + manager + .create_table( + Table::create() + .table(Collections::Table) + .if_not_exists() + .col( + ColumnDef::new(Collections::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(Collections::Name) + .string_len(255) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(Collections::NormalizedName) + .string_len(255) + .not_null() + .unique_key(), + ) + // false => members sorted by series title; true => use position + .col( + ColumnDef::new(Collections::Ordered) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Collections::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Collections::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_collections_normalized_name") + .table(Collections::Table) + .col(Collections::NormalizedName) + .to_owned(), + ) + .await?; + + // collection_series: ordered membership (collection has many series, + // series may belong to many collections). + manager + .create_table( + Table::create() + .table(CollectionSeries::Table) + .if_not_exists() + .col( + ColumnDef::new(CollectionSeries::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CollectionSeries::CollectionId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(CollectionSeries::SeriesId).uuid().not_null()) + // Honored only when collections.ordered = true. + .col( + ColumnDef::new(CollectionSeries::Position) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(CollectionSeries::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_collection_series_collection_id") + .from(CollectionSeries::Table, CollectionSeries::CollectionId) + .to(Collections::Table, Collections::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_collection_series_series_id") + .from(CollectionSeries::Table, CollectionSeries::SeriesId) + .to(Series::Table, Series::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // A series appears at most once per collection. + manager + .create_index( + Index::create() + .name("idx_collection_series_unique") + .table(CollectionSeries::Table) + .col(CollectionSeries::CollectionId) + .col(CollectionSeries::SeriesId) + .unique() + .to_owned(), + ) + .await?; + + // Reverse lookup: which collections contain a given series. + manager + .create_index( + Index::create() + .name("idx_collection_series_series_id") + .table(CollectionSeries::Table) + .col(CollectionSeries::SeriesId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CollectionSeries::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Collections::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Collections { + Table, + Id, + Name, + NormalizedName, + Ordered, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +pub enum CollectionSeries { + Table, + Id, + CollectionId, + SeriesId, + Position, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Series { + Table, + Id, +} diff --git a/migration/src/m20260615_000098_create_read_lists.rs b/migration/src/m20260615_000098_create_read_lists.rs new file mode 100644 index 00000000..85eb05ec --- /dev/null +++ b/migration/src/m20260615_000098_create_read_lists.rs @@ -0,0 +1,176 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Read lists: shared, ordered groupings of books across series + // (Komga-style "playlists for books"). + manager + .create_table( + Table::create() + .table(ReadLists::Table) + .if_not_exists() + .col( + ColumnDef::new(ReadLists::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ReadLists::Name) + .string_len(255) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(ReadLists::NormalizedName) + .string_len(255) + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(ReadLists::Summary).text()) + // Read lists default to manual reading order (the point of a + // read list); false => members sorted by release date. + .col( + ColumnDef::new(ReadLists::Ordered) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(ReadLists::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ReadLists::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_read_lists_normalized_name") + .table(ReadLists::Table) + .col(ReadLists::NormalizedName) + .to_owned(), + ) + .await?; + + // read_list_books: ordered membership. + manager + .create_table( + Table::create() + .table(ReadListBooks::Table) + .if_not_exists() + .col( + ColumnDef::new(ReadListBooks::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ReadListBooks::ReadListId).uuid().not_null()) + .col(ColumnDef::new(ReadListBooks::BookId).uuid().not_null()) + // Honored only when read_lists.ordered = true. + .col( + ColumnDef::new(ReadListBooks::Position) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(ReadListBooks::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_read_list_books_read_list_id") + .from(ReadListBooks::Table, ReadListBooks::ReadListId) + .to(ReadLists::Table, ReadLists::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_read_list_books_book_id") + .from(ReadListBooks::Table, ReadListBooks::BookId) + .to(Books::Table, Books::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // A book appears at most once per read list. + manager + .create_index( + Index::create() + .name("idx_read_list_books_unique") + .table(ReadListBooks::Table) + .col(ReadListBooks::ReadListId) + .col(ReadListBooks::BookId) + .unique() + .to_owned(), + ) + .await?; + + // Reverse lookup: which read lists contain a given book. + manager + .create_index( + Index::create() + .name("idx_read_list_books_book_id") + .table(ReadListBooks::Table) + .col(ReadListBooks::BookId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReadListBooks::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(ReadLists::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum ReadLists { + Table, + Id, + Name, + NormalizedName, + Summary, + Ordered, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +pub enum ReadListBooks { + Table, + Id, + ReadListId, + BookId, + Position, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Books { + Table, + Id, +} diff --git a/migration/src/m20260615_000099_create_want_to_read.rs b/migration/src/m20260615_000099_create_want_to_read.rs new file mode 100644 index 00000000..48af7a77 --- /dev/null +++ b/migration/src/m20260615_000099_create_want_to_read.rs @@ -0,0 +1,129 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // want_to_read: per-user, flat on-deck queue. Each row flags exactly one + // series OR one book the user intends to read. Replaces the "open page 1 + // so it shows in Keep Reading" workaround. + manager + .create_table( + Table::create() + .table(WantToRead::Table) + .if_not_exists() + .col( + ColumnDef::new(WantToRead::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(WantToRead::UserId).uuid().not_null()) + .col(ColumnDef::new(WantToRead::SeriesId).uuid()) + .col(ColumnDef::new(WantToRead::BookId).uuid()) + .col( + ColumnDef::new(WantToRead::AddedAt) + .timestamp_with_time_zone() + .not_null(), + ) + // Exactly one of series_id / book_id must be set. Rendered + // inline at CREATE TABLE so it holds on SQLite too (SQLite + // cannot ALTER TABLE ADD CONSTRAINT CHECK). + .check(Expr::cust( + "(series_id IS NOT NULL) <> (book_id IS NOT NULL)", + )) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_user_id") + .from(WantToRead::Table, WantToRead::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_series_id") + .from(WantToRead::Table, WantToRead::SeriesId) + .to(Series::Table, Series::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_book_id") + .from(WantToRead::Table, WantToRead::BookId) + .to(Books::Table, Books::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // Can't flag the same series twice for one user. NULLs are distinct in + // unique indexes (both SQLite and PostgreSQL), so book-only rows (with + // series_id NULL) don't collide. + manager + .create_index( + Index::create() + .name("idx_want_to_read_user_series_unique") + .table(WantToRead::Table) + .col(WantToRead::UserId) + .col(WantToRead::SeriesId) + .unique() + .to_owned(), + ) + .await?; + + // Can't flag the same book twice for one user. + manager + .create_index( + Index::create() + .name("idx_want_to_read_user_book_unique") + .table(WantToRead::Table) + .col(WantToRead::UserId) + .col(WantToRead::BookId) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(WantToRead::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum WantToRead { + Table, + Id, + UserId, + SeriesId, + BookId, + AddedAt, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Series { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Books { + Table, + Id, +} diff --git a/tests/api/collections.rs b/tests/api/collections.rs new file mode 100644 index 00000000..ca436b70 --- /dev/null +++ b/tests/api/collections.rs @@ -0,0 +1,338 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{CollectionDto, CollectionListResponse, SeriesDto}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{LibraryRepository, SeriesRepository, UserRepository}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +/// A reader carrying an explicit `collections-write` custom permission. +async fn reader_with_write_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> String { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user_with_permissions( + "editor", + "editor@example.com", + &password_hash, + false, + vec![ + "collections-read".to_string(), + "collections-write".to_string(), + ], + ); + let created = UserRepository::create(db, &user).await.unwrap(); + state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap() +} + +async fn make_series( + db: &sea_orm::DatabaseConnection, + name: &str, +) -> codex::db::entities::series::Model { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap() +} + +/// Create N series under a single shared library. +async fn make_series_in_library( + db: &sea_orm::DatabaseConnection, + names: &[&str], +) -> Vec { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let mut out = Vec::new(); + for name in names { + out.push( + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap(), + ); + } + out +} + +#[tokio::test] +async fn test_create_get_and_list_collection() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Batman", "ordered": true }), + &token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + assert_eq!(created.name, "Batman"); + assert!(created.ordered); + assert_eq!(created.series_count, 0); + + let req = get_request_with_auth(&format!("/api/v1/collections/{}", created.id), &token); + let (status, fetched): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched.unwrap().id, created.id); + + let req = get_request_with_auth("/api/v1/collections", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().total, 1); +} + +#[tokio::test] +async fn test_create_rejects_empty_and_duplicate_name() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": " " }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + let body = serde_json::json!({ "name": "Marvel" }); + let req = post_json_request_with_auth("/api/v1/collections", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + let req = post_json_request_with_auth("/api/v1/collections", &body, &token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_permission_matrix() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_admin, admin_token) = user_and_token(&db, &state, "admin", true).await; + let (_reader, reader_token) = user_and_token(&db, &state, "reader", false).await; + let editor_token = reader_with_write_token(&db, &state).await; + let app = create_test_router(state).await; + + // Reader can list (CollectionsRead is in the reader bundle). + let req = get_request_with_auth("/api/v1/collections", &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + + // Reader cannot create (no CollectionsWrite). + let body = serde_json::json!({ "name": "Nope" }); + let req = post_json_request_with_auth("/api/v1/collections", &body, &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // A reader with an explicit collections-write permission can create. + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Editor's Pick" }), + &editor_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + // Admin can create. + let req = post_json_request_with_auth("/api/v1/collections", &body, &admin_token); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + + // Reader cannot delete (no CollectionsDelete; editor lacks it too). + let req = delete_request_with_auth( + &format!("/api/v1/collections/{}", created.id), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // Admin can delete. + let req = + delete_request_with_auth(&format!("/api/v1/collections/{}", created.id), &admin_token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_member_management_order_and_count() { + let (db, _t) = setup_test_db().await; + let series = make_series_in_library(&db, &["Alpha", "Bravo", "Charlie"]).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + // Ordered collection so manual order is honored. + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Coll", "ordered": true }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + // Add all three. + let req = post_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": series.iter().map(|s| s.id).collect::>() }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated.unwrap().series_count, 3); + + // Members come back in insertion order. + let req = get_request_with_auth(&format!("/api/v1/collections/{coll_id}/series"), &token); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, series[0].id); + assert_eq!(members[2].id, series[2].id); + + // Reorder reversed. + let reversed: Vec<_> = series.iter().rev().map(|s| s.id).collect(); + let req = put_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": reversed }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth(&format!("/api/v1/collections/{coll_id}/series"), &token); + let (_s, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + let members = members.unwrap(); + assert_eq!(members[0].id, series[2].id); + assert_eq!(members[2].id, series[0].id); + + // Remove the middle series. + let req = delete_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series/{}", series[1].id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // series/{id}/collections reverse lookup includes this collection. + let req = get_request_with_auth( + &format!("/api/v1/series/{}/collections", series[0].id), + &token, + ); + let (status, containers): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let containers = containers.unwrap(); + assert_eq!(containers.total, 1); + assert_eq!(containers.items[0].id, coll_id); +} + +#[tokio::test] +async fn test_update_and_not_found() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Old" }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + let req = patch_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}"), + &serde_json::json!({ "name": "New", "ordered": true }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let updated = updated.unwrap(); + assert_eq!(updated.name, "New"); + assert!(updated.ordered); + + // Unknown collection -> 404. + let req = get_request_with_auth( + &format!("/api/v1/collections/{}", uuid::Uuid::new_v4()), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_add_nonexistent_series_returns_404() { + let (db, _t) = setup_test_db().await; + let _series = make_series(&db, "Solo").await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Coll" }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + let req = post_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": [uuid::Uuid::new_v4()] }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/komga.rs b/tests/api/komga.rs index 687638ca..93ca3c1c 100644 --- a/tests/api/komga.rs +++ b/tests/api/komga.rs @@ -4963,3 +4963,146 @@ async fn test_komga_put_progression_book_not_found() { assert_eq!(status, StatusCode::NOT_FOUND); } + +// ============================================================================ +// Collections / Read Lists (Komga-compatible, read-only) +// ============================================================================ + +use codex::api::routes::komga::dto::{KomgaCollectionDto, KomgaReadListDto}; +use codex::db::repositories::{CollectionRepository, ReadListRepository}; + +#[tokio::test] +async fn test_komga_collections_real_data() { + let (db, temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let coll = CollectionRepository::create(&db, "Batman Collection", true) + .await + .unwrap(); + CollectionRepository::add_series(&db, coll.id, series.id) + .await + .unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_komga(state); + + // List + let request = get_request_with_auth("/komga/api/v1/collections", &token); + let (status, page): (StatusCode, Option>) = + make_json_request(app.clone(), request).await; + assert_eq!(status, StatusCode::OK); + let page = page.unwrap(); + assert_eq!(page.total_elements, 1); + assert_eq!(page.content[0].name, "Batman Collection"); + assert_eq!(page.content[0].series_ids, vec![series.id.to_string()]); + + // Detail + let request = get_request_with_auth(&format!("/komga/api/v1/collections/{}", coll.id), &token); + let (status, dto): (StatusCode, Option) = + make_json_request(app.clone(), request).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(dto.unwrap().id, coll.id.to_string()); + + // Members + let request = get_request_with_auth( + &format!("/komga/api/v1/collections/{}/series", coll.id), + &token, + ); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), request).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.total_elements, 1); + assert_eq!(members.content[0].id, series.id.to_string()); + + // Reverse lookup + let request = get_request_with_auth( + &format!("/komga/api/v1/series/{}/collections", series.id), + &token, + ); + let (status, list): (StatusCode, Option>) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().len(), 1); +} + +#[tokio::test] +async fn test_komga_readlists_real_data() { + let (db, temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Spider-Man", None) + .await + .unwrap(); + let book = create_test_book_with_hash( + &db, + &library, + &series, + "Civil War #1", + "/comics/cw1.cbz", + "hash_cw1", + ) + .await; + let rl = ReadListRepository::create(&db, "Civil War", Some("Crossover"), true) + .await + .unwrap(); + ReadListRepository::add_book(&db, rl.id, book.id) + .await + .unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_komga(state); + + // List + let request = get_request_with_auth("/komga/api/v1/readlists", &token); + let (status, page): (StatusCode, Option>) = + make_json_request(app.clone(), request).await; + assert_eq!(status, StatusCode::OK); + let page = page.unwrap(); + assert_eq!(page.total_elements, 1); + assert_eq!(page.content[0].name, "Civil War"); + assert_eq!(page.content[0].summary, "Crossover"); + assert_eq!(page.content[0].book_ids, vec![book.id.to_string()]); + + // Members + let request = + get_request_with_auth(&format!("/komga/api/v1/readlists/{}/books", rl.id), &token); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), request).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.total_elements, 1); + assert_eq!(members.content[0].id, book.id.to_string()); + + // Reverse lookup + let request = get_request_with_auth( + &format!("/komga/api/v1/books/{}/readlists", book.id), + &token, + ); + let (status, list): (StatusCode, Option>) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().len(), 1); +} + +#[tokio::test] +async fn test_komga_collection_not_found() { + let (db, temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_komga(state); + + let request = get_request_with_auth( + &format!("/komga/api/v1/collections/{}", uuid::Uuid::new_v4()), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/mod.rs b/tests/api/mod.rs index ece3a03b..362f94f0 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -12,6 +12,7 @@ mod auth; mod books; mod bulk_metadata; mod bulk_operations; +mod collections; mod covers; mod current_user; mod duplicates; @@ -41,6 +42,7 @@ mod plugins; mod pool_contention; mod rate_limit; mod read_progress; +mod readlists; mod recommendations; mod refresh_token; mod releases; @@ -60,3 +62,4 @@ mod tracking; mod user_plugins; mod user_preferences; mod user_ratings; +mod want_to_read; diff --git a/tests/api/opds.rs b/tests/api/opds.rs index 7d2abd4c..d76e5216 100644 --- a/tests/api/opds.rs +++ b/tests/api/opds.rs @@ -567,3 +567,122 @@ async fn create_test_book_with_metadata( created } + +// ============================================================================ +// Collections / Read Lists feeds (OPDS 1.2) +// ============================================================================ + +async fn opds_user_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> String { + let password_hash = password::hash_password("password").unwrap(); + let user = create_test_user("opdsuser", "opds@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap() +} + +fn opds_get(uri: &str, token: &str) -> hyper::Request { + hyper::Request::builder() + .method("GET") + .uri(uri) + .header("Authorization", format!("Bearer {}", token)) + .body(String::new()) + .unwrap() +} + +#[tokio::test] +async fn test_opds_collections_feed() { + use codex::db::repositories::CollectionRepository; + let (db, _temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let coll = CollectionRepository::create(&db, "Batman Collection", true) + .await + .unwrap(); + CollectionRepository::add_series(&db, coll.id, series.id) + .await + .unwrap(); + + let state = create_test_auth_state(db).await; + let token = opds_user_token(&state.db, &state).await; + let app = create_test_router(state).await; + + // Root advertises Collections. + let (status, body) = make_request(app.clone(), opds_get("/opds", &token)).await; + assert_eq!(status, StatusCode::OK); + let root = String::from_utf8(body.to_vec()).unwrap(); + assert!(root.contains("Collections")); + assert!(root.contains("/opds/collections")); + + // Collections feed lists the collection. + let (status, body) = make_request(app.clone(), opds_get("/opds/collections", &token)).await; + assert_eq!(status, StatusCode::OK); + let feed = String::from_utf8(body.to_vec()).unwrap(); + assert!(feed.contains("Batman Collection")); + assert!(feed.contains(&format!("/opds/collections/{}", coll.id))); + + // Collection detail lists the member series. + let (status, body) = make_request( + app, + opds_get(&format!("/opds/collections/{}", coll.id), &token), + ) + .await; + assert_eq!(status, StatusCode::OK); + let detail = String::from_utf8(body.to_vec()).unwrap(); + assert!(detail.contains(&format!("/opds/series/{}", series.id))); +} + +#[tokio::test] +async fn test_opds_readlists_feed() { + use codex::db::repositories::ReadListRepository; + let (db, _temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Spider-Man", None) + .await + .unwrap(); + let book = create_test_book_with_metadata( + &db, + series.id, + library.id, + "Civil War #1", + "/cw1.cbz", + 1, + 20, + ) + .await; + let rl = ReadListRepository::create(&db, "Civil War", Some("Crossover"), true) + .await + .unwrap(); + ReadListRepository::add_book(&db, rl.id, book.id) + .await + .unwrap(); + + let state = create_test_auth_state(db).await; + let token = opds_user_token(&state.db, &state).await; + let app = create_test_router(state).await; + + // Read lists feed lists the read list. + let (status, body) = make_request(app.clone(), opds_get("/opds/readlists", &token)).await; + assert_eq!(status, StatusCode::OK); + let feed = String::from_utf8(body.to_vec()).unwrap(); + assert!(feed.contains("Civil War")); + assert!(feed.contains(&format!("/opds/readlists/{}", rl.id))); + + // Read list detail is an acquisition feed of the member book. + let (status, body) = + make_request(app, opds_get(&format!("/opds/readlists/{}", rl.id), &token)).await; + assert_eq!(status, StatusCode::OK); + let detail = String::from_utf8(body.to_vec()).unwrap(); + assert!(detail.contains("Civil War #1")); + assert!(detail.contains(&format!("/api/v1/books/{}/file", book.id))); +} diff --git a/tests/api/opds2.rs b/tests/api/opds2.rs index 83cf9715..8f9928ee 100644 --- a/tests/api/opds2.rs +++ b/tests/api/opds2.rs @@ -948,3 +948,133 @@ async fn create_test_book_with_metadata( created } + +// ============================================================================ +// Collections / Read Lists feeds (OPDS 2.0) +// ============================================================================ + +async fn opds2_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> String { + let password_hash = password::hash_password("password").unwrap(); + let user = create_test_user("opds2cl", "opds2cl@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap() +} + +fn opds2_get(uri: &str, token: &str) -> hyper::Request { + hyper::Request::builder() + .method("GET") + .uri(uri) + .header("Authorization", format!("Bearer {}", token)) + .body(String::new()) + .unwrap() +} + +#[tokio::test] +async fn test_opds2_collections_feed() { + use codex::db::repositories::CollectionRepository; + let (db, _temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let coll = CollectionRepository::create(&db, "Batman Collection", true) + .await + .unwrap(); + CollectionRepository::add_series(&db, coll.id, series.id) + .await + .unwrap(); + + let state = create_test_auth_state(db).await; + let token = opds2_token(&state.db, &state).await; + let app = create_test_router(state).await; + + // Collections navigation feed. + let (status, feed): (StatusCode, Option) = + make_json_request(app.clone(), opds2_get("/opds/v2/collections", &token)).await; + assert_eq!(status, StatusCode::OK); + let nav = feed.unwrap().navigation.unwrap(); + let link = nav + .iter() + .find(|l| l.title == Some("Batman Collection".to_string())) + .expect("collection nav link"); + assert!( + link.href + .contains(&format!("/opds/v2/collections/{}", coll.id)) + ); + + // Collection detail navigation feed lists the member series. + let (status, feed): (StatusCode, Option) = make_json_request( + app, + opds2_get(&format!("/opds/v2/collections/{}", coll.id), &token), + ) + .await; + assert_eq!(status, StatusCode::OK); + let nav = feed.unwrap().navigation.unwrap(); + assert!( + nav.iter() + .any(|l| l.href.contains(&format!("/opds/v2/series/{}", series.id))) + ); +} + +#[tokio::test] +async fn test_opds2_readlists_feed() { + use codex::db::repositories::ReadListRepository; + let (db, _temp_dir) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Spider-Man", None) + .await + .unwrap(); + let book = create_test_book_with_metadata( + &db, + series.id, + library.id, + "Civil War #1", + "/cw1.cbz", + 1, + 20, + ) + .await; + let rl = ReadListRepository::create(&db, "Civil War", Some("Crossover"), true) + .await + .unwrap(); + ReadListRepository::add_book(&db, rl.id, book.id) + .await + .unwrap(); + + let state = create_test_auth_state(db).await; + let token = opds2_token(&state.db, &state).await; + let app = create_test_router(state).await; + + // Read lists navigation feed. + let (status, feed): (StatusCode, Option) = + make_json_request(app.clone(), opds2_get("/opds/v2/readlists", &token)).await; + assert_eq!(status, StatusCode::OK); + let nav = feed.unwrap().navigation.unwrap(); + assert!(nav.iter().any(|l| l.title == Some("Civil War".to_string()))); + + // Read list detail is a publications feed with the member book. + let (status, feed): (StatusCode, Option) = make_json_request( + app, + opds2_get(&format!("/opds/v2/readlists/{}", rl.id), &token), + ) + .await; + assert_eq!(status, StatusCode::OK); + let publications = feed.unwrap().publications.unwrap(); + assert_eq!(publications.len(), 1); + assert!( + publications[0] + .links + .iter() + .any(|l| l.href.contains(&format!("/api/v1/books/{}/file", book.id))) + ); +} diff --git a/tests/api/readlists.rs b/tests/api/readlists.rs new file mode 100644 index 00000000..0cfa79f6 --- /dev/null +++ b/tests/api/readlists.rs @@ -0,0 +1,283 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{BookDto, ReadListDto, ReadListListResponse}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, +}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +/// Create a library, a series, and N books under it. +async fn make_books( + db: &sea_orm::DatabaseConnection, + count: usize, +) -> Vec { + use chrono::Utc; + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let mut out = Vec::new(); + for _ in 0..count { + let book = codex::db::entities::books::Model { + id: uuid::Uuid::new_v4(), + series_id: series.id, + library_id: library.id, + path: format!("/test/{}.cbz", uuid::Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", uuid::Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + out.push(BookRepository::create(db, &book, None).await.unwrap()); + } + out +} + +#[tokio::test] +async fn test_create_with_summary_get_and_list() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Civil War", "summary": "Crossover event" }), + &token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + assert_eq!(created.name, "Civil War"); + assert_eq!(created.summary.as_deref(), Some("Crossover event")); + // Read lists default to ordered = true. + assert!(created.ordered); + assert_eq!(created.book_count, 0); + + let req = get_request_with_auth(&format!("/api/v1/readlists/{}", created.id), &token); + let (status, fetched): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched.unwrap().id, created.id); + + let req = get_request_with_auth("/api/v1/readlists", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().total, 1); +} + +#[tokio::test] +async fn test_update_clears_summary() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "List", "summary": "to be cleared" }), + &token, + ); + let (_s, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + let id = created.unwrap().id; + + // Explicit null clears the summary; ordered toggled off. + let req = patch_json_request_with_auth( + &format!("/api/v1/readlists/{id}"), + &serde_json::json!({ "summary": null, "ordered": false }), + &token, + ); + let (status, updated): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let updated = updated.unwrap(); + assert_eq!(updated.summary, None); + assert!(!updated.ordered); +} + +#[tokio::test] +async fn test_permission_matrix() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_admin, admin_token) = user_and_token(&db, &state, "admin", true).await; + let (_reader, reader_token) = user_and_token(&db, &state, "reader", false).await; + let app = create_test_router(state).await; + + // Reader can list. + let req = get_request_with_auth("/api/v1/readlists", &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + + // Reader cannot create. + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Nope" }), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // Admin can create and delete. + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Admin List" }), + &admin_token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let id = created.unwrap().id; + + let req = delete_request_with_auth(&format!("/api/v1/readlists/{id}"), &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let req = delete_request_with_auth(&format!("/api/v1/readlists/{id}"), &admin_token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_member_management_order_and_count() { + let (db, _t) = setup_test_db().await; + let books = make_books(&db, 3).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "List", "ordered": true }), + &token, + ); + let (_s, rl): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let rl_id = rl.unwrap().id; + + // Add all three books. + let req = post_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": books.iter().map(|b| b.id).collect::>() }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated.unwrap().book_count, 3); + + // Members in insertion order. + let req = get_request_with_auth(&format!("/api/v1/readlists/{rl_id}/books"), &token); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, books[0].id); + + // Reorder reversed. + let reversed: Vec<_> = books.iter().rev().map(|b| b.id).collect(); + let req = put_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": reversed }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth(&format!("/api/v1/readlists/{rl_id}/books"), &token); + let (_s, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(members.unwrap()[0].id, books[2].id); + + // Remove the middle book. + let req = delete_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books/{}", books[1].id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // books/{id}/readlists reverse lookup. + let req = get_request_with_auth(&format!("/api/v1/books/{}/readlists", books[0].id), &token); + let (status, containers): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let containers = containers.unwrap(); + assert_eq!(containers.total, 1); + assert_eq!(containers.items[0].id, rl_id); +} + +#[tokio::test] +async fn test_add_nonexistent_book_and_duplicate_name() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let body = serde_json::json!({ "name": "Dupe" }); + let req = post_json_request_with_auth("/api/v1/readlists", &body, &token); + let (_s, rl): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let rl_id = rl.unwrap().id; + + // Duplicate name -> 409. + let req = post_json_request_with_auth("/api/v1/readlists", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CONFLICT); + + // Adding an unknown book -> 404. + let req = post_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": [uuid::Uuid::new_v4()] }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/want_to_read.rs b/tests/api/want_to_read.rs new file mode 100644 index 00000000..4b290bc5 --- /dev/null +++ b/tests/api/want_to_read.rs @@ -0,0 +1,324 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{ + SeriesDto, WantToReadEntryDto, WantToReadItemType, WantToReadListResponse, +}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, +}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +/// Create a user and return (id, token). +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +async fn a_book( + db: &sea_orm::DatabaseConnection, + series_id: uuid::Uuid, + library_id: uuid::Uuid, +) -> codex::db::entities::books::Model { + use chrono::Utc; + let book = codex::db::entities::books::Model { + id: uuid::Uuid::new_v4(), + series_id, + library_id, + path: format!("/test/{}.cbz", uuid::Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", uuid::Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + BookRepository::create(db, &book, None).await.unwrap() +} + +async fn library_and_series( + db: &sea_orm::DatabaseConnection, +) -> ( + codex::db::entities::libraries::Model, + codex::db::entities::series::Model, +) { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + (library, series) +} + +#[tokio::test] +async fn test_add_series_and_book_to_queue() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + let book = a_book(&db, series.id, series.library_id).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Add a series. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &token, + ); + let (status, entry): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let entry = entry.unwrap(); + assert_eq!(entry.item_type, WantToReadItemType::Series); + assert_eq!(entry.series_id, Some(series.id)); + assert_eq!(entry.book_id, None); + + // Add a book. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "bookId": book.id }), + &token, + ); + let (status, entry): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(entry.unwrap().item_type, WantToReadItemType::Book); + + // List the queue. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let list = list.unwrap(); + assert_eq!(list.total, 2); + assert_eq!(list.items.len(), 2); +} + +#[tokio::test] +async fn test_add_requires_exactly_one_target() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + let book = a_book(&db, series.id, series.library_id).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Neither provided -> 400. + let req = post_json_request_with_auth("/api/v1/want-to-read", &serde_json::json!({}), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Both provided -> 400. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id, "bookId": book.id }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_add_nonexistent_returns_404() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": uuid::Uuid::new_v4() }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_idempotent_add_and_remove() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + let body = serde_json::json!({ "seriesId": series.id }); + for _ in 0..2 { + let req = post_json_request_with_auth("/api/v1/want-to-read", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + } + + // Still only one entry. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(list.unwrap().total, 1); + + // Remove it. + let req = delete_request_with_auth( + &format!("/api/v1/want-to-read/series/{}", series.id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(list.unwrap().total, 0); +} + +#[tokio::test] +async fn test_queue_is_per_user() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_alice, alice_token) = user_and_token(&db, &state, "alice", false).await; + let (_bob, bob_token) = user_and_token(&db, &state, "bob", false).await; + let app = create_test_router(state).await; + + // Alice adds a series. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &alice_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + // Bob's queue is empty. + let req = get_request_with_auth("/api/v1/want-to-read", &bob_token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(list.unwrap().total, 0); +} + +#[tokio::test] +async fn test_list_sort_direction() { + let (db, _t) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let first = SeriesRepository::create(&db, library.id, "First", None) + .await + .unwrap(); + let second = SeriesRepository::create(&db, library.id, "Second", None) + .await + .unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Add `first`, then `second`, with a gap so added_at is strictly ordered. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": first.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": second.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + + // Default (desc): newest first => `second` before `first`. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + let desc = list.unwrap().items; + assert_eq!(desc[0].series_id, Some(second.id)); + assert_eq!(desc[1].series_id, Some(first.id)); + + // Ascending: oldest first => `first` before `second`. + let req = get_request_with_auth("/api/v1/want-to-read?sort=added_at:asc", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + let asc = list.unwrap().items; + assert_eq!(asc[0].series_id, Some(first.id)); + assert_eq!(asc[1].series_id, Some(second.id)); +} + +#[tokio::test] +async fn test_requires_authentication() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + let req = get_request("/api/v1/want-to-read"); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_series_dto_exposes_want_to_read_flag() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", true).await; + let app = create_test_router(state).await; + + // Before flagging, the detail DTO reports false. + let req = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (status, dto): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(dto.unwrap().want_to_read, Some(false)); + + // Flag it. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + + // Now the detail DTO reports true. + let req = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (_s, dto): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(dto.unwrap().want_to_read, Some(true)); +} diff --git a/tests/db/auth.rs b/tests/db/auth.rs index a8c3c2c9..bec49ff6 100644 --- a/tests/db/auth.rs +++ b/tests/db/auth.rs @@ -164,7 +164,7 @@ async fn test_permission_sets() { assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersWrite)); assert!(ADMIN_PERMISSIONS.contains(&Permission::LibrariesDelete)); - assert_eq!(ADMIN_PERMISSIONS.len(), 23); + assert_eq!(ADMIN_PERMISSIONS.len(), 29); // Test permission serialization roundtrip let perms = READONLY_PERMISSIONS.clone(); @@ -219,7 +219,7 @@ async fn test_user_with_multiple_api_keys() { if key.name == "Mobile App" { assert_eq!(perms.len(), 7); // READONLY } else if key.name == "Admin Tool" { - assert_eq!(perms.len(), 23); // ADMIN + assert_eq!(perms.len(), 29); // ADMIN } else if key.name == "CI/CD" { assert_eq!(perms.len(), 1); assert!(perms.contains(&Permission::BooksRead)); diff --git a/tests/db/collections.rs b/tests/db/collections.rs new file mode 100644 index 00000000..62dfef75 --- /dev/null +++ b/tests/db/collections.rs @@ -0,0 +1,381 @@ +#[path = "../common/mod.rs"] +mod common; + +// Schema/entity tests for collections, read lists, and the per-user +// want-to-read queue (Phase 1 foundation). Exercises round-trips, ordered +// membership, uniqueness, cascade deletes, and the want_to_read CHECK +// constraint on both SQLite and (ignored) PostgreSQL. + +use chrono::Utc; +use codex::db::entities::{ + collection_series, collections, read_list_books, read_lists, want_to_read, +}; +use codex::db::repositories::UserRepository; +use common::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; +use uuid::Uuid; + +async fn persist_user(db: &DatabaseConnection, username: &str) -> users::Model { + let model = create_test_user(username, &format!("{username}@test.test"), "hash", false); + UserRepository::create(db, &model).await.unwrap() +} + +// ============================================================================ +// Collections +// ============================================================================ + +/// Shared assertions: collection holds ordered series membership, membership is +/// unique per (collection, series), and cascades when either side is deleted. +async fn exercise_collections(db: &DatabaseConnection) { + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let s1 = create_test_series(db, &lib, "Series A").await; + let s2 = create_test_series(db, &lib, "Series B").await; + + let coll = collections::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(format!("Batman-{}", Uuid::new_v4())), + normalized_name: Set(format!("batman-{}", Uuid::new_v4())), + ordered: Set(true), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + // s2 first (position 0), s1 second (position 1) — deliberately reverse of + // insertion so the order test verifies position, not insertion order. + for (pos, s) in [(0, &s2), (1, &s1)] { + collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(coll.id), + series_id: Set(s.id), + position: Set(pos), + created_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + } + + // Members come back ordered by position. + let members = collection_series::Entity::find() + .filter(collection_series::Column::CollectionId.eq(coll.id)) + .order_by_asc(collection_series::Column::Position) + .all(db) + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert_eq!(members[0].series_id, s2.id); + assert_eq!(members[1].series_id, s1.id); + + // A series can't be added to the same collection twice. + let dup = collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(coll.id), + series_id: Set(s1.id), + position: Set(2), + created_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + dup.is_err(), + "duplicate (collection, series) must be rejected" + ); + + // Deleting a series cascades its membership row away. + series::Entity::delete_by_id(s1.id).exec(db).await.unwrap(); + let after_series_delete = collection_series::Entity::find() + .filter(collection_series::Column::SeriesId.eq(s1.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_series_delete, 0, + "series delete should cascade membership" + ); + + // Deleting the collection cascades the remaining membership rows. + collections::Entity::delete_by_id(coll.id) + .exec(db) + .await + .unwrap(); + let after_coll_delete = collection_series::Entity::find() + .filter(collection_series::Column::CollectionId.eq(coll.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_coll_delete, 0, + "collection delete should cascade membership" + ); +} + +#[tokio::test] +async fn test_collections_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_collections(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_collections_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_collections(&db).await; +} + +// ============================================================================ +// Read lists +// ============================================================================ + +async fn exercise_read_lists(db: &DatabaseConnection) { + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let series = create_test_series(db, &lib, "Series A").await; + let b1 = create_test_book_with_hash(db, &lib, &series, "B1", &uniq_path(), &rand_hash()).await; + let b2 = create_test_book_with_hash(db, &lib, &series, "B2", &uniq_path(), &rand_hash()).await; + + let rl = read_lists::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(format!("Wolverine-{}", Uuid::new_v4())), + normalized_name: Set(format!("wolverine-{}", Uuid::new_v4())), + summary: Set(Some("Every book where Wolverine appears".to_string())), + ordered: Set(true), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + assert_eq!( + rl.summary.as_deref(), + Some("Every book where Wolverine appears") + ); + + for (pos, b) in [(0, &b2), (1, &b1)] { + read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(rl.id), + book_id: Set(b.id), + position: Set(pos), + created_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + } + + let members = read_list_books::Entity::find() + .filter(read_list_books::Column::ReadListId.eq(rl.id)) + .order_by_asc(read_list_books::Column::Position) + .all(db) + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert_eq!(members[0].book_id, b2.id); + assert_eq!(members[1].book_id, b1.id); + + // A book can't be added to the same read list twice. + let dup = read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(rl.id), + book_id: Set(b1.id), + position: Set(2), + created_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!(dup.is_err(), "duplicate (read_list, book) must be rejected"); + + // Deleting a book cascades its membership row. + books::Entity::delete_by_id(b1.id).exec(db).await.unwrap(); + let after_book_delete = read_list_books::Entity::find() + .filter(read_list_books::Column::BookId.eq(b1.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_book_delete, 0, + "book delete should cascade membership" + ); + + // Deleting the read list cascades remaining membership. + read_lists::Entity::delete_by_id(rl.id) + .exec(db) + .await + .unwrap(); + let after_rl_delete = read_list_books::Entity::find() + .filter(read_list_books::Column::ReadListId.eq(rl.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_rl_delete, 0, + "read list delete should cascade membership" + ); +} + +#[tokio::test] +async fn test_read_lists_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_read_lists(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_read_lists_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_read_lists(&db).await; +} + +// ============================================================================ +// Want to Read (per-user queue + CHECK constraint + partial uniqueness) +// ============================================================================ + +async fn exercise_want_to_read(db: &DatabaseConnection) { + let user = persist_user(db, &format!("wtr-{}", Uuid::new_v4())).await; + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let series = create_test_series(db, &lib, "Series A").await; + let book = create_test_book_with_hash(db, &lib, &series, "B", &uniq_path(), &rand_hash()).await; + let book2 = + create_test_book_with_hash(db, &lib, &series, "B2", &uniq_path(), &rand_hash()).await; + + // A series-only entry and a book-only entry both insert cleanly. + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(Some(book.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + let queue = want_to_read::Entity::find() + .filter(want_to_read::Column::UserId.eq(user.id)) + .all(db) + .await + .unwrap(); + assert_eq!( + queue.len(), + 2, + "both a series and a book entry should persist" + ); + + // CHECK: neither side set is rejected. + let both_null = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + both_null.is_err(), + "both-null must violate the CHECK constraint" + ); + + // CHECK: both sides set is rejected. + let both_set = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(Some(book2.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + both_set.is_err(), + "both-set must violate the CHECK constraint" + ); + + // Same series can't be flagged twice for the same user. + let dup_series = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + dup_series.is_err(), + "duplicate (user, series) must be rejected" + ); + + // A second, distinct book-only entry coexists with the first even though + // both have series_id NULL — NULLs are distinct in the unique index. + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(Some(book2.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .expect("multiple book-only rows (series_id NULL) for one user must be allowed"); + + // Deleting the user cascades the whole queue away. + users::Entity::delete_by_id(user.id).exec(db).await.unwrap(); + let after_user_delete = want_to_read::Entity::find() + .filter(want_to_read::Column::UserId.eq(user.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_user_delete, 0, + "user delete should cascade the want-to-read queue" + ); +} + +#[tokio::test] +async fn test_want_to_read_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_want_to_read(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_want_to_read_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_want_to_read(&db).await; +} + +// Distinct, throwaway paths/hashes so parallel tests (and the shared PostgreSQL +// database) never collide on unique constraints. +fn uniq_path() -> String { + format!("/test/{}", Uuid::new_v4()) +} + +fn rand_hash() -> String { + Uuid::new_v4().simple().to_string() +} diff --git a/tests/db/mod.rs b/tests/db/mod.rs index 6d2d827d..77c649ff 100644 --- a/tests/db/mod.rs +++ b/tests/db/mod.rs @@ -4,6 +4,7 @@ mod auth; mod book_duplicates; +mod collections; mod migrations; mod postgres; mod refresh_token_repository; diff --git a/web/openapi.json b/web/openapi.json index 348387f2..718ff693 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5295,6 +5295,49 @@ ] } }, + "/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List the read lists that contain a given book.", + "operationId": "get_book_readlists", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read lists containing the book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/books/{book_id}/retry": { "post": { "tags": [ @@ -5510,6 +5553,461 @@ ] } }, + "/api/v1/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List all collections.", + "operationId": "list_collections", + "responses": { + "200": { + "description": "Collections", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Create a collection.", + "operationId": "create_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A collection with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection.", + "operationId": "get_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Collections" + ], + "summary": "Delete a collection.", + "operationId": "delete_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Collections" + ], + "summary": "Update a collection (rename / toggle ordered).", + "operationId": "update_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get the series in a collection (visibility-filtered, in stored order).", + "operationId": "get_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member series", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Collections" + ], + "summary": "Set the manual order of a collection's series.", + "operationId": "reorder_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderCollectionSeriesRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Add one or more series to a collection.", + "operationId": "add_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesToCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection or series not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series/{series_id}": { + "delete": { + "tags": [ + "Collections" + ], + "summary": "Remove a series from a collection.", + "operationId": "remove_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection's thumbnail (the first visible member series' cover).", + "operationId": "get_collection_thumbnail", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member series thumbnail" + }, + "404": { + "description": "No visible member series" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/duplicates": { "get": { "tags": [ @@ -8240,6 +8738,461 @@ ] } }, + "/api/v1/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List all read lists.", + "operationId": "list_readlists", + "responses": { + "200": { + "description": "Read lists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Create a read list.", + "operationId": "create_readlist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A read list with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list.", + "operationId": "get_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Delete a read list.", + "operationId": "delete_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Read Lists" + ], + "summary": "Update a read list (rename / edit summary / toggle ordered).", + "operationId": "update_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get the books in a read list (visibility-filtered, in stored order).", + "operationId": "get_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Read Lists" + ], + "summary": "Set the manual order of a read list's books.", + "operationId": "reorder_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderReadListBooksRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Add one or more books to a read list.", + "operationId": "add_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBooksToReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books/{book_id}": { + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Remove a book from a read list.", + "operationId": "remove_readlist_book", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list's thumbnail (the first visible member book's cover).", + "operationId": "get_readlist_thumbnail", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member book thumbnail" + }, + "404": { + "description": "No visible member books" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources": { "get": { "tags": [ @@ -11423,6 +12376,49 @@ ] } }, + "/api/v1/series/{series_id}/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List the collections that contain a given series.", + "operationId": "get_series_collections", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collections containing the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/series/{series_id}/cover": { "post": { "tags": [ @@ -16429,6 +17425,169 @@ ] } }, + "/api/v1/want-to-read": { + "get": { + "tags": [ + "Want to Read" + ], + "summary": "List the authenticated user's want-to-read queue.", + "operationId": "list_want_to_read", + "parameters": [ + { + "name": "sort", + "in": "query", + "description": "Sort by add time. Accepts `added_at:asc` for oldest-first; any other\nvalue (or omitted) yields newest-first (`added_at:desc`).", + "required": false, + "schema": { + "type": "string" + }, + "example": "added_at:desc" + } + ], + "responses": { + "200": { + "description": "Queue retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Want to Read" + ], + "summary": "Add a series or book to the authenticated user's queue.", + "description": "Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging\nsomething already queued returns the existing entry.", + "operationId": "add_want_to_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWantToReadRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry added (or already present)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadEntryDto" + } + } + } + }, + "400": { + "description": "Must provide exactly one of seriesId / bookId" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Series or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/books/{book_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a book from the authenticated user's queue.", + "operationId": "remove_want_to_read_book", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/series/{series_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a series from the authenticated user's queue.", + "operationId": "remove_want_to_read_series", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -16574,6 +17733,77 @@ ] } }, + "/opds/collections": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List collections (navigation feed)", + "operationId": "opds_list_collections", + "responses": { + "200": { + "description": "OPDS collections feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/collections/{collection_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List the series in a collection (navigation feed)", + "operationId": "opds_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS collection series feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/libraries": { "get": { "tags": [ @@ -16667,6 +17897,77 @@ ] } }, + "/opds/readlists": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List read lists (navigation feed)", + "operationId": "opds_list_readlists", + "responses": { + "200": { + "description": "OPDS read lists feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/readlists/{read_list_id}": { + "get": { + "tags": [ + "OPDS" + ], + "summary": "List the books in a read list (acquisition feed)", + "operationId": "opds_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "description": "Read list ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS read list books feed", + "content": { + "application/atom+xml": {} + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/search": { "get": { "tags": [ @@ -16810,6 +18111,85 @@ ] } }, + "/opds/v2/collections": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List collections (OPDS 2.0 navigation feed)", + "operationId": "opds2_list_collections", + "responses": { + "200": { + "description": "OPDS 2.0 collections feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/collections/{collection_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List the series in a collection (OPDS 2.0 navigation feed)", + "operationId": "opds2_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 collection series feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/v2/libraries": { "get": { "tags": [ @@ -16911,6 +18291,85 @@ ] } }, + "/opds/v2/readlists": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List read lists (OPDS 2.0 navigation feed)", + "operationId": "opds2_list_readlists", + "responses": { + "200": { + "description": "OPDS 2.0 read lists feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/opds/v2/readlists/{read_list_id}": { + "get": { + "tags": [ + "OPDS 2.0" + ], + "summary": "List the books in a read list (OPDS 2.0 publications feed)", + "operationId": "opds2_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "description": "Read list ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OPDS 2.0 read list books feed", + "content": { + "application/opds+json": { + "schema": { + "$ref": "#/components/schemas/Opds2Feed" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/opds/v2/recent": { "get": { "tags": [ @@ -17815,6 +19274,57 @@ ] } }, + "/{prefix}/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List the read lists that contain a book (Komga-compatible).", + "operationId": "komga_get_book_readlists", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaReadListDto" + } + } + } + } + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/{prefix}/api/v1/books/{book_id}/thumbnail": { "get": { "tags": [ @@ -17873,14 +19383,13 @@ "tags": [ "Komga" ], - "summary": "List collections (stub - always returns empty)", - "description": "Komga collections are user-created groupings of series.\nCodex doesn't support this feature, so we return empty results.\n\n## Endpoint\n`GET /{prefix}/api/v1/collections`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "summary": "List collections (Komga-compatible).", "operationId": "komga_list_collections", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -17889,7 +19398,7 @@ ], "responses": { "200": { - "description": "Empty list of collections", + "description": "", "content": { "application/json": { "schema": { @@ -17899,7 +19408,7 @@ } }, "401": { - "description": "Unauthorized" + "description": "" } }, "security": [ @@ -17912,19 +19421,26 @@ ] } }, - "/{prefix}/api/v1/genres": { + "/{prefix}/api/v1/collections/{collection_id}": { "get": { "tags": [ "Komga" ], - "summary": "List genres", - "description": "Returns all genres in the library.\n\n## Endpoint\n`GET /{prefix}/api/v1/genres`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_genres", + "summary": "Get a collection (Komga-compatible).", + "operationId": "komga_get_collection", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", "required": true, "schema": { "type": "string" @@ -17933,20 +19449,17 @@ ], "responses": { "200": { - "description": "List of all genres", + "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/components/schemas/KomgaCollectionDto" } } } }, - "401": { - "description": "Unauthorized" + "404": { + "description": "" } }, "security": [ @@ -17959,14 +19472,109 @@ ] } }, - "/{prefix}/api/v1/languages": { + "/{prefix}/api/v1/collections/{collection_id}/series": { "get": { "tags": [ "Komga" ], - "summary": "List languages (stub - always returns empty array)", - "description": "Returns all languages in the library.\nCurrently returns empty as Codex doesn't aggregate languages separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/languages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_languages", + "summary": "Get the series in a collection (Komga-compatible).", + "operationId": "komga_get_collection_series", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a collection's thumbnail (redirects to the first visible member series).", + "operationId": "komga_get_collection_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "307": { + "description": "" + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/genres": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List genres", + "description": "Returns all genres in the library.\n\n## Endpoint\n`GET /{prefix}/api/v1/genres`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_genres", "parameters": [ { "name": "prefix", @@ -17980,7 +19588,54 @@ ], "responses": { "200": { - "description": "Empty list of languages", + "description": "List of all genres", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/languages": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List languages (stub - always returns empty array)", + "description": "Returns all languages in the library.\nCurrently returns empty as Codex doesn't aggregate languages separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/languages`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_languages", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Empty list of languages", "content": { "application/json": { "schema": { @@ -18215,14 +19870,13 @@ "tags": [ "Komga" ], - "summary": "List read lists (stub - always returns empty)", - "description": "Komga read lists are user-created lists of books to read.\nCodex doesn't support this feature, so we return empty results.\n\n## Endpoint\n`GET /{prefix}/api/v1/readlists`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "summary": "List read lists (Komga-compatible).", "operationId": "komga_list_readlists", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -18231,7 +19885,7 @@ ], "responses": { "200": { - "description": "Empty list of read lists", + "description": "", "content": { "application/json": { "schema": { @@ -18241,7 +19895,153 @@ } }, "401": { - "description": "Unauthorized" + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a read list (Komga-compatible).", + "operationId": "komga_get_readlist", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaReadListDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get the books in a read list (Komga-compatible).", + "operationId": "komga_get_readlist_books", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get a read list's thumbnail (redirects to the first visible member book).", + "operationId": "komga_get_readlist_thumbnail", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "307": { + "description": "" + }, + "404": { + "description": "" } }, "security": [ @@ -18466,14 +20266,263 @@ ] } }, - "/{prefix}/api/v1/series/new": { + "/{prefix}/api/v1/series/new": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently added series", + "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_new", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently added series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/release-dates": { + "get": { + "tags": [ + "Komga" + ], + "summary": "List series release dates (stub - always returns empty array)", + "description": "Returns all release dates used by series in the library.\nCurrently returns empty as Codex doesn't aggregate release dates separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/release-dates`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_list_series_release_dates", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Empty list of release dates", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/updated": { + "get": { + "tags": [ + "Komga" + ], + "summary": "Get recently updated series", + "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_updated", + "parameters": [ + { + "name": "prefix", + "in": "path", + "description": "Komga API prefix (default: komga)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (0-indexed, Komga-style)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "description": "Page size (default: 20)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "library_id", + "in": "query", + "description": "Filter by library ID", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "search", + "in": "query", + "description": "Search query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated list of recently updated series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/{prefix}/api/v1/series/{series_id}": { "get": { "tags": [ "Komga" ], - "summary": "Get recently added series", - "description": "Returns series sorted by created date descending (newest first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/new`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_new", + "summary": "Get series by ID", + "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series", "parameters": [ { "name": "prefix", @@ -18485,76 +20534,32 @@ } }, { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, + "name": "series_id", + "in": "path", + "description": "Series ID", + "required": true, "schema": { - "type": [ - "string", - "null" - ], + "type": "string", "format": "uuid" } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } } ], "responses": { "200": { - "description": "Paginated list of recently added series", + "description": "Series details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" + "$ref": "#/components/schemas/KomgaSeriesDto" } } } }, "401": { "description": "Unauthorized" + }, + "404": { + "description": "Series not found" } }, "security": [ @@ -18567,14 +20572,14 @@ ] } }, - "/{prefix}/api/v1/series/release-dates": { + "/{prefix}/api/v1/series/{series_id}/books": { "get": { "tags": [ "Komga" ], - "summary": "List series release dates (stub - always returns empty array)", - "description": "Returns all release dates used by series in the library.\nCurrently returns empty as Codex doesn't aggregate release dates separately.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/release-dates`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_list_series_release_dates", + "summary": "Get books in a series", + "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", + "operationId": "komga_get_series_books", "parameters": [ { "name": "prefix", @@ -18584,52 +20589,15 @@ "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "Empty list of release dates", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] }, { - "api_key": [] - } - ] - } - }, - "/{prefix}/api/v1/series/updated": { - "get": { - "tags": [ - "Komga" - ], - "summary": "Get recently updated series", - "description": "Returns series sorted by last modified date descending (most recently updated first).\n\n## Endpoint\n`GET /{prefix}/api/v1/series/updated`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n- `library_id` - Optional filter by library UUID\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_updated", - "parameters": [ - { - "name": "prefix", + "name": "series_id", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Series ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -18692,65 +20660,11 @@ ], "responses": { "200": { - "description": "Paginated list of recently updated series", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaSeriesDto" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "jwt_bearer": [] - }, - { - "api_key": [] - } - ] - } - }, - "/{prefix}/api/v1/series/{series_id}": { - "get": { - "tags": [ - "Komga" - ], - "summary": "Get series by ID", - "description": "Returns a single series in Komga-compatible format.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}`\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series", - "parameters": [ - { - "name": "prefix", - "in": "path", - "description": "Komga API prefix (default: komga)", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "series_id", - "in": "path", - "description": "Series ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "Series details", + "description": "Paginated list of books in series", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaSeriesDto" + "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" } } } @@ -18772,19 +20686,18 @@ ] } }, - "/{prefix}/api/v1/series/{series_id}/books": { + "/{prefix}/api/v1/series/{series_id}/collections": { "get": { "tags": [ "Komga" ], - "summary": "Get books in a series", - "description": "Returns all books in a series with pagination.\n\n## Endpoint\n`GET /{prefix}/api/v1/series/{seriesId}/books`\n\n## Query Parameters\n- `page` - Page number (0-indexed, default: 0)\n- `size` - Page size (default: 20)\n\n## Authentication\n- Bearer token (JWT)\n- Basic Auth\n- API Key", - "operationId": "komga_get_series_books", + "summary": "List the collections that contain a series (Komga-compatible).", + "operationId": "komga_get_series_collections", "parameters": [ { "name": "prefix", "in": "path", - "description": "Komga API prefix (default: komga)", + "description": "Komga API prefix", "required": true, "schema": { "type": "string" @@ -18793,87 +20706,25 @@ { "name": "series_id", "in": "path", - "description": "Series ID", "required": true, "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (0-indexed, Komga-style)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "size", - "in": "query", - "description": "Page size (default: 20)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "library_id", - "in": "query", - "description": "Filter by library ID", - "required": false, - "schema": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - }, - { - "name": "search", - "in": "query", - "description": "Search query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "sort", - "in": "query", - "description": "Sort parameter (e.g., \"metadata.titleSort,asc\", \"createdDate,desc\")", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "type": "string" } } ], "responses": { "200": { - "description": "Paginated list of books in series", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KomgaPage_KomgaBookDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/KomgaCollectionDto" + } } } } - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Series not found" } }, "security": [ @@ -19490,6 +21341,25 @@ } } }, + "AddBooksToReadListRequest": { + "type": "object", + "description": "Request to add one or more books to a read list.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddSeriesGenreRequest": { "type": "object", "description": "Request to add a single genre to a series", @@ -19518,6 +21388,47 @@ } } }, + "AddSeriesToCollectionRequest": { + "type": "object", + "description": "Request to add one or more series to a collection.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, + "AddWantToReadRequest": { + "type": "object", + "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", + "properties": { + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a series." + } + } + }, "AdjacentBooksResponse": { "type": "object", "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", @@ -20605,6 +22516,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -23823,6 +25742,70 @@ } } }, + "CollectionDto": { + "type": "object", + "description": "A collection of series.", + "required": [ + "id", + "name", + "ordered", + "seriesCount", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual order; otherwise sorted by title.", + "example": false + }, + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of member series visible to the requesting user.", + "example": 12, + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CollectionListResponse": { + "type": "object", + "description": "List of collections.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ConfigFieldDto": { "type": "object", "description": "Configuration field definition for documenting plugin config options", @@ -24101,6 +26084,24 @@ } } }, + "CreateCollectionRequest": { + "type": "object", + "description": "Request to create a collection.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `false` (members sorted by title).", + "example": false + } + } + }, "CreateExternalLinkRequest": { "type": "object", "description": "Request to create or update an external link for a series", @@ -24533,6 +26534,30 @@ } } }, + "CreateReadListRequest": { + "type": "object", + "description": "Request to create a read list.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `true` (manual reading order).", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateSeriesAliasRequest": { "type": "object", "required": [ @@ -26983,6 +29008,14 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.", + "example": false } } }, @@ -27365,6 +29398,14 @@ "format": "int64", "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", "example": 14 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.", + "example": false } } }, @@ -31906,6 +33947,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -32600,6 +34649,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -35414,6 +37471,77 @@ } } }, + "ReadListDto": { + "type": "object", + "description": "A read list.", + "required": [ + "id", + "name", + "ordered", + "bookCount", + "createdAt", + "updatedAt" + ], + "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Number of member books visible to the requesting user.", + "example": 24, + "minimum": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual reading order; otherwise sorted by\nrelease date.", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Optional description (Komga read lists carry a summary)." + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadListListResponse": { + "type": "object", + "description": "List of read lists.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ReadProgressListResponse": { "type": "object", "description": "Response containing a list of reading progress records", @@ -36302,6 +38430,46 @@ } } }, + "ReorderCollectionSeriesRequest": { + "type": "object", + "description": "Request to set the manual order of a collection's series. IDs not currently\nmembers are ignored; omitted members keep their existing position.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, + "ReorderReadListBooksRequest": { + "type": "object", + "description": "Request to set the manual order of a read list's books.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -37932,6 +40100,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -41478,6 +43654,24 @@ } } }, + "UpdateCollectionRequest": { + "type": "object", + "description": "Request to update a collection. Absent fields are left unchanged.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateFilterPresetRequest": { "type": "object", "description": "Request body for updating an existing filter preset.\n\nTreated as a full replacement of the mutable fields. `scope` and `target`\nare immutable since the condition is validated against them on create.", @@ -41966,6 +44160,31 @@ } } }, + "UpdateReadListRequest": { + "type": "object", + "description": "Request to update a read list. Absent fields are left unchanged. To clear the\nsummary, send `summary: null` explicitly.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "`Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it." + } + } + }, "UpdateReleaseLedgerEntryRequest": { "type": "object", "description": "PATCH payload for ledger row state transitions.\n\nOnly `state` is patchable from the API today; the rest of the row is\nsource-controlled. `state` is validated against the canonical set:\n`announced` | `dismissed` | `marked_acquired` | `hidden`.", @@ -42934,6 +45153,80 @@ "description": "User information" } } + }, + "WantToReadEntryDto": { + "type": "object", + "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", + "required": [ + "id", + "itemType", + "addedAt" + ], + "properties": { + "addedAt": { + "type": "string", + "format": "date-time", + "description": "When the entry was added to the queue.", + "example": "2026-06-15T18:45:00Z" + }, + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged book (set when `item_type` is `book`)." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Queue entry ID.", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "itemType": { + "$ref": "#/components/schemas/WantToReadItemType", + "description": "Whether this entry flags a series or a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged series (set when `item_type` is `series`)." + } + } + }, + "WantToReadItemType": { + "type": "string", + "description": "What a want-to-read entry points at.", + "enum": [ + "series", + "book" + ] + }, + "WantToReadListResponse": { + "type": "object", + "description": "A user's want-to-read queue.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WantToReadEntryDto" + }, + "description": "Queue entries." + }, + "total": { + "type": "integer", + "description": "Total number of entries.", + "example": 7, + "minimum": 0 + } + } } }, "securitySchemes": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 799fe7b3..9b3e5591 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,12 +14,16 @@ import { setupApi } from "@/api/setup"; import { AppLayout } from "@/components/layout/AppLayout"; import { useEntityEvents } from "@/hooks/useEntityEvents"; import { BookDetail } from "@/pages/BookDetail"; +import { CollectionDetail } from "@/pages/CollectionDetail"; +import { Collections } from "@/pages/Collections"; import { Home } from "@/pages/Home"; import { LibraryPage } from "@/pages/Library"; import { LibraryJobsPage } from "@/pages/LibraryJobs"; import { Login } from "@/pages/Login"; import { OidcComplete } from "@/pages/OidcComplete"; import { Reader } from "@/pages/Reader"; +import { ReadListDetail } from "@/pages/ReadListDetail"; +import { ReadLists } from "@/pages/ReadLists"; import { Recommendations } from "@/pages/Recommendations"; import { Register } from "@/pages/Register"; import { ReleasesInbox } from "@/pages/ReleasesInbox"; @@ -46,6 +50,7 @@ import { TasksSettings, UsersSettings, } from "@/pages/settings"; +import { WantToRead } from "@/pages/WantToRead"; import { navigationService } from "@/services/navigation"; import { useAuthStore } from "@/store/authStore"; import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; @@ -202,6 +207,61 @@ function App() { } /> + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + => { + const response = await api.get("/collections"); + return response.data.items; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/collections/${id}`); + return response.data; + }, + + /** Member series, in stored order, filtered by the user's visibility. */ + getSeries: async (id: string): Promise => { + const response = await api.get(`/collections/${id}/series`); + return response.data; + }, + + create: async (body: CreateCollectionRequest): Promise => { + const response = await api.post("/collections", body); + return response.data; + }, + + update: async ( + id: string, + body: UpdateCollectionRequest, + ): Promise => { + const response = await api.patch(`/collections/${id}`, body); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/collections/${id}`); + }, + + addSeries: async (id: string, seriesIds: string[]): Promise => { + const response = await api.post(`/collections/${id}/series`, { + seriesIds, + }); + return response.data; + }, + + removeSeries: async (id: string, seriesId: string): Promise => { + await api.delete(`/collections/${id}/series/${seriesId}`); + }, + + /** Set the full manual order of a collection's series. */ + reorder: async (id: string, seriesIds: string[]): Promise => { + await api.put(`/collections/${id}/series`, { seriesIds }); + }, + + /** Collections that contain a given series. */ + forSeries: async (seriesId: string): Promise => { + const response = await api.get( + `/series/${seriesId}/collections`, + ); + return response.data.items; + }, +}; diff --git a/web/src/api/readlists.ts b/web/src/api/readlists.ts new file mode 100644 index 00000000..d9f45e40 --- /dev/null +++ b/web/src/api/readlists.ts @@ -0,0 +1,71 @@ +import type { Book } from "@/types"; +import type { components } from "@/types/api.generated"; +import { api } from "./client"; + +export type ReadList = components["schemas"]["ReadListDto"]; +export type CreateReadListRequest = + components["schemas"]["CreateReadListRequest"]; +export type UpdateReadListRequest = + components["schemas"]["UpdateReadListRequest"]; + +type ReadListListResponse = components["schemas"]["ReadListListResponse"]; + +export const readListsApi = { + /** All read lists (with each read list's visible book count). */ + list: async (): Promise => { + const response = await api.get("/readlists"); + return response.data.items; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/readlists/${id}`); + return response.data; + }, + + /** Member books, in stored order, filtered by the user's visibility. */ + getBooks: async (id: string): Promise => { + const response = await api.get(`/readlists/${id}/books`); + return response.data; + }, + + create: async (body: CreateReadListRequest): Promise => { + const response = await api.post("/readlists", body); + return response.data; + }, + + update: async ( + id: string, + body: UpdateReadListRequest, + ): Promise => { + const response = await api.patch(`/readlists/${id}`, body); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/readlists/${id}`); + }, + + addBooks: async (id: string, bookIds: string[]): Promise => { + const response = await api.post(`/readlists/${id}/books`, { + bookIds, + }); + return response.data; + }, + + removeBook: async (id: string, bookId: string): Promise => { + await api.delete(`/readlists/${id}/books/${bookId}`); + }, + + /** Set the full manual reading order of a read list's books. */ + reorder: async (id: string, bookIds: string[]): Promise => { + await api.put(`/readlists/${id}/books`, { bookIds }); + }, + + /** Read lists that contain a given book. */ + forBook: async (bookId: string): Promise => { + const response = await api.get( + `/books/${bookId}/readlists`, + ); + return response.data.items; + }, +}; diff --git a/web/src/api/wantToRead.ts b/web/src/api/wantToRead.ts new file mode 100644 index 00000000..6667a1e5 --- /dev/null +++ b/web/src/api/wantToRead.ts @@ -0,0 +1,50 @@ +import type { components } from "@/types/api.generated"; +import { api } from "./client"; + +export type WantToReadEntry = components["schemas"]["WantToReadEntryDto"]; +export type WantToReadListResponse = + components["schemas"]["WantToReadListResponse"]; +export type WantToReadItemType = components["schemas"]["WantToReadItemType"]; + +/** Sort direction for the queue, by add time. */ +export type WantToReadSort = "newest" | "oldest"; + +export const wantToReadApi = { + /** + * List the current user's want-to-read queue. + * `newest` (default) returns most-recently-added first. + */ + list: async (sort: WantToReadSort = "newest"): Promise => { + const query = sort === "oldest" ? "?sort=added_at:asc" : ""; + const response = await api.get( + `/want-to-read${query}`, + ); + return response.data.items; + }, + + /** Add a series to the queue. Idempotent. */ + addSeries: async (seriesId: string): Promise => { + const response = await api.post("/want-to-read", { + seriesId, + }); + return response.data; + }, + + /** Add a book to the queue. Idempotent. */ + addBook: async (bookId: string): Promise => { + const response = await api.post("/want-to-read", { + bookId, + }); + return response.data; + }, + + /** Remove a series from the queue. */ + removeSeries: async (seriesId: string): Promise => { + await api.delete(`/want-to-read/series/${seriesId}`); + }, + + /** Remove a book from the queue. */ + removeBook: async (bookId: string): Promise => { + await api.delete(`/want-to-read/books/${bookId}`); + }, +}; diff --git a/web/src/components/WantToReadButton.test.tsx b/web/src/components/WantToReadButton.test.tsx new file mode 100644 index 00000000..0aec1ca4 --- /dev/null +++ b/web/src/components/WantToReadButton.test.tsx @@ -0,0 +1,52 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { wantToReadApi } from "@/api/wantToRead"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { WantToReadButton } from "./WantToReadButton"; + +vi.mock("@/api/wantToRead", () => ({ + wantToReadApi: { + addSeries: vi.fn().mockResolvedValue({}), + addBook: vi.fn().mockResolvedValue({}), + removeSeries: vi.fn().mockResolvedValue(undefined), + removeBook: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe("WantToReadButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds a series when it isn't in the queue yet", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: /add to want to read/i }), + ); + + await waitFor(() => + expect(wantToReadApi.addSeries).toHaveBeenCalledWith("series-1"), + ); + expect(wantToReadApi.removeSeries).not.toHaveBeenCalled(); + }); + + it("removes a book when it is already in the queue", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: /remove from want to read/i }), + ); + + await waitFor(() => + expect(wantToReadApi.removeBook).toHaveBeenCalledWith("book-1"), + ); + expect(wantToReadApi.addBook).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/WantToReadButton.tsx b/web/src/components/WantToReadButton.tsx new file mode 100644 index 00000000..7d10bbc1 --- /dev/null +++ b/web/src/components/WantToReadButton.tsx @@ -0,0 +1,58 @@ +import { ActionIcon, type MantineSize, Tooltip } from "@mantine/core"; +import { IconBookmark, IconBookmarkFilled } from "@tabler/icons-react"; +import { + useAddToWantToRead, + useRemoveFromWantToRead, +} from "@/hooks/useWantToRead"; + +interface WantToReadButtonProps { + itemType: "series" | "book"; + id: string; + /** Current queue membership (from the series/book DTO). */ + wantToRead: boolean | null | undefined; + size?: MantineSize | number; +} + +/** + * Bookmark-style toggle that adds/removes a series or book from the user's + * want-to-read queue. State is driven by the `wantToRead` prop; after a toggle + * the owning detail query is invalidated so the prop refreshes. + */ +export function WantToReadButton({ + itemType, + id, + wantToRead, + size = "lg", +}: WantToReadButtonProps) { + const add = useAddToWantToRead(); + const remove = useRemoveFromWantToRead(); + + const active = Boolean(wantToRead); + const pending = add.isPending || remove.isPending; + const label = active ? "Remove from Want to Read" : "Add to Want to Read"; + + const toggle = () => { + if (pending) return; + if (active) { + remove.mutate({ itemType, id }); + } else { + add.mutate({ itemType, id }); + } + }; + + return ( + + + {active ? : } + + + ); +} diff --git a/web/src/components/collections/AddToCollectionButton.tsx b/web/src/components/collections/AddToCollectionButton.tsx new file mode 100644 index 00000000..e9c334e2 --- /dev/null +++ b/web/src/components/collections/AddToCollectionButton.tsx @@ -0,0 +1,94 @@ +import { ActionIcon, Loader, Menu, Tooltip } from "@mantine/core"; +import { IconCheck, IconFolderPlus, IconPlus } from "@tabler/icons-react"; +import { useState } from "react"; +import { + useAddSeriesToCollection, + useCollections, + useCollectionsForSeries, + useRemoveSeriesFromCollections, +} from "@/hooks/useCollections"; +import { CollectionFormModal } from "./CollectionFormModal"; + +/** + * Toggle membership of a series across collections, and create new ones inline. + * Render only for users with `collections:write`. + */ +export function AddToCollectionButton({ seriesId }: { seriesId: string }) { + const { data: collections, isLoading } = useCollections(); + const { data: memberOf } = useCollectionsForSeries(seriesId); + const add = useAddSeriesToCollection(); + const remove = useRemoveSeriesFromCollections(); + const [createOpen, setCreateOpen] = useState(false); + + const memberIds = new Set((memberOf ?? []).map((c) => c.id)); + const busy = add.isPending || remove.isPending; + + const toggle = (collectionId: string) => { + if (memberIds.has(collectionId)) { + remove.mutate({ collectionId, seriesId }); + } else { + add.mutate({ collectionId, seriesIds: [seriesId] }); + } + }; + + return ( + <> + + + + + + + + + + Collections + {isLoading ? ( + + + + ) : !collections || collections.length === 0 ? ( + No collections yet + ) : ( + collections.map((c) => ( + + ) : ( + + ) + } + onClick={() => toggle(c.id)} + > + {c.name} + + )) + )} + + } + onClick={() => setCreateOpen(true)} + > + New collection… + + + + + setCreateOpen(false)} + onCreated={(c) => + add.mutate({ collectionId: c.id, seriesIds: [seriesId] }) + } + /> + + ); +} diff --git a/web/src/components/collections/BulkAddToCollectionSub.tsx b/web/src/components/collections/BulkAddToCollectionSub.tsx new file mode 100644 index 00000000..94e13583 --- /dev/null +++ b/web/src/components/collections/BulkAddToCollectionSub.tsx @@ -0,0 +1,63 @@ +import { Loader, Menu } from "@mantine/core"; +import { IconFolderPlus, IconPlus } from "@tabler/icons-react"; +import { useCollections } from "@/hooks/useCollections"; + +/** + * Nested submenu listing every collection, for embedding in the bulk-selection + * "More" menu. Unlike the single-series version this does not show membership + * checkmarks: across many series a single checked state is meaningless, so + * picking a collection simply adds all selected series to it. + * + * `onRequestCreate` is delegated to the parent so the create modal can be + * mounted outside this Menu (an item click closes the menu, which would + * otherwise unmount a modal rendered within it). + * + * Render only for users with `collections:write`. + */ +export function BulkAddToCollectionSub({ + onSelect, + onRequestCreate, + disabled, +}: { + onSelect: (collectionId: string) => void; + onRequestCreate: () => void; + disabled?: boolean; +}) { + const { data: collections, isLoading } = useCollections(); + + return ( + + + }> + Add to collection + + + + {isLoading ? ( + + + + ) : !collections || collections.length === 0 ? ( + No collections yet + ) : ( + collections.map((c) => ( + onSelect(c.id)} + > + {c.name} + + )) + )} + + } + onClick={onRequestCreate} + > + New collection… + + + + ); +} diff --git a/web/src/components/collections/CollectionFormModal.test.tsx b/web/src/components/collections/CollectionFormModal.test.tsx new file mode 100644 index 00000000..aa658b7b --- /dev/null +++ b/web/src/components/collections/CollectionFormModal.test.tsx @@ -0,0 +1,47 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { collectionsApi } from "@/api/collections"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { CollectionFormModal } from "./CollectionFormModal"; + +vi.mock("@/api/collections", () => ({ + collectionsApi: { + create: vi.fn().mockResolvedValue({ + id: "c1", + name: "Batman", + ordered: true, + seriesCount: 0, + createdAt: "2026-06-15T00:00:00Z", + updatedAt: "2026-06-15T00:00:00Z", + }), + update: vi.fn(), + }, +})); + +describe("CollectionFormModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a collection with the entered name and ordered flag", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onCreated = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("e.g. Batman"), "Batman"); + await user.click(screen.getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: /create/i })); + + await waitFor(() => + expect(collectionsApi.create).toHaveBeenCalledWith({ + name: "Batman", + ordered: true, + }), + ); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/collections/CollectionFormModal.tsx b/web/src/components/collections/CollectionFormModal.tsx new file mode 100644 index 00000000..a187e4b1 --- /dev/null +++ b/web/src/components/collections/CollectionFormModal.tsx @@ -0,0 +1,101 @@ +import { + Button, + Checkbox, + Group, + Modal, + Stack, + TextInput, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { Collection } from "@/api/collections"; +import { + useCreateCollection, + useUpdateCollection, +} from "@/hooks/useCollections"; + +interface CollectionFormModalProps { + opened: boolean; + onClose: () => void; + /** When provided, the modal edits this collection instead of creating one. */ + collection?: Collection; + /** Called with the created collection (create mode only). */ + onCreated?: (collection: Collection) => void; +} + +export function CollectionFormModal({ + opened, + onClose, + collection, + onCreated, +}: CollectionFormModalProps) { + const isEdit = Boolean(collection); + const [name, setName] = useState(""); + const [ordered, setOrdered] = useState(false); + + // Seed fields when (re)opening. + useEffect(() => { + if (opened) { + setName(collection?.name ?? ""); + setOrdered(collection?.ordered ?? false); + } + }, [opened, collection]); + + const createMutation = useCreateCollection(); + const updateMutation = useUpdateCollection(collection?.id ?? ""); + const pending = createMutation.isPending || updateMutation.isPending; + + const submit = () => { + const trimmed = name.trim(); + if (!trimmed) return; + if (isEdit) { + updateMutation.mutate( + { name: trimmed, ordered }, + { onSuccess: () => onClose() }, + ); + } else { + createMutation.mutate( + { name: trimmed, ordered }, + { + onSuccess: (created) => { + onCreated?.(created); + onClose(); + }, + }, + ); + } + }; + + return ( + + + setName(e.currentTarget.value)} + data-autofocus + required + /> + setOrdered(e.currentTarget.checked)} + /> + + + + + + + ); +} diff --git a/web/src/components/collections/CollectionMembershipSub.tsx b/web/src/components/collections/CollectionMembershipSub.tsx new file mode 100644 index 00000000..b79d4c39 --- /dev/null +++ b/web/src/components/collections/CollectionMembershipSub.tsx @@ -0,0 +1,97 @@ +import { Loader, Menu } from "@mantine/core"; +import { IconCheck, IconFolderPlus, IconPlus } from "@tabler/icons-react"; +import { + useAddSeriesToCollection, + useCollections, + useCollectionsForSeries, + useRemoveSeriesFromCollections, +} from "@/hooks/useCollections"; + +/** + * Nested submenu for toggling a series across collections, made for embedding + * inside another Menu (e.g. the media card dropdown). + * + * The inline "New collection…" action is delegated to the parent via + * `onRequestCreate`: the create modal must live outside this Menu, because + * clicking any item closes the surrounding menu, which would unmount a modal + * rendered within the dropdown before it could open. + * + * The membership query only runs once this component mounts, which Mantine + * defers until the parent dropdown is opened, so a grid of cards does not fire + * one request per card on mount. + * + * Render only for users with `collections:write`. + */ +export function CollectionMembershipSub({ + seriesId, + onRequestCreate, +}: { + seriesId: string; + onRequestCreate: () => void; +}) { + const { data: collections, isLoading } = useCollections(); + const { data: memberOf } = useCollectionsForSeries(seriesId); + const add = useAddSeriesToCollection(); + const remove = useRemoveSeriesFromCollections(); + + const memberIds = new Set((memberOf ?? []).map((c) => c.id)); + const busy = add.isPending || remove.isPending; + + const toggle = (collectionId: string) => { + if (memberIds.has(collectionId)) { + remove.mutate({ collectionId, seriesId }); + } else { + add.mutate({ collectionId, seriesIds: [seriesId] }); + } + }; + + return ( + + + }> + Add to collection + + + + {isLoading ? ( + + + + ) : !collections || collections.length === 0 ? ( + No collections yet + ) : ( + collections.map((c) => ( + + ) : ( + + ) + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + toggle(c.id); + }} + > + {c.name} + + )) + )} + + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + onRequestCreate(); + }} + > + New collection… + + + + ); +} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 802ce558..4309bab0 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertTriangle, + IconBookmark, IconBooks, IconBrush, IconChartBar, @@ -26,7 +27,9 @@ import { IconFileExport, IconFileTypePdf, IconHome, + IconLayoutGrid, IconLink, + IconList, IconLogout, IconPhoto, IconPlugConnected, @@ -396,6 +399,30 @@ export function Sidebar({ onNavigate }: SidebarProps = {}) { active={currentPath === "/"} onClick={onNavigate} /> + } + active={currentPath === "/want-to-read"} + onClick={onNavigate} + /> + } + active={currentPath.startsWith("/collections")} + onClick={onNavigate} + /> + } + active={currentPath.startsWith("/readlists")} + onClick={onNavigate} + /> {hasRecommendationPlugin && ( { const handleKeyDown = (e: KeyboardEvent) => { @@ -580,8 +598,10 @@ export function BulkSelectionToolbar() { bulkSetTrackedMutation.isPending; // Determine if the "More" menu should be shown based on permissions - const showBooksMoreMenu = isBooks && (canWriteBooks || canWriteTasks); - const showSeriesMoreMenu = !isBooks && (canWriteSeries || canWriteTasks); + const showBooksMoreMenu = + isBooks && (canWriteBooks || canWriteTasks || canManageReadLists); + const showSeriesMoreMenu = + !isBooks && (canWriteSeries || canWriteTasks || canManageCollections); // Get available plugin actions based on selection type const pluginActions = isBooks @@ -680,6 +700,41 @@ export function BulkSelectionToolbar() { bulkResetMetadataMutation.mutate(selectedIds); }; + // Add every selected series to a collection (existing or freshly created), + // then clear the selection like the other bulk actions. + const addSelectedToCollection = (collectionId: string) => { + addSeriesToCollection.mutate( + { collectionId, seriesIds: selectedIds }, + { + onSuccess: () => { + notifications.show({ + title: "Added to collection", + message: `Added ${count} series to the collection.`, + color: "green", + }); + clearSelection(); + }, + }, + ); + }; + + // Add every selected book to a read list (existing or freshly created). + const addSelectedToReadList = (readListId: string) => { + addBooksToReadList.mutate( + { readListId, bookIds: selectedIds }, + { + onSuccess: () => { + notifications.show({ + title: "Added to read list", + message: `Added ${count} books to the read list.`, + color: "green", + }); + clearSelection(); + }, + }, + ); + }; + const itemLabel = isBooks ? count === 1 ? "book" @@ -831,6 +886,18 @@ export function BulkSelectionToolbar() { )} + + {canManageReadLists && ( + <> + {(canWriteBooks || canWriteTasks) && } + Read Lists + setCreateReadListOpen(true)} + disabled={isAnyPending} + /> + + )} )} @@ -967,6 +1034,18 @@ export function BulkSelectionToolbar() { )} + + {canManageCollections && ( + <> + {(canWriteSeries || canWriteTasks) && } + Collections + setCreateCollectionOpen(true)} + disabled={isAnyPending} + /> + + )} )} @@ -1050,6 +1129,25 @@ export function BulkSelectionToolbar() { clearSelection(); }} /> + + {/* Inline-create modals for the bulk membership submenus. A freshly + created collection / read list immediately receives all selected + items. Rendered here, outside the "More" menu, so they survive the + menu closing on item click. */} + {!isBooks && canManageCollections && ( + setCreateCollectionOpen(false)} + onCreated={(c) => addSelectedToCollection(c.id)} + /> + )} + {isBooks && canManageReadLists && ( + setCreateReadListOpen(false)} + onCreated={(r) => addSelectedToReadList(r.id)} + /> + )} ); } diff --git a/web/src/components/library/MediaCard.test.tsx b/web/src/components/library/MediaCard.test.tsx index 25dbcb2d..452a2313 100644 --- a/web/src/components/library/MediaCard.test.tsx +++ b/web/src/components/library/MediaCard.test.tsx @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBook, createReadProgress, createSeries, } from "@/mocks/data/factories"; +import { useAuthStore } from "@/store/authStore"; import { renderWithProviders, screen, userEvent } from "@/test/utils"; +import type { User } from "@/types"; import { MediaCard } from "./MediaCard"; const mockNavigate = vi.fn(); @@ -36,6 +38,30 @@ vi.mock("@/api/series", () => ({ }, })); +vi.mock("@/api/collections", () => ({ + collectionsApi: { + list: vi.fn().mockResolvedValue([{ id: "col-1", name: "My Collection" }]), + forSeries: vi.fn().mockResolvedValue([]), + addSeries: vi.fn(), + removeSeries: vi.fn(), + }, +})); + +vi.mock("@/api/readlists", () => ({ + readListsApi: { + list: vi.fn().mockResolvedValue([{ id: "rl-1", name: "My Read List" }]), + forBook: vi.fn().mockResolvedValue([]), + addBooks: vi.fn(), + removeBook: vi.fn(), + }, +})); + +const adminUser = { + id: "u1", + role: "admin", + permissions: [], +} as unknown as User; + describe("MediaCard", () => { describe("book display", () => { it("should display cover image for non-deleted book", () => { @@ -523,4 +549,50 @@ describe("MediaCard", () => { expect(card).toHaveClass("media-card--disabled"); }); }); + + describe("collection / read list membership", () => { + afterEach(() => { + // Clear the admin user primed in these tests so other suites keep the + // unauthenticated default (which hides the management menu entries). + useAuthStore.setState({ user: null }); + }); + + it("offers 'Add to collection' on a series card for users with collections-write", async () => { + const user = userEvent.setup(); + useAuthStore.setState({ user: adminUser }); + const series = createSeries({ unreadCount: 2 }); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Card actions" })); + + expect(await screen.findByText("Add to collection")).toBeInTheDocument(); + }); + + it("offers 'Add to read list' on a book card for users with readlists-write", async () => { + const user = userEvent.setup(); + useAuthStore.setState({ user: adminUser }); + const book = createBook(); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Card actions" })); + + expect(await screen.findByText("Add to read list")).toBeInTheDocument(); + }); + + it("hides membership entries when the user lacks write permission", async () => { + const user = userEvent.setup(); + // No user primed -> usePermissions denies everything. + const series = createSeries({ unreadCount: 2 }); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Card actions" })); + + // The reading action still renders, but the membership submenu does not. + expect(await screen.findByText("Mark as Read")).toBeInTheDocument(); + expect(screen.queryByText("Add to collection")).not.toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/library/MediaCard.tsx b/web/src/components/library/MediaCard.tsx index cde42d96..0b563924 100644 --- a/web/src/components/library/MediaCard.tsx +++ b/web/src/components/library/MediaCard.tsx @@ -29,8 +29,14 @@ import { useNavigate } from "react-router-dom"; import { booksApi } from "@/api/books"; import { seriesApi } from "@/api/series"; import { trackingApi } from "@/api/tracking"; +import { CollectionFormModal } from "@/components/collections/CollectionFormModal"; +import { CollectionMembershipSub } from "@/components/collections/CollectionMembershipSub"; import { AppLink } from "@/components/common"; +import { ReadListFormModal } from "@/components/readlists/ReadListFormModal"; +import { ReadListMembershipSub } from "@/components/readlists/ReadListMembershipSub"; +import { useAddSeriesToCollection } from "@/hooks/useCollections"; import { usePermissions } from "@/hooks/usePermissions"; +import { useAddBooksToReadList } from "@/hooks/useReadLists"; import { useCoverUpdatesStore } from "@/store/coverUpdatesStore"; import type { Book, Series } from "@/types"; import { PERMISSIONS } from "@/types/permissions"; @@ -66,6 +72,18 @@ export const MediaCard = memo(function MediaCard({ const { hasPermission } = usePermissions(); const canWriteBooks = hasPermission(PERMISSIONS.BOOKS_WRITE); const canWriteSeries = hasPermission(PERMISSIONS.SERIES_WRITE); + const canManageCollections = hasPermission(PERMISSIONS.COLLECTIONS_WRITE); + const canManageReadLists = hasPermission(PERMISSIONS.READLISTS_WRITE); + + // Inline-create modals for the membership submenus. These must be rendered + // outside the card's Menu: clicking the "New…" item closes the menu, which + // would unmount a modal living inside the dropdown before it could open. + const [createCollectionOpen, setCreateCollectionOpen] = useState(false); + const [createReadListOpen, setCreateReadListOpen] = useState(false); + // Separate mutation instances drive "create then auto-add" from the modals; + // the submenus own their own add/remove for toggling existing memberships. + const addSeriesToCollection = useAddSeriesToCollection(); + const addBooksToReadList = useAddBooksToReadList(); // Get cover update timestamp for cache-busting (forces image reload when cover is regenerated) const coverTimestamp = useCoverUpdatesStore( @@ -110,6 +128,14 @@ export const MediaCard = memo(function MediaCard({ const book = type === "book" ? (data as Book) : null; const series = type === "series" ? (data as Series) : null; + // Whether the series dropdown's reading group renders any item. Used to + // decide if the following groups need a leading divider (an empty series + // shows neither "Mark as Read" nor "Mark as Unread"). + const seriesHasReadingActions = + !!series && + ((series.unreadCount ?? 0) > 0 || + (series.bookCount ?? 0) > (series.unreadCount ?? 0)); + // Track image loading state for skeleton placeholder const [imageLoaded, setImageLoaded] = useState(false); const [_imageError, setImageError] = useState(false); @@ -443,516 +469,573 @@ export const MediaCard = memo(function MediaCard({ .join(" "); return ( - - - {/* Cover Image - Fixed height section (Komga ratio: 150px width, 212.125px height = 1.414) */} -
- {book?.deleted ? ( -
- - - Deleted - -
- ) : ( - <> - {/* Skeleton placeholder shown while image is loading */} - {!imageLoaded && ( - + + + {/* Cover Image - Fixed height section (Komga ratio: 150px width, 212.125px height = 1.414) */} +
+ {book?.deleted ? ( +
+ + + Deleted + +
+ ) : ( + <> + {/* Skeleton placeholder shown while image is loading */} + {!imageLoaded && ( + + )} + {altText} - )} - {altText} - - )} - {/* Selection checkbox - top left */} - {onSelect && ( -
- { - // Prevent event from bubbling to card click handler - e.stopPropagation(); - if (canBeSelected && onSelect) { - // Get the native event to check for shift key - const nativeEvent = e.nativeEvent as unknown as MouseEvent; - onSelect(data.id, nativeEvent?.shiftKey ?? false, index); - } - }} - disabled={!canBeSelected} - color="orange" - size="md" - aria-label={`Select ${type === "book" ? book?.title : series?.title}`} - styles={{ - input: { - cursor: canBeSelected ? "pointer" : "not-allowed", - }, - }} - /> -
- )} - {/* Tracking indicator - bell glyph top-left, centered on the same + + )} + {/* Selection checkbox - top left */} + {onSelect && ( +
+ { + // Prevent event from bubbling to card click handler + e.stopPropagation(); + if (canBeSelected && onSelect) { + // Get the native event to check for shift key + const nativeEvent = + e.nativeEvent as unknown as MouseEvent; + onSelect(data.id, nativeEvent?.shiftKey ?? false, index); + } + }} + disabled={!canBeSelected} + color="orange" + size="md" + aria-label={`Select ${type === "book" ? book?.title : series?.title}`} + styles={{ + input: { + cursor: canBeSelected ? "pointer" : "not-allowed", + }, + }} + /> +
+ )} + {/* Tracking indicator - bell glyph top-left, centered on the same slot the selection checkbox occupies so the corner stays stable when toggling in/out of selection mode. Hidden in selection mode so the checkbox takes over. Drop shadow keeps it legible on light covers. */} - {type === "series" && series?.tracked && !isSelectionMode && ( - + {type === "series" && series?.tracked && !isSelectionMode && ( + +
+ +
+
+ )} + {/* Unread indicator - Triangle for books, Square for series */} + {type === "book" && book && !book.readProgress && ( +
+ )} + {type === "series" && series && (series.unreadCount ?? 0) > 0 && (
- + + {(series.unreadCount ?? 0) > 99 ? "99+" : series.unreadCount} +
- - )} - {/* Unread indicator - Triangle for books, Square for series */} - {type === "book" && book && !book.readProgress && ( -
- )} - {type === "series" && series && (series.unreadCount ?? 0) > 0 && ( + )} + {/* Menu overlay */}
- - {(series.unreadCount ?? 0) > 99 ? "99+" : series.unreadCount} - -
- )} - {/* Menu overlay */} -
- - - e.stopPropagation()} - > - - - - - {type === "book" ? ( - <> - {/* Show Mark as Read if book is unread (no progress or not completed) */} - {(!book?.readProgress || !book.readProgress.completed) && ( - } - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - bookMarkAsReadMutation.mutate(); - }} - disabled={bookMarkAsReadMutation.isPending} - > - {bookMarkAsReadMutation.isPending - ? "Marking..." - : "Mark as Read"} - - )} - {/* Show Mark as Unread if book has progress */} - {book?.readProgress && ( - } - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - bookMarkAsUnreadMutation.mutate(); - }} - disabled={bookMarkAsUnreadMutation.isPending} - > - {bookMarkAsUnreadMutation.isPending - ? "Marking..." - : "Mark as Unread"} - - )} - {canWriteBooks && ( - } - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - bookAnalyzeMutation.mutate(); - }} - disabled={bookAnalyzeMutation.isPending} - > - {bookAnalyzeMutation.isPending - ? "Analyzing..." - : "Force Analyze"} - - )} - - ) : ( - <> - {/* Show Mark as Read if series has any unread books */} - {series && (series.unreadCount ?? 0) > 0 && ( - } - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - seriesMarkAsReadMutation.mutate(); - }} - disabled={seriesMarkAsReadMutation.isPending} - > - {seriesMarkAsReadMutation.isPending - ? "Marking..." - : "Mark as Read"} - - )} - {/* Show Mark as Unread if series has any read books */} - {series && - (series.bookCount ?? 0) > (series.unreadCount ?? 0) && ( + + + e.stopPropagation()} + > + + + + + {type === "book" ? ( + <> + {/* Show Mark as Read if book is unread (no progress or not completed) */} + {(!book?.readProgress || + !book.readProgress.completed) && ( } + leftSection={} onClick={(e: React.MouseEvent) => { e.stopPropagation(); - seriesMarkAsUnreadMutation.mutate(); + bookMarkAsReadMutation.mutate(); }} - disabled={seriesMarkAsUnreadMutation.isPending} + disabled={bookMarkAsReadMutation.isPending} > - {seriesMarkAsUnreadMutation.isPending + {bookMarkAsReadMutation.isPending ? "Marking..." - : "Mark as Unread"} + : "Mark as Read"} )} - {canWriteSeries && series && ( - - ) : ( - - ) - } - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - seriesTrackToggleMutation.mutate(!series.tracked); - }} - disabled={seriesTrackToggleMutation.isPending} - > - {seriesTrackToggleMutation.isPending - ? "Updating..." - : series.tracked - ? "Stop Tracking" - : "Start Tracking"} - - )} - {canWriteSeries && ( - <> + {/* Show Mark as Unread if book has progress */} + {book?.readProgress && ( } + leftSection={} onClick={(e: React.MouseEvent) => { e.stopPropagation(); - seriesAnalyzeMutation.mutate(); + bookMarkAsUnreadMutation.mutate(); }} - disabled={seriesAnalyzeMutation.isPending} + disabled={bookMarkAsUnreadMutation.isPending} > - {seriesAnalyzeMutation.isPending - ? "Analyzing..." - : "Force Analyze All"} + {bookMarkAsUnreadMutation.isPending + ? "Marking..." + : "Mark as Unread"} + )} + {canWriteBooks && ( + <> + + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + bookAnalyzeMutation.mutate(); + }} + disabled={bookAnalyzeMutation.isPending} + > + {bookAnalyzeMutation.isPending + ? "Analyzing..." + : "Force Analyze"} + + + )} + {canManageReadLists && book && ( + <> + + setCreateReadListOpen(true)} + /> + + )} + + ) : ( + <> + {/* Show Mark as Read if series has any unread books */} + {series && (series.unreadCount ?? 0) > 0 && ( } + leftSection={} onClick={(e: React.MouseEvent) => { e.stopPropagation(); - seriesAnalyzeUnanalyzedMutation.mutate(); + seriesMarkAsReadMutation.mutate(); }} - disabled={seriesAnalyzeUnanalyzedMutation.isPending} + disabled={seriesMarkAsReadMutation.isPending} > - {seriesAnalyzeUnanalyzedMutation.isPending - ? "Analyzing..." - : "Analyze Unanalyzed"} + {seriesMarkAsReadMutation.isPending + ? "Marking..." + : "Mark as Read"} - - )} - - )} - - -
- {/* Read button overlay - shows on hover for books only */} - {type === "book" && !book?.deleted && ( -
- - - + )} + {/* Show Mark as Unread if series has any read books */} + {series && + (series.bookCount ?? 0) > (series.unreadCount ?? 0) && ( + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + seriesMarkAsUnreadMutation.mutate(); + }} + disabled={seriesMarkAsUnreadMutation.isPending} + > + {seriesMarkAsUnreadMutation.isPending + ? "Marking..." + : "Mark as Unread"} + + )} + {canWriteSeries && series && ( + <> + {seriesHasReadingActions && } + + ) : ( + + ) + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + seriesTrackToggleMutation.mutate(!series.tracked); + }} + disabled={seriesTrackToggleMutation.isPending} + > + {seriesTrackToggleMutation.isPending + ? "Updating..." + : series.tracked + ? "Stop Tracking" + : "Start Tracking"} + + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + seriesAnalyzeMutation.mutate(); + }} + disabled={seriesAnalyzeMutation.isPending} + > + {seriesAnalyzeMutation.isPending + ? "Analyzing..." + : "Force Analyze All"} + + } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + seriesAnalyzeUnanalyzedMutation.mutate(); + }} + disabled={seriesAnalyzeUnanalyzedMutation.isPending} + > + {seriesAnalyzeUnanalyzedMutation.isPending + ? "Analyzing..." + : "Analyze Unanalyzed"} + + + )} + {canManageCollections && series && ( + <> + {(seriesHasReadingActions || canWriteSeries) && ( + + )} + + setCreateCollectionOpen(true) + } + /> + + )} + + )} + +
- )} - {/* Progress bar - shows at bottom of cover for books with progress */} - {type === "book" && - book?.readProgress && - !book.readProgress.completed && - progressPercentage > 0 && ( - - )} -
- {/* Card Content - Fixed height section (Komga: 94px = 5.875rem at 16px base) */} - - {!hideSeriesName && - type === "book" && - book?.seriesName && - book.seriesName.trim() !== "" && - book.seriesName.trim() !== "-" && ( - - - - {book.seriesName} - - - + + +
)} - -
- - 0 && ( + + )} +
+ {/* Card Content - Fixed height section (Komga: 94px = 5.875rem at 16px base) */} + + {!hideSeriesName && + type === "book" && + book?.seriesName && + book.seriesName.trim() !== "" && + book.seriesName.trim() !== "-" && ( + - {title} - - -
- - - {book && ( - <> - {book.pageCount && ( - - {book.pageCount} pages - - )} - - {book.fileFormat.toUpperCase()} - - - )} - {series && ( - <> - {series.bookCount !== undefined && ( - - {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} + + + {book.seriesName} + + + + )} + +
+ + + {title} - )} - {series.year && ( + +
+
+ + {book && ( + <> + {book.pageCount && ( + + {book.pageCount} pages + + )} - {series.year} + {book.fileFormat.toUpperCase()} - )} - - )} - + + )} + {series && ( + <> + {series.bookCount !== undefined && ( + + {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} + + )} + {series.year && ( + + {series.year} + + )} + + )} +
+
- -
+ + {/* Inline-create modals for the membership submenus. Rendered as siblings + of the Card (not children) so their portaled clicks don't bubble + through React's tree to the card's navigation handler, and so they + stay mounted when the dropdown closes on item click. */} + {type === "series" && series && ( + setCreateCollectionOpen(false)} + onCreated={(c) => + addSeriesToCollection.mutate({ + collectionId: c.id, + seriesIds: [series.id], + }) + } + /> + )} + {type === "book" && book && ( + setCreateReadListOpen(false)} + onCreated={(r) => + addBooksToReadList.mutate({ + readListId: r.id, + bookIds: [book.id], + }) + } + /> + )} + ); }); diff --git a/web/src/components/readlists/AddToReadListButton.tsx b/web/src/components/readlists/AddToReadListButton.tsx new file mode 100644 index 00000000..ad62e1fb --- /dev/null +++ b/web/src/components/readlists/AddToReadListButton.tsx @@ -0,0 +1,92 @@ +import { ActionIcon, Loader, Menu, Tooltip } from "@mantine/core"; +import { IconCheck, IconListNumbers, IconPlus } from "@tabler/icons-react"; +import { useState } from "react"; +import { + useAddBooksToReadList, + useReadLists, + useReadListsForBook, + useRemoveBookFromReadLists, +} from "@/hooks/useReadLists"; +import { ReadListFormModal } from "./ReadListFormModal"; + +/** + * Toggle membership of a book across read lists, and create new ones inline. + * Render only for users with `readlists:write`. + */ +export function AddToReadListButton({ bookId }: { bookId: string }) { + const { data: readLists, isLoading } = useReadLists(); + const { data: memberOf } = useReadListsForBook(bookId); + const add = useAddBooksToReadList(); + const remove = useRemoveBookFromReadLists(); + const [createOpen, setCreateOpen] = useState(false); + + const memberIds = new Set((memberOf ?? []).map((r) => r.id)); + const busy = add.isPending || remove.isPending; + + const toggle = (readListId: string) => { + if (memberIds.has(readListId)) { + remove.mutate({ readListId, bookId }); + } else { + add.mutate({ readListId, bookIds: [bookId] }); + } + }; + + return ( + <> + + + + + + + + + + Read lists + {isLoading ? ( + + + + ) : !readLists || readLists.length === 0 ? ( + No read lists yet + ) : ( + readLists.map((r) => ( + + ) : ( + + ) + } + onClick={() => toggle(r.id)} + > + {r.name} + + )) + )} + + } + onClick={() => setCreateOpen(true)} + > + New read list… + + + + + setCreateOpen(false)} + onCreated={(r) => add.mutate({ readListId: r.id, bookIds: [bookId] })} + /> + + ); +} diff --git a/web/src/components/readlists/BulkAddToReadListSub.tsx b/web/src/components/readlists/BulkAddToReadListSub.tsx new file mode 100644 index 00000000..b5724159 --- /dev/null +++ b/web/src/components/readlists/BulkAddToReadListSub.tsx @@ -0,0 +1,63 @@ +import { Loader, Menu } from "@mantine/core"; +import { IconListNumbers, IconPlus } from "@tabler/icons-react"; +import { useReadLists } from "@/hooks/useReadLists"; + +/** + * Nested submenu listing every read list, for embedding in the bulk-selection + * "More" menu. Unlike the single-book version this does not show membership + * checkmarks: across many books a single checked state is meaningless, so + * picking a read list simply adds all selected books to it. + * + * `onRequestCreate` is delegated to the parent so the create modal can be + * mounted outside this Menu (an item click closes the menu, which would + * otherwise unmount a modal rendered within it). + * + * Render only for users with `readlists:write`. + */ +export function BulkAddToReadListSub({ + onSelect, + onRequestCreate, + disabled, +}: { + onSelect: (readListId: string) => void; + onRequestCreate: () => void; + disabled?: boolean; +}) { + const { data: readLists, isLoading } = useReadLists(); + + return ( + + + }> + Add to read list + + + + {isLoading ? ( + + + + ) : !readLists || readLists.length === 0 ? ( + No read lists yet + ) : ( + readLists.map((r) => ( + onSelect(r.id)} + > + {r.name} + + )) + )} + + } + onClick={onRequestCreate} + > + New read list… + + + + ); +} diff --git a/web/src/components/readlists/ReadListFormModal.test.tsx b/web/src/components/readlists/ReadListFormModal.test.tsx new file mode 100644 index 00000000..73dbfcc0 --- /dev/null +++ b/web/src/components/readlists/ReadListFormModal.test.tsx @@ -0,0 +1,50 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readListsApi } from "@/api/readlists"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { ReadListFormModal } from "./ReadListFormModal"; + +vi.mock("@/api/readlists", () => ({ + readListsApi: { + create: vi.fn().mockResolvedValue({ + id: "r1", + name: "Civil War", + summary: "Crossover", + ordered: true, + bookCount: 0, + createdAt: "2026-06-15T00:00:00Z", + updatedAt: "2026-06-15T00:00:00Z", + }), + update: vi.fn(), + }, +})); + +describe("ReadListFormModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a read list with name, summary, and ordered flag", async () => { + const user = userEvent.setup(); + const onCreated = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("e.g. Civil War"), "Civil War"); + await user.type( + screen.getByPlaceholderText("Optional description"), + "Crossover", + ); + await user.click(screen.getByRole("button", { name: /create/i })); + + await waitFor(() => + expect(readListsApi.create).toHaveBeenCalledWith({ + name: "Civil War", + summary: "Crossover", + ordered: true, + }), + ); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + }); +}); diff --git a/web/src/components/readlists/ReadListFormModal.tsx b/web/src/components/readlists/ReadListFormModal.tsx new file mode 100644 index 00000000..6242429d --- /dev/null +++ b/web/src/components/readlists/ReadListFormModal.tsx @@ -0,0 +1,109 @@ +import { + Button, + Checkbox, + Group, + Modal, + Stack, + Textarea, + TextInput, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { ReadList } from "@/api/readlists"; +import { useCreateReadList, useUpdateReadList } from "@/hooks/useReadLists"; + +interface ReadListFormModalProps { + opened: boolean; + onClose: () => void; + /** When provided, the modal edits this read list instead of creating one. */ + readList?: ReadList; + /** Called with the created read list (create mode only). */ + onCreated?: (readList: ReadList) => void; +} + +export function ReadListFormModal({ + opened, + onClose, + readList, + onCreated, +}: ReadListFormModalProps) { + const isEdit = Boolean(readList); + const [name, setName] = useState(""); + const [summary, setSummary] = useState(""); + const [ordered, setOrdered] = useState(true); + + useEffect(() => { + if (opened) { + setName(readList?.name ?? ""); + setSummary(readList?.summary ?? ""); + setOrdered(readList?.ordered ?? true); + } + }, [opened, readList]); + + const createMutation = useCreateReadList(); + const updateMutation = useUpdateReadList(readList?.id ?? ""); + const pending = createMutation.isPending || updateMutation.isPending; + + const submit = () => { + const trimmedName = name.trim(); + if (!trimmedName) return; + const trimmedSummary = summary.trim(); + if (isEdit) { + updateMutation.mutate( + { name: trimmedName, summary: trimmedSummary || null, ordered }, + { onSuccess: () => onClose() }, + ); + } else { + createMutation.mutate( + { name: trimmedName, summary: trimmedSummary || undefined, ordered }, + { + onSuccess: (created) => { + onCreated?.(created); + onClose(); + }, + }, + ); + } + }; + + return ( + + + setName(e.currentTarget.value)} + data-autofocus + required + /> +