feat(books): édition des métadonnées livres et séries + champ authors multi-valeurs

- Nouveaux endpoints PATCH /books/:id et PATCH /libraries/:id/series/:name pour éditer les métadonnées
- GET /libraries/:id/series/:name/metadata pour récupérer les métadonnées de série
- Ajout du champ `authors` (Vec<String>) sur les structs Book/BookDetails
- 3 migrations : table series_metadata, colonne authors sur series_metadata et books
- Composants EditBookForm et EditSeriesForm dans le backoffice
- Routes API Next.js correspondantes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 17:21:55 +01:00
parent a085924f8a
commit bc98067871
15 changed files with 1061 additions and 8 deletions

View File

@@ -38,6 +38,7 @@ pub struct BookItem {
pub format: Option<String>,
pub title: String,
pub author: Option<String>,
pub authors: Vec<String>,
pub series: Option<String>,
pub volume: Option<i32>,
pub language: Option<String>,
@@ -69,6 +70,7 @@ pub struct BookDetails {
pub kind: String,
pub title: String,
pub author: Option<String>,
pub authors: Vec<String>,
pub series: Option<String>,
pub volume: Option<i32>,
pub language: Option<String>,
@@ -149,7 +151,7 @@ pub async fn list_books(
let offset_p = p + 2;
let data_sql = format!(
r#"
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,
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, 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
@@ -204,6 +206,7 @@ pub async fn list_books(
format: row.get("format"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
volume: row.get("volume"),
language: row.get("language"),
@@ -246,7 +249,7 @@ pub async fn get_book(
) -> Result<Json<BookDetails>, ApiError> {
let row = sqlx::query(
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,
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
bf.abs_path, bf.format, bf.parse_status,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
@@ -275,6 +278,7 @@ pub async fn get_book(
kind: row.get("kind"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
volume: row.get("volume"),
language: row.get("language"),
@@ -784,7 +788,7 @@ pub async fn ongoing_books(
),
next_books AS (
SELECT
b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume,
b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, 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,
@@ -799,7 +803,7 @@ pub async fn ongoing_books(
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,
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at
FROM next_books
WHERE rn = 1
@@ -822,6 +826,7 @@ pub async fn ongoing_books(
format: row.get("format"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
volume: row.get("volume"),
language: row.get("language"),
@@ -945,6 +950,305 @@ pub async fn convert_book(
Ok(Json(crate::index_jobs::map_row(job_row)))
}
// ─── Metadata editing ─────────────────────────────────────────────────────────
#[derive(Deserialize, ToSchema)]
pub struct UpdateBookRequest {
pub title: String,
pub author: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
pub series: Option<String>,
pub volume: Option<i32>,
pub language: Option<String>,
}
/// Update metadata for a specific book
#[utoipa::path(
patch,
path = "/books/{id}",
tag = "books",
params(("id" = String, Path, description = "Book UUID")),
request_body = UpdateBookRequest,
responses(
(status = 200, body = BookDetails),
(status = 400, description = "Invalid request"),
(status = 404, description = "Book not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_book(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateBookRequest>,
) -> Result<Json<BookDetails>, ApiError> {
let title = body.title.trim().to_string();
if title.is_empty() {
return Err(ApiError::bad_request("title cannot be empty"));
}
let author = body.author.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let authors: Vec<String> = body.authors.iter()
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.collect();
let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let row = sqlx::query(
r#"
UPDATE books
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7, updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
"#,
)
.bind(id)
.bind(&title)
.bind(&author)
.bind(&authors)
.bind(&series)
.bind(body.volume)
.bind(&language)
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
let thumbnail_path: Option<String> = row.get("thumbnail_path");
Ok(Json(BookDetails {
id: row.get("id"),
library_id: row.get("library_id"),
kind: row.get("kind"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
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", id)),
file_path: None,
file_format: None,
file_parse_status: None,
reading_status: row.get("reading_status"),
reading_current_page: row.get("reading_current_page"),
reading_last_read_at: row.get("reading_last_read_at"),
}))
}
#[derive(Serialize, ToSchema)]
pub struct SeriesMetadata {
/// Authors of the series (series-level metadata, distinct from per-book author field)
pub authors: Vec<String>,
pub description: Option<String>,
pub publishers: Vec<String>,
pub start_year: Option<i32>,
/// Convenience: author from first book (for pre-filling the per-book apply section)
pub book_author: Option<String>,
pub book_language: Option<String>,
}
/// Get metadata for a specific series
#[utoipa::path(
get,
path = "/libraries/{library_id}/series/{name}/metadata",
tag = "books",
params(
("library_id" = String, Path, description = "Library UUID"),
("name" = String, Path, description = "Series name"),
),
responses(
(status = 200, body = SeriesMetadata),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_series_metadata(
State(state): State<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
) -> Result<Json<SeriesMetadata>, ApiError> {
// author/language from first book of series
let books_row = if name == "unclassified" {
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND (series IS NULL OR series = '') LIMIT 1")
.bind(library_id)
.fetch_optional(&state.pool)
.await?
} else {
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND series = $2 LIMIT 1")
.bind(library_id)
.bind(&name)
.fetch_optional(&state.pool)
.await?
};
let meta_row = sqlx::query(
"SELECT authors, description, publishers, start_year FROM series_metadata WHERE library_id = $1 AND name = $2"
)
.bind(library_id)
.bind(&name)
.fetch_optional(&state.pool)
.await?;
Ok(Json(SeriesMetadata {
authors: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("authors")).unwrap_or_default(),
description: meta_row.as_ref().and_then(|r| r.get("description")),
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
start_year: meta_row.as_ref().and_then(|r| r.get("start_year")),
book_author: books_row.as_ref().and_then(|r| r.get("author")),
book_language: books_row.as_ref().and_then(|r| r.get("language")),
}))
}
/// `author` and `language` are wrapped in an extra Option so we can distinguish
/// "absent from JSON" (keep books unchanged) from "present as null" (clear the field).
#[derive(Deserialize, ToSchema)]
pub struct UpdateSeriesRequest {
pub new_name: String,
/// Series-level authors list (stored in series_metadata)
#[serde(default)]
pub authors: Vec<String>,
/// Per-book author propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Option<String>>,
/// Per-book language propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<Option<String>>,
pub description: Option<String>,
#[serde(default)]
pub publishers: Vec<String>,
pub start_year: Option<i32>,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateSeriesResponse {
pub updated: u64,
}
/// Update metadata for all books in a series
#[utoipa::path(
patch,
path = "/libraries/{library_id}/series/{name}",
tag = "books",
params(
("library_id" = String, Path, description = "Library UUID"),
("name" = String, Path, description = "Series name (use 'unclassified' for books without series)"),
),
request_body = UpdateSeriesRequest,
responses(
(status = 200, body = UpdateSeriesResponse),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_series(
State(state): State<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
Json(body): Json<UpdateSeriesRequest>,
) -> Result<Json<UpdateSeriesResponse>, ApiError> {
let new_name = body.new_name.trim().to_string();
if new_name.is_empty() {
return Err(ApiError::bad_request("series name cannot be empty"));
}
// author/language: None = absent (keep books unchanged), Some(v) = apply to all books
let apply_author = body.author.is_some();
let author_value = body.author.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let apply_language = body.language.is_some();
let language_value = body.language.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let description = body.description.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let publishers: Vec<String> = body.publishers.iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
let new_series_value: Option<String> = if new_name == "unclassified" { None } else { Some(new_name.clone()) };
// 1. Update books: always update series name; author/language only if opted-in
// $1=library_id, $2=new_series_value, $3=apply_author, $4=author_value,
// $5=apply_language, $6=language_value, [$7=old_name]
let result = if name == "unclassified" {
sqlx::query(
"UPDATE books \
SET series = $2, \
author = CASE WHEN $3 THEN $4 ELSE author END, \
language = CASE WHEN $5 THEN $6 ELSE language END, \
updated_at = NOW() \
WHERE library_id = $1 AND (series IS NULL OR series = '')"
)
.bind(library_id)
.bind(&new_series_value)
.bind(apply_author)
.bind(&author_value)
.bind(apply_language)
.bind(&language_value)
.execute(&state.pool)
.await?
} else {
sqlx::query(
"UPDATE books \
SET series = $2, \
author = CASE WHEN $3 THEN $4 ELSE author END, \
language = CASE WHEN $5 THEN $6 ELSE language END, \
updated_at = NOW() \
WHERE library_id = $1 AND series = $7"
)
.bind(library_id)
.bind(&new_series_value)
.bind(apply_author)
.bind(&author_value)
.bind(apply_language)
.bind(&language_value)
.bind(&name)
.execute(&state.pool)
.await?
};
// 2. Upsert series_metadata (keyed by new_name)
let meta_name = new_series_value.as_deref().unwrap_or("unclassified");
let authors: Vec<String> = body.authors.iter()
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.collect();
sqlx::query(
r#"
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (library_id, name) DO UPDATE
SET authors = EXCLUDED.authors,
description = EXCLUDED.description,
publishers = EXCLUDED.publishers,
start_year = EXCLUDED.start_year,
updated_at = NOW()
"#
)
.bind(library_id)
.bind(meta_name)
.bind(&authors)
.bind(&description)
.bind(&publishers)
.bind(body.start_year)
.execute(&state.pool)
.await?;
// 3. If renamed, move series_metadata from old name to new name
if name != "unclassified" && new_name != name {
sqlx::query(
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
)
.bind(library_id)
.bind(&name)
.execute(&state.pool)
.await?;
}
Ok(Json(UpdateSeriesResponse { updated: result.rows_affected() }))
}
use axum::{
body::Body,
http::{header, HeaderMap, HeaderValue, StatusCode},

View File

@@ -84,7 +84,9 @@ async fn main() -> anyhow::Result<()> {
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/books/:id", axum::routing::patch(books::update_book))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
@@ -112,6 +114,7 @@ async fn main() -> anyhow::Result<()> {
.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("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
.route("/series", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))

View File

@@ -15,6 +15,9 @@ use utoipa::OpenApi;
crate::books::ongoing_series,
crate::books::ongoing_books,
crate::books::convert_book,
crate::books::update_book,
crate::books::get_series_metadata,
crate::books::update_series,
crate::pages::get_page,
crate::search::search_books,
crate::index_jobs::enqueue_rebuild,
@@ -58,6 +61,10 @@ use utoipa::OpenApi;
crate::books::SeriesPage,
crate::books::ListAllSeriesQuery,
crate::books::OngoingQuery,
crate::books::UpdateBookRequest,
crate::books::SeriesMetadata,
crate::books::UpdateSeriesRequest,
crate::books::UpdateSeriesResponse,
crate::pages::PageQuery,
crate::search::SearchQuery,
crate::search::SearchResponse,