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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ repos:
name: Check OpenAPI files are in sync
entry: ./scripts/check-openapi-sync.sh
language: script
files: ^(src/api/.*\.rs|src/db/entities/.*\.rs|web/openapi\.json|web/src/types/api\.generated\.ts)$
files: ^(crates/codex-(api|models|services|tasks|events|db)/.*\.rs|web/openapi\.json|web/src/types/api\.generated\.ts)$
pass_filenames: false

- id: plugin-lint-fix
Expand Down
4 changes: 4 additions & 0 deletions crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ The following paths are exempt from rate limiting:
v1::handlers::list_series,
v1::handlers::search_series,
v1::handlers::list_series_filtered,
v1::handlers::list_series_external_index,
v1::handlers::list_series_alphabetical_groups,
v1::handlers::get_series,
v1::handlers::patch_series,
Expand Down Expand Up @@ -666,6 +667,9 @@ The following paths are exempt from rate limiting:
codex_models::SmartBookConfig,
v1::dto::SeriesDto,
v1::dto::SeriesListResponse,
v1::dto::SeriesExternalIndexDto,
v1::dto::SeriesExternalIdRefDto,
v1::dto::SeriesExternalIndexListResponse,
v1::dto::SearchSeriesRequest,
v1::dto::SeriesListRequest,
v1::dto::SeriesCondition,
Expand Down
68 changes: 68 additions & 0 deletions crates/codex-api/src/routes/v1/dto/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,74 @@ impl From<codex_db::entities::series_external_ids::Model> for SeriesExternalIdDt
}
}

/// Slim external-ID reference used by the external-index endpoint.
///
/// Carries only the fields a discovery consumer needs to match a Codex
/// series against an upstream provider: the source, the provider's ID, and
/// an optional deep link. Deliberately omits the record `id`, sync hashes,
/// and timestamps that [`SeriesExternalIdDto`] exposes.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SeriesExternalIdRefDto {
/// Source identifier (e.g., "plugin:mangabaka", "comicinfo", "epub")
#[schema(example = "plugin:mangabaka")]
pub source: String,

/// External ID value from the source
#[schema(example = "12345")]
pub external_id: String,

/// URL to the external source page (if available)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = "https://mangabaka.dev/manga/12345")]
pub external_url: Option<String>,
}

impl From<codex_db::entities::series_external_ids::Model> for SeriesExternalIdRefDto {
fn from(model: codex_db::entities::series_external_ids::Model) -> Self {
Self {
source: model.source,
external_id: model.external_id,
external_url: model.external_url,
}
}
}

/// Slim per-series projection for external discovery tools.
///
/// Returned by `GET /api/v1/series/external-index`. It carries just the
/// series UUID, its linked external IDs, and the locally-owned
/// volume/chapter signals, deliberately omitting the heavy metadata,
/// genres, tags, covers, ratings, and links of [`FullSeriesResponse`].
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SeriesExternalIndexDto {
/// Series ID (build the Codex web deep link `/series/{id}` consumer-side)
#[schema(example = "550e8400-e29b-41d4-a716-446655440002")]
pub id: uuid::Uuid,

/// External IDs linked to this series (empty if none have been linked yet)
pub external_ids: Vec<SeriesExternalIdRefDto>,

/// Highest `book_metadata.volume` across non-deleted books, or null if
/// no book in the series has a parsed volume.
#[schema(example = 12)]
pub local_max_volume: Option<i32>,

/// Highest `book_metadata.chapter` across non-deleted books, or null if
/// no book in the series has a parsed chapter.
#[schema(example = 130.5)]
pub local_max_chapter: Option<f32>,

/// Count of complete-volume files (volume set, chapter null). A soft,
/// display-only signal; not authoritative for "how far along".
#[schema(example = 12)]
pub volumes_owned: Option<i64>,
}

/// Paginated list of slim per-series external-index entries.
pub type SeriesExternalIndexListResponse = PaginatedResponse<SeriesExternalIndexDto>;

/// Response containing a list of external IDs
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
Expand Down
112 changes: 112 additions & 0 deletions crates/codex-api/src/routes/v1/handlers/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use super::super::dto::{
GenreListResponse, MetadataLocks, PatchSeriesMetadataRequest, PatchSeriesRequest,
ReplaceSeriesMetadataRequest, SeriesAverageRatingResponse, SeriesCoverDto,
SeriesCoverListResponse, SeriesExternalIdDto, SeriesExternalIdListResponse,
SeriesExternalIdRefDto, SeriesExternalIndexDto, SeriesExternalIndexListResponse,
SeriesFullMetadata, SeriesMetadataResponse, SeriesSortParam, SeriesUpdateResponse,
SetSeriesGenresRequest, SetSeriesTagsRequest, SetUserRatingRequest, TagDto,
TagListResponse, TaxonomyCleanupResponse, UpdateAlternateTitleRequest,
Expand Down Expand Up @@ -850,6 +851,117 @@ pub async fn list_series(
}
}

/// Slim external-index projection of every visible series
///
/// Returns one entry per series the caller can access, carrying only the
/// series UUID, its linked external IDs, and the locally-owned
/// volume/chapter signals. A deliberately lightweight alternative to
/// `GET /api/v1/series?full=true` for external discovery tools that key on
/// external IDs and would otherwise over-fetch and discard the full DTO.
///
/// Ordering is fixed (`name asc, id asc`) so a consumer can paginate the
/// list to completion in one deterministic sweep.
#[utoipa::path(
get,
path = "/api/v1/series/external-index",
params(ListPaginationParams),
responses(
(status = 200, description = "Paginated slim per-series external-index entries", body = SeriesExternalIndexListResponse),
(status = 403, description = "Forbidden"),
),
security(
("jwt_bearer" = []),
("api_key" = [])
),
tag = "Series"
)]
pub async fn list_series_external_index(
State(state): State<Arc<AuthState>>,
auth: AuthContext,
Query(pagination): Query<ListPaginationParams>,
) -> Result<Response, ApiError> {
require_permission!(auth, Permission::SeriesRead)?;

let (page, page_size) = pagination.validated();
let offset = (page - 1) * page_size;

// Honor sharing-tag visibility: only export series this user can access.
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();

// Collect every visible series ID, then defer the deterministic name-asc
// page (and total) to the repo's SQL-level sort + pagination.
let visible_ids: Vec<Uuid> = SeriesRepository::list_all(&state.db, visibility.as_ref())
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?
.into_iter()
.map(|s| s.id)
.collect();

let (series_list, total) = SeriesRepository::list_by_ids_sorted(
&state.db,
&visible_ids,
&SeriesSortParam::default(),
Some(auth.user_id),
offset,
page_size,
visibility.as_ref(),
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch sorted series: {}", e)))?;

let page_ids: Vec<Uuid> = series_list.iter().map(|s| s.id).collect();

// Batch-load external IDs + classification aggregates for just this page.
let mut external_ids_by_series =
SeriesExternalIdRepository::get_for_series_ids(&state.db, &page_ids)
.await
.map_err(|e| ApiError::Internal(format!("Failed to load external IDs: {}", e)))?;
let aggregates =
SeriesRepository::get_book_classification_aggregates_for_series_ids(&state.db, &page_ids)
.await
.map_err(|e| {
ApiError::Internal(format!("Failed to load classification aggregates: {}", e))
})?;

let items: Vec<SeriesExternalIndexDto> = page_ids
.iter()
.map(|id| {
let agg = aggregates.get(id).copied().unwrap_or_default();
let external_ids = external_ids_by_series
.remove(id)
.unwrap_or_default()
.into_iter()
.map(SeriesExternalIdRefDto::from)
.collect();
SeriesExternalIndexDto {
id: *id,
external_ids,
local_max_volume: agg.local_max_volume,
local_max_chapter: agg.local_max_chapter,
volumes_owned: agg.volumes_owned,
}
})
.collect();

let total_pages = if page_size == 0 {
0
} else {
total.div_ceil(page_size)
};
let link_builder = PaginationLinkBuilder::new(
"/api/v1/series/external-index",
page,
page_size,
total_pages,
);
let response =
SeriesExternalIndexListResponse::with_builder(items, page, page_size, total, &link_builder);
Ok(paginated_response(response, &link_builder))
}

/// Query parameters for getting a single series
#[derive(Debug, Deserialize, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
Expand Down
4 changes: 4 additions & 0 deletions crates/codex-api/src/routes/v1/routes/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ pub fn routes(_state: Arc<AppState>) -> Router<Arc<AppState>> {
"/series/list/alphabetical-groups",
post(handlers::list_series_alphabetical_groups),
)
.route(
"/series/external-index",
get(handlers::list_series_external_index),
)
.route("/series/{series_id}", get(handlers::get_series))
.route("/series/{series_id}", patch(handlers::patch_series))
.route("/series/{series_id}/books", get(handlers::get_series_books))
Expand Down
Loading
Loading