feat(api): enrichir GET /books et series avec filtres et pagination

- fix(auth): parse_prefix supporte les préfixes de token contenant '_'
- feat: GET /books expose reading_status, reading_current_page, reading_last_read_at
- feat: GET /books accepte ?reading_status=unread,reading (CSV multi-valeur)
- feat: SeriesItem expose books_read_count pour dériver le statut de lecture
- feat: GET /libraries/:id/series accepte ?reading_status=unread,reading
- feat: BooksPage et SeriesPage exposent total (count matchant les filtres)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 09:25:31 +01:00
parent 648d86970f
commit a2da5081ea
2 changed files with 185 additions and 59 deletions

View File

@@ -94,11 +94,15 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
} }
fn parse_prefix(token: &str) -> Option<&str> { fn parse_prefix(token: &str) -> Option<&str> {
let mut parts = token.split('_'); // Format: stl_{8-char prefix}_{secret}
let namespace = parts.next()?; // Base64 URL_SAFE peut contenir '_', donc on ne peut pas splitter aveuglément
let prefix = parts.next()?; let rest = token.strip_prefix("stl_")?;
let secret = parts.next()?; if rest.len() < 10 {
if namespace != "stl" || secret.is_empty() || prefix.len() < 6 { // 8 (prefix) + 1 ('_') + 1 (secret min)
return None;
}
let prefix = &rest[..8];
if rest.as_bytes().get(8) != Some(&b'_') {
return None; return None;
} }
Some(prefix) Some(prefix)

View File

@@ -15,6 +15,8 @@ pub struct ListBooksQuery {
pub kind: Option<String>, pub kind: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub series: Option<String>, pub series: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub cursor: Option<Uuid>, pub cursor: Option<Uuid>,
#[schema(value_type = Option<i64>, example = 50)] #[schema(value_type = Option<i64>, example = 50)]
@@ -37,6 +39,11 @@ pub struct BookItem {
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
#[schema(value_type = String)] #[schema(value_type = String)]
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
/// Reading status: "unread", "reading", or "read"
pub reading_status: String,
pub reading_current_page: Option<i32>,
#[schema(value_type = Option<String>)]
pub reading_last_read_at: Option<DateTime<Utc>>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -44,6 +51,7 @@ pub struct BooksPage {
pub items: Vec<BookItem>, pub items: Vec<BookItem>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub next_cursor: Option<Uuid>, pub next_cursor: Option<Uuid>,
pub total: i64,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -79,6 +87,7 @@ pub struct BookDetails {
("library_id" = Option<String>, Query, description = "Filter by library ID"), ("library_id" = Option<String>, Query, description = "Filter by library ID"),
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"), ("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"), ("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("cursor" = Option<String>, Query, description = "Cursor for pagination"), ("cursor" = Option<String>, Query, description = "Cursor for pagination"),
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"), ("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
), ),
@@ -94,50 +103,93 @@ pub async fn list_books(
) -> Result<Json<BooksPage>, ApiError> { ) -> Result<Json<BooksPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200); let limit = query.limit.unwrap_or(50).clamp(1, 200);
// Build series filter condition // Parse reading_status CSV → Vec<String>
let series_condition = match query.series.as_deref() { let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
Some("unclassified") => "AND (series IS NULL OR series = '')", s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
Some(_series_name) => "AND series = $5", });
None => "",
};
let sql = format!( // COUNT query: $1=library_id $2=kind, then optional series/reading_status
r#" let mut cp: usize = 2;
SELECT id, library_id, kind, title, author, series, volume, language, page_count, thumbnail_path, updated_at let count_series_cond = match query.series.as_deref() {
FROM books Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(),
WHERE ($1::uuid IS NULL OR library_id = $1) Some(_) => { cp += 1; format!("AND b.series = ${cp}") }
AND ($2::text IS NULL OR kind = $2) None => String::new(),
AND ($3::uuid IS NULL OR id > $3) };
{} let count_rs_cond = if reading_statuses.is_some() {
ORDER BY cp += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${cp})")
-- Extract text part before numbers (case insensitive) } else { String::new() };
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer for numeric sort let count_sql = format!(
COALESCE( r#"SELECT COUNT(*) FROM books b
(REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
0 WHERE ($1::uuid IS NULL OR b.library_id = $1)
), AND ($2::text IS NULL OR b.kind = $2)
-- Then by full title as fallback {count_series_cond}
title ASC {count_rs_cond}"#
LIMIT $4
"#,
series_condition
); );
let mut query_builder = sqlx::query(&sql) let mut count_builder = sqlx::query(&count_sql)
.bind(query.library_id)
.bind(query.kind.as_deref());
if let Some(s) = query.series.as_deref() {
if s != "unclassified" { count_builder = count_builder.bind(s); }
}
if let Some(ref statuses) = reading_statuses {
count_builder = count_builder.bind(statuses.clone());
}
// DATA query: $1=library_id $2=kind $3=cursor $4=limit, then optional series/reading_status
let mut dp: usize = 4;
let series_condition = match query.series.as_deref() {
Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(),
Some(_) => { dp += 1; format!("AND b.series = ${dp}") }
None => String::new(),
};
let reading_status_condition = if reading_statuses.is_some() {
dp += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${dp})")
} else { String::new() };
let data_sql = format!(
r#"
SELECT b.id, b.library_id, b.kind, 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
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::uuid IS NULL OR b.id > $3)
{series_condition}
{reading_status_condition}
ORDER BY
REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'),
COALESCE(
(REGEXP_MATCH(LOWER(b.title), '\d+'))[1]::int,
0
),
b.title ASC
LIMIT $4
"#
);
let mut data_builder = sqlx::query(&data_sql)
.bind(query.library_id) .bind(query.library_id)
.bind(query.kind.as_deref()) .bind(query.kind.as_deref())
.bind(query.cursor) .bind(query.cursor)
.bind(limit + 1); .bind(limit + 1);
if let Some(s) = query.series.as_deref() {
// Bind series parameter if it's not unclassified if s != "unclassified" { data_builder = data_builder.bind(s); }
if let Some(series) = query.series.as_deref() { }
if series != "unclassified" { if let Some(ref statuses) = reading_statuses {
query_builder = query_builder.bind(series); data_builder = data_builder.bind(statuses.clone());
}
} }
let rows = query_builder.fetch_all(&state.pool).await?; let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool),
)?;
let total: i64 = count_row.get(0);
let mut items: Vec<BookItem> = rows let mut items: Vec<BookItem> = rows
.iter() .iter()
@@ -156,6 +208,9 @@ pub async fn list_books(
page_count: row.get("page_count"), page_count: row.get("page_count"),
thumbnail_url: thumbnail_path.map(|_p| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))), thumbnail_url: thumbnail_path.map(|_p| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))),
updated_at: row.get("updated_at"), 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(); .collect();
@@ -169,6 +224,7 @@ pub async fn list_books(
Ok(Json(BooksPage { Ok(Json(BooksPage {
items: std::mem::take(&mut items), items: std::mem::take(&mut items),
next_cursor, next_cursor,
total,
})) }))
} }
@@ -240,6 +296,7 @@ pub async fn get_book(
pub struct SeriesItem { pub struct SeriesItem {
pub name: String, pub name: String,
pub book_count: i64, pub book_count: i64,
pub books_read_count: i64,
#[schema(value_type = String)] #[schema(value_type = String)]
pub first_book_id: Uuid, pub first_book_id: Uuid,
} }
@@ -249,10 +306,13 @@ pub struct SeriesPage {
pub items: Vec<SeriesItem>, pub items: Vec<SeriesItem>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub next_cursor: Option<String>, pub next_cursor: Option<String>,
pub total: i64,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct ListSeriesQuery { pub struct ListSeriesQuery {
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub cursor: Option<String>, pub cursor: Option<String>,
#[schema(value_type = Option<i64>, example = 50)] #[schema(value_type = Option<i64>, example = 50)]
@@ -266,6 +326,7 @@ pub struct ListSeriesQuery {
tag = "books", tag = "books",
params( params(
("library_id" = String, Path, description = "Library UUID"), ("library_id" = String, Path, description = "Library UUID"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("cursor" = Option<String>, Query, description = "Cursor for pagination (series name)"), ("cursor" = Option<String>, Query, description = "Cursor for pagination (series name)"),
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"), ("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
), ),
@@ -282,13 +343,61 @@ pub async fn list_series(
) -> Result<Json<SeriesPage>, ApiError> { ) -> Result<Json<SeriesPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200); let limit = query.limit.unwrap_or(50).clamp(1, 200);
let rows = sqlx::query( let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
});
// Le CTE series_counts est partagé — on dérive le statut de lecture de la série
// à partir de books_read_count / book_count.
let series_status_expr = r#"CASE
WHEN sc.books_read_count = sc.book_count THEN 'read'
WHEN sc.books_read_count = 0 THEN 'unread'
ELSE 'reading'
END"#;
// COUNT query: $1=library_id, then optional reading_status ($2)
let count_rs_cond = if reading_statuses.is_some() {
format!("AND {series_status_expr} = ANY($2)")
} else {
String::new()
};
let count_sql = format!(
r#"
WITH sorted_books AS (
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
FROM books WHERE library_id = $1
),
series_counts AS (
SELECT sb.name,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {count_rs_cond}
"#
);
let mut count_builder = sqlx::query(&count_sql).bind(library_id);
if let Some(ref statuses) = reading_statuses {
count_builder = count_builder.bind(statuses.clone());
}
// DATA query: $1=library_id $2=cursor $3=limit, then optional reading_status ($4)
let data_rs_cond = if reading_statuses.is_some() {
format!("AND {series_status_expr} = ANY($4)")
} else {
String::new()
};
let data_sql = format!(
r#" r#"
WITH sorted_books AS ( WITH sorted_books AS (
SELECT SELECT
COALESCE(NULLIF(series, ''), 'unclassified') as name, COALESCE(NULLIF(series, ''), 'unclassified') as name,
id, id,
-- Natural sort order for books within series
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY ORDER BY
@@ -301,35 +410,46 @@ pub async fn list_series(
), ),
series_counts AS ( series_counts AS (
SELECT SELECT
name, sb.name,
COUNT(*) as book_count COUNT(*) as book_count,
FROM sorted_books COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
GROUP BY name FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
) )
SELECT SELECT
sc.name, sc.name,
sc.book_count, sc.book_count,
sc.books_read_count,
sb.id as first_book_id sb.id as first_book_id
FROM series_counts sc FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1 JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
WHERE ($2::text IS NULL OR sc.name > $2) WHERE ($2::text IS NULL OR sc.name > $2)
{data_rs_cond}
ORDER BY ORDER BY
-- Natural sort: extract text part before numbers
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'), REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer
COALESCE( COALESCE(
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int, (REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
0 0
), ),
sc.name ASC sc.name ASC
LIMIT $3 LIMIT $3
"#, "#
) );
.bind(library_id)
.bind(query.cursor.as_deref()) let mut data_builder = sqlx::query(&data_sql)
.bind(limit + 1) .bind(library_id)
.fetch_all(&state.pool) .bind(query.cursor.as_deref())
.await?; .bind(limit + 1);
if let Some(ref statuses) = reading_statuses {
data_builder = data_builder.bind(statuses.clone());
}
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool),
)?;
let total: i64 = count_row.get(0);
let mut items: Vec<SeriesItem> = rows let mut items: Vec<SeriesItem> = rows
.iter() .iter()
@@ -337,6 +457,7 @@ pub async fn list_series(
.map(|row| SeriesItem { .map(|row| SeriesItem {
name: row.get("name"), name: row.get("name"),
book_count: row.get("book_count"), book_count: row.get("book_count"),
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"), first_book_id: row.get("first_book_id"),
}) })
.collect(); .collect();
@@ -349,6 +470,7 @@ pub async fn list_series(
Ok(Json(SeriesPage { Ok(Json(SeriesPage {
items: std::mem::take(&mut items), items: std::mem::take(&mut items),
total,
next_cursor, next_cursor,
})) }))
} }