Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion crates/codex-api/src/routes/komga/handlers/books.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthState>, series_id: Uuid) -> Result<String, ApiError> {
pub(crate) async fn get_series_title(
state: &Arc<AuthState>,
series_id: Uuid,
) -> Result<String, ApiError> {
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)))?
Expand Down
226 changes: 226 additions & 0 deletions crates/codex-api/src/routes/komga/handlers/collections.rs
Original file line number Diff line number Diff line change
@@ -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, ApiError> {
Uuid::parse_str(value).map_err(|_| ApiError::NotFound("Collection not found".to_string()))
}

async fn user_visibility(
state: &AuthState,
user_id: Uuid,
) -> Result<Option<SeriesVisibility>, 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<KomgaCollectionDto, ApiError> {
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<KomgaCollectionDto>), (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<Arc<AuthState>>,
FlexibleAuthContext(auth): FlexibleAuthContext,
Query(query): Query<StubPaginationQuery>,
) -> Result<Json<KomgaPage<KomgaCollectionDto>>, 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<Arc<AuthState>>,
FlexibleAuthContext(auth): FlexibleAuthContext,
Path(collection_id): Path<String>,
) -> Result<Json<KomgaCollectionDto>, 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<KomgaSeriesDto>), (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<Arc<AuthState>>,
FlexibleAuthContext(auth): FlexibleAuthContext,
Path(collection_id): Path<String>,
Query(query): Query<StubPaginationQuery>,
) -> Result<Json<KomgaPage<KomgaSeriesDto>>, 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<Arc<AuthState>>,
FlexibleAuthContext(auth): FlexibleAuthContext,
Path(collection_id): Path<String>,
) -> Result<Redirect, ApiError> {
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<KomgaCollectionDto>)),
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<Arc<AuthState>>,
FlexibleAuthContext(auth): FlexibleAuthContext,
Path(series_id): Path<String>,
) -> Result<Json<Vec<KomgaCollectionDto>>, 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))
}
Loading
Loading