From fd277602c99de6e9e3c02ca993e02d98aa3a329b Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 15 Mar 2026 16:24:05 +0100 Subject: [PATCH] feat(api): add GET /series/ongoing and GET /books/ongoing endpoints Two new read routes for the home screen: - /series/ongoing: partially read series sorted by last activity - /books/ongoing: next unread book per ongoing series Co-Authored-By: Claude Opus 4.6 --- apps/api/src/books.rs | 169 ++++++++++++++++++++++++++++++++++++++++ apps/api/src/main.rs | 2 + apps/api/src/openapi.rs | 3 + 3 files changed, 174 insertions(+) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 1ade223..f0b6e04 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -471,6 +471,175 @@ pub async fn list_series( })) } +#[derive(Deserialize, ToSchema)] +pub struct OngoingQuery { + #[schema(value_type = Option, example = 10)] + pub limit: Option, +} + +/// List ongoing series (partially read, sorted by most recent activity) +#[utoipa::path( + get, + path = "/series/ongoing", + tag = "books", + params( + ("limit" = Option, Query, description = "Max items to return (default 10, max 50)"), + ), + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn ongoing_series( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let limit = query.limit.unwrap_or(10).clamp(1, 50); + + let rows = sqlx::query( + r#" + WITH series_stats AS ( + SELECT + COALESCE(NULLIF(b.series, ''), 'unclassified') AS name, + COUNT(*) AS book_count, + COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count, + MAX(brp.last_read_at) AS last_read_at + FROM books b + LEFT JOIN book_reading_progress brp ON brp.book_id = b.id + GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified') + HAVING ( + COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0 + AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*) + ) + ), + first_books AS ( + SELECT + COALESCE(NULLIF(series, ''), 'unclassified') AS name, + id, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') + ORDER BY + REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'), + COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0), + title ASC + ) AS rn + FROM books + ) + SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id + FROM series_stats ss + JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1 + ORDER BY ss.last_read_at DESC NULLS LAST + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(&state.pool) + .await?; + + let items: Vec = rows + .iter() + .map(|row| SeriesItem { + name: row.get("name"), + book_count: row.get("book_count"), + books_read_count: row.get("books_read_count"), + first_book_id: row.get("first_book_id"), + }) + .collect(); + + Ok(Json(items)) +} + +/// List next unread book for each ongoing series (sorted by most recent activity) +#[utoipa::path( + get, + path = "/books/ongoing", + tag = "books", + params( + ("limit" = Option, Query, description = "Max items to return (default 10, max 50)"), + ), + responses( + (status = 200, body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn ongoing_books( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let limit = query.limit.unwrap_or(10).clamp(1, 50); + + let rows = sqlx::query( + r#" + WITH ongoing_series AS ( + SELECT + COALESCE(NULLIF(b.series, ''), 'unclassified') AS name, + MAX(brp.last_read_at) AS series_last_read_at + FROM books b + LEFT JOIN book_reading_progress brp ON brp.book_id = b.id + GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified') + HAVING ( + COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0 + AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*) + ) + ), + next_books AS ( + SELECT + b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume, + b.language, b.page_count, b.thumbnail_path, b.updated_at, + COALESCE(brp.status, 'unread') AS reading_status, + brp.current_page AS reading_current_page, + brp.last_read_at AS reading_last_read_at, + os.series_last_read_at, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(NULLIF(b.series, ''), 'unclassified') + ORDER BY b.volume NULLS LAST, b.title + ) AS rn + FROM books b + JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name + LEFT JOIN book_reading_progress brp ON brp.book_id = b.id + WHERE COALESCE(brp.status, 'unread') != 'read' + ) + SELECT id, library_id, kind, format, title, author, series, volume, language, page_count, + thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at + FROM next_books + WHERE rn = 1 + ORDER BY series_last_read_at DESC NULLS LAST + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(&state.pool) + .await?; + + let items: Vec = rows + .iter() + .map(|row| { + let thumbnail_path: Option = row.get("thumbnail_path"); + BookItem { + id: row.get("id"), + library_id: row.get("library_id"), + kind: row.get("kind"), + format: row.get("format"), + title: row.get("title"), + author: row.get("author"), + series: row.get("series"), + volume: row.get("volume"), + language: row.get("language"), + page_count: row.get("page_count"), + thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", row.get::("id"))), + updated_at: row.get("updated_at"), + reading_status: row.get("reading_status"), + reading_current_page: row.get("reading_current_page"), + reading_last_read_at: row.get("reading_last_read_at"), + } + }) + .collect(); + + Ok(Json(items)) +} + fn remap_libraries_path(path: &str) -> String { if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { if path.starts_with("/libraries/") { diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 1baa62b..e510d4e 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -105,11 +105,13 @@ async fn main() -> anyhow::Result<()> { let read_routes = Router::new() .route("/books", get(books::list_books)) + .route("/books/ongoing", get(books::ongoing_books)) .route("/books/:id", get(books::get_book)) .route("/books/:id/thumbnail", get(books::get_thumbnail)) .route("/books/:id/pages/:n", get(pages::get_page)) .route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress)) .route("/libraries/:library_id/series", get(books::list_series)) + .route("/series/ongoing", get(books::ongoing_series)) .route("/search", get(search::search_books)) .route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit)) .route_layer(middleware::from_fn_with_state( diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 6126e06..32ab6ae 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -10,6 +10,8 @@ use utoipa::OpenApi; crate::reading_progress::update_reading_progress, crate::books::get_thumbnail, crate::books::list_series, + crate::books::ongoing_series, + crate::books::ongoing_books, crate::books::convert_book, crate::pages::get_page, crate::search::search_books, @@ -49,6 +51,7 @@ use utoipa::OpenApi; crate::reading_progress::UpdateReadingProgressRequest, crate::books::SeriesItem, crate::books::SeriesPage, + crate::books::OngoingQuery, crate::pages::PageQuery, crate::search::SearchQuery, crate::search::SearchResponse,