diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 46b0109..1bc0a12 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -1079,6 +1079,8 @@ pub struct SeriesMetadata { pub publishers: Vec, pub start_year: Option, pub total_volumes: Option, + /// Series status: "ongoing", "ended", "hiatus", "cancelled", or null + pub status: Option, /// Convenience: author from first book (for pre-filling the per-book apply section) pub book_author: Option, pub book_language: Option, @@ -1120,7 +1122,7 @@ pub async fn get_series_metadata( }; let meta_row = sqlx::query( - "SELECT authors, description, publishers, start_year, total_volumes, locked_fields FROM series_metadata WHERE library_id = $1 AND name = $2" + "SELECT authors, description, publishers, start_year, total_volumes, status, locked_fields FROM series_metadata WHERE library_id = $1 AND name = $2" ) .bind(library_id) .bind(&name) @@ -1133,6 +1135,7 @@ pub async fn get_series_metadata( publishers: meta_row.as_ref().map(|r| r.get::, _>("publishers")).unwrap_or_default(), start_year: meta_row.as_ref().and_then(|r| r.get("start_year")), total_volumes: meta_row.as_ref().and_then(|r| r.get("total_volumes")), + status: meta_row.as_ref().and_then(|r| r.get("status")), book_author: books_row.as_ref().and_then(|r| r.get("author")), book_language: books_row.as_ref().and_then(|r| r.get("language")), locked_fields: meta_row.as_ref().map(|r| r.get::("locked_fields")).unwrap_or(serde_json::json!({})), @@ -1158,6 +1161,8 @@ pub struct UpdateSeriesRequest { pub publishers: Vec, pub start_year: Option, pub total_volumes: Option, + /// Series status: "ongoing", "ended", "hiatus", "cancelled", or null + pub status: Option, /// Fields locked from external metadata sync #[serde(default)] pub locked_fields: Option, @@ -1256,14 +1261,15 @@ pub async fn update_series( let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({})); sqlx::query( r#" - INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, locked_fields, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, status, locked_fields, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) ON CONFLICT (library_id, name) DO UPDATE SET authors = EXCLUDED.authors, description = EXCLUDED.description, publishers = EXCLUDED.publishers, start_year = EXCLUDED.start_year, total_volumes = EXCLUDED.total_volumes, + status = EXCLUDED.status, locked_fields = EXCLUDED.locked_fields, updated_at = NOW() "# @@ -1275,6 +1281,7 @@ pub async fn update_series( .bind(&publishers) .bind(body.start_year) .bind(body.total_volumes) + .bind(&body.status) .bind(&locked_fields) .execute(&state.pool) .await?; diff --git a/apps/api/src/metadata.rs b/apps/api/src/metadata.rs index d1a685f..3ce0ee1 100644 --- a/apps/api/src/metadata.rs +++ b/apps/api/src/metadata.rs @@ -109,6 +109,8 @@ pub struct SyncReport { pub books: Vec, pub books_matched: i64, pub books_unmatched: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub books_message: Option, } #[derive(Serialize, ToSchema)] @@ -352,6 +354,14 @@ pub async fn approve_metadata( report.books = book_reports; report.books_unmatched = unmatched; + if matched == 0 && unmatched == 0 { + report.books_message = Some( + "This provider does not have volume-level data for this series. \ + Series metadata was synced, but book matching is not available." + .to_string(), + ); + } + // Update synced_at sqlx::query("UPDATE external_metadata_links SET synced_at = NOW(), updated_at = NOW() WHERE id = $1") .bind(id) @@ -683,10 +693,14 @@ async fn sync_series_metadata( .get("start_year") .and_then(|y| y.as_i64()) .map(|y| y as i32); + let status = metadata_json + .get("status") + .and_then(|s| s.as_str()) + .map(normalize_series_status); // Fetch existing state before upsert let existing = sqlx::query( - r#"SELECT description, publishers, start_year, total_volumes, authors, locked_fields + r#"SELECT description, publishers, start_year, total_volumes, status, authors, locked_fields FROM series_metadata WHERE library_id = $1 AND name = $2"#, ) .bind(library_id) @@ -697,8 +711,8 @@ async fn sync_series_metadata( // Respect locked_fields: only update fields that are NOT locked sqlx::query( r#" - INSERT INTO series_metadata (library_id, name, description, publishers, start_year, total_volumes, authors, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + INSERT INTO series_metadata (library_id, name, description, publishers, start_year, total_volumes, status, authors, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) ON CONFLICT (library_id, name) DO UPDATE SET description = CASE @@ -718,6 +732,10 @@ async fn sync_series_metadata( WHEN (series_metadata.locked_fields->>'total_volumes')::boolean IS TRUE THEN series_metadata.total_volumes ELSE COALESCE(EXCLUDED.total_volumes, series_metadata.total_volumes) END, + status = CASE + WHEN (series_metadata.locked_fields->>'status')::boolean IS TRUE THEN series_metadata.status + ELSE COALESCE(EXCLUDED.status, series_metadata.status) + END, authors = CASE WHEN (series_metadata.locked_fields->>'authors')::boolean IS TRUE THEN series_metadata.authors WHEN array_length(EXCLUDED.authors, 1) > 0 THEN EXCLUDED.authors @@ -732,6 +750,7 @@ async fn sync_series_metadata( .bind(&publishers) .bind(start_year) .bind(total_volumes) + .bind(&status) .bind(&authors) .execute(&state.pool) .await?; @@ -779,6 +798,11 @@ async fn sync_series_metadata( old: existing.as_ref().and_then(|r| r.get::, _>("total_volumes")).map(|y| serde_json::json!(y)), new: total_volumes.map(|y| serde_json::json!(y)), }, + FieldDef { + name: "status", + old: existing.as_ref().and_then(|r| r.get::, _>("status")).map(|s| serde_json::Value::String(s)), + new: status.as_ref().map(|s| serde_json::Value::String(s.clone())), + }, ]; for f in fields { @@ -801,6 +825,27 @@ async fn sync_series_metadata( Ok(report) } +/// Normalize provider-specific status strings to a standard set: +/// "ongoing", "ended", "hiatus", "cancelled", or the original lowercase value +fn normalize_series_status(raw: &str) -> String { + let lower = raw.to_lowercase(); + match lower.as_str() { + // AniList + "finished" => "ended".to_string(), + "releasing" => "ongoing".to_string(), + "not_yet_released" => "upcoming".to_string(), + "cancelled" => "cancelled".to_string(), + "hiatus" => "hiatus".to_string(), + // Bédéthèque + _ if lower.contains("finie") || lower.contains("terminée") => "ended".to_string(), + _ if lower.contains("en cours") => "ongoing".to_string(), + _ if lower.contains("hiatus") || lower.contains("suspendue") => "hiatus".to_string(), + _ if lower.contains("annulée") || lower.contains("arrêtée") => "cancelled".to_string(), + // Fallback + _ => lower, + } +} + async fn sync_books_metadata( state: &AppState, link_id: Uuid, diff --git a/apps/api/src/metadata_providers/anilist.rs b/apps/api/src/metadata_providers/anilist.rs index 936ee12..e08c534 100644 --- a/apps/api/src/metadata_providers/anilist.rs +++ b/apps/api/src/metadata_providers/anilist.rs @@ -41,6 +41,7 @@ query ($search: String) { description(asHtml: false) coverImage { large medium } startDate { year } + status volumes chapters staff { edges { node { name { full } } role } } @@ -59,6 +60,7 @@ query ($id: Int) { description(asHtml: false) coverImage { large medium } startDate { year } + status volumes chapters staff { edges { node { name { full } } role } } @@ -157,6 +159,17 @@ async fn search_series_impl( .and_then(|v| v.as_i64()) .map(|v| v as i32); + let chapters = m + .get("chapters") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + let status = m + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("UNKNOWN") + .to_string(); + let site_url = m .get("siteUrl") .and_then(|u| u.as_str()) @@ -166,6 +179,15 @@ async fn search_series_impl( let confidence = compute_confidence(&title, &query_lower); + // Use volumes if known, otherwise fall back to chapters count + let (total_volumes, volume_source) = match volumes { + Some(v) => (Some(v), "volumes"), + None => match chapters { + Some(c) => (Some(c), "chapters"), + None => (None, "unknown"), + }, + }; + Some(SeriesCandidate { external_id: id.to_string(), title, @@ -173,11 +195,16 @@ async fn search_series_impl( description, publishers: vec![], start_year, - total_volumes: volumes, + total_volumes, cover_url, external_url: site_url, confidence, - metadata_json: serde_json::json!({}), + metadata_json: serde_json::json!({ + "status": status, + "chapters": chapters, + "volumes": volumes, + "volume_source": volume_source, + }), }) }) .collect(); @@ -225,6 +252,14 @@ async fn get_series_books_impl( .and_then(|v| v.as_i64()) .map(|v| v as i32); + let chapters = media + .get("chapters") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + // Use volumes if known, otherwise fall back to chapters count + let total = volumes.or(chapters); + let cover_url = media .get("coverImage") .and_then(|ci| ci.get("large").or_else(|| ci.get("medium"))) @@ -238,9 +273,9 @@ async fn get_series_books_impl( let authors = extract_authors(media); - // AniList doesn't have per-volume data — generate volume entries if volumes count is known + // AniList doesn't have per-volume data — generate entries from volumes count (or chapters as fallback) let mut books = Vec::new(); - if let Some(total) = volumes { + if let Some(total) = total { for vol in 1..=total { books.push(BookCandidate { external_book_id: format!("{}-vol-{}", external_id, vol), @@ -256,21 +291,6 @@ async fn get_series_books_impl( metadata_json: serde_json::json!({}), }); } - } else { - // Single entry for the whole manga - books.push(BookCandidate { - external_book_id: external_id.to_string(), - title, - volume_number: Some(1), - authors, - isbn: None, - summary: description, - cover_url, - page_count: None, - language: Some("ja".to_string()), - publish_date: None, - metadata_json: serde_json::json!({}), - }); } Ok(books) diff --git a/apps/api/src/metadata_providers/bedetheque.rs b/apps/api/src/metadata_providers/bedetheque.rs index 285ede4..1fae076 100644 --- a/apps/api/src/metadata_providers/bedetheque.rs +++ b/apps/api/src/metadata_providers/bedetheque.rs @@ -210,6 +210,10 @@ async fn search_series_impl( "authors": c.authors, "publishers": c.publishers, "start_year": c.start_year, + "genres": details.genres, + "status": details.status, + "origin": details.origin, + "language": details.language, }); } } @@ -235,6 +239,10 @@ struct SeriesDetails { publishers: Vec, start_year: Option, album_count: Option, + genres: Vec, + status: Option, + origin: Option, + language: Option, } async fn fetch_series_details( @@ -276,64 +284,109 @@ async fn fetch_series_details( publishers: vec![], start_year: None, album_count: None, + genres: vec![], + status: None, + origin: None, + language: None, }; - // Description: look for #full-commentaire or .serie-info - if let Ok(sel) = Selector::parse("#full-commentaire") { + // Description from — format: "Tout sur la série {name} : {description}" + if let Ok(sel) = Selector::parse(r#"meta[name="description"]"#) { if let Some(el) = doc.select(&sel).next() { - let text = el.text().collect::().trim().to_string(); - if !text.is_empty() { - details.description = Some(text); - } - } - } - - // Fallback description from span.infoedition - if details.description.is_none() { - if let Ok(sel) = Selector::parse("span.infoedition") { - if let Some(el) = doc.select(&sel).next() { - let text = el.text().collect::().trim().to_string(); - if !text.is_empty() { - details.description = Some(text); + if let Some(content) = el.value().attr("content") { + let desc = content.trim().to_string(); + // Strip the "Tout sur la série ... : " prefix + let cleaned = if let Some(pos) = desc.find(" : ") { + desc[pos + 3..].trim().to_string() + } else { + desc + }; + if !cleaned.is_empty() { + details.description = Some(cleaned); } } } } - // Extract authors and publishers from album info blocks - if let Ok(sel) = Selector::parse(".infos li") { + // Extract authors from itemprop="author" and itemprop="illustrator" (deduplicated) + { let mut authors_set = std::collections::HashSet::new(); - let mut publishers_set = std::collections::HashSet::new(); - - for li in doc.select(&sel) { - let text = li.text().collect::(); - let text = text.trim(); - - if let Some(val) = extract_info_value(text, "Scénario") { - for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) { - authors_set.insert(a.to_string()); - } - } - if let Some(val) = extract_info_value(text, "Dessin") { - for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) { - authors_set.insert(a.to_string()); - } - } - if let Some(val) = extract_info_value(text, "Editeur") { - for p in val.split(',').map(str::trim).filter(|s| !s.is_empty()) { - publishers_set.insert(p.to_string()); + for attr in ["author", "illustrator"] { + if let Ok(sel) = Selector::parse(&format!(r#"[itemprop="{attr}"]"#)) { + for el in doc.select(&sel) { + let name = el.text().collect::().trim().to_string(); + // Names are "Last, First" — normalize to "First Last" + let normalized = if let Some((last, first)) = name.split_once(',') { + format!("{} {}", first.trim(), last.trim()) + } else { + name + }; + if !normalized.is_empty() && is_real_author(&normalized) { + authors_set.insert(normalized); + } } } } - details.authors = authors_set.into_iter().collect(); details.authors.sort(); + } + + // Extract publishers from itemprop="publisher" (deduplicated) + { + let mut publishers_set = std::collections::HashSet::new(); + if let Ok(sel) = Selector::parse(r#"[itemprop="publisher"]"#) { + for el in doc.select(&sel) { + let name = el.text().collect::().trim().to_string(); + if !name.is_empty() { + publishers_set.insert(name); + } + } + } details.publishers = publishers_set.into_iter().collect(); details.publishers.sort(); } - // Album count from serie-info text (e.g. "Tomes : 8") + // Extract series-level info from
  • value
  • blocks + // Genre:
  • Animalier, Aventure, Humour
  • + if let Ok(sel) = Selector::parse("span.style-serie") { + if let Some(el) = doc.select(&sel).next() { + let text = el.text().collect::(); + details.genres = text + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + + // Parution:
  • Série finie
  • + if let Ok(sel) = Selector::parse("span.parution-serie") { + if let Some(el) = doc.select(&sel).next() { + let text = el.text().collect::().trim().to_string(); + if !text.is_empty() { + details.status = Some(text); + } + } + } + + // Origine and Langue from page text (no dedicated CSS class) let page_text = doc.root_element().text().collect::(); + + if let Some(val) = extract_info_value(&page_text, "Origine") { + let val = val.lines().next().unwrap_or(val).trim(); + if !val.is_empty() { + details.origin = Some(val.to_string()); + } + } + + if let Some(val) = extract_info_value(&page_text, "Langue") { + let val = val.lines().next().unwrap_or(val).trim(); + if !val.is_empty() { + details.language = Some(val.to_string()); + } + } + + // Album count from serie-info text (e.g. "Tomes : 8") if let Ok(re) = regex::Regex::new(r"Tomes?\s*:\s*(\d+)") { if let Some(caps) = re.captures(&page_text) { if let Ok(n) = caps[1].parse::() { @@ -342,11 +395,16 @@ async fn fetch_series_details( } } - // Start year from first album date (Dépot légal) - if let Ok(re) = regex::Regex::new(r"[Dd][ée]p[ôo]t l[ée]gal\s*:\s*\d{2}/(\d{4})") { - if let Some(caps) = re.captures(&page_text) { - if let Ok(year) = caps[1].parse::() { - details.start_year = Some(year); + // Start year from first + if let Ok(sel) = Selector::parse(r#"[itemprop="datePublished"]"#) { + if let Some(el) = doc.select(&sel).next() { + if let Some(content) = el.value().attr("content") { + // content is "YYYY-MM-DD" + if let Some(year_str) = content.split('-').next() { + if let Ok(year) = year_str.parse::() { + details.start_year = Some(year); + } + } } } } @@ -424,79 +482,91 @@ async fn get_series_books_impl( let doc = Html::parse_document(&html); let mut books = Vec::new(); - // Albums are in .album-main blocks + // Each album block starts before a .album-main div. + // The cover image () is OUTSIDE .album-main (sibling), + // so we iterate over a broader parent. But the simplest approach: parse all + // itemprop elements relative to each .album-main, plus pick covers separately. let album_sel = Selector::parse(".album-main").map_err(|e| format!("selector: {e}"))?; - for album_el in doc.select(&album_sel) { - let album_html = album_el.html(); - let album_doc = Html::parse_fragment(&album_html); + // Pre-collect cover images — they appear in before each .album-main + // and link to an album URL containing the book ID + let cover_sel = Selector::parse(r#"img[itemprop="image"]"#).map_err(|e| format!("selector: {e}"))?; + let covers: Vec = doc.select(&cover_sel) + .filter_map(|el| el.value().attr("src").map(|s| { + if s.starts_with("http") { s.to_string() } else { format!("https://www.bedetheque.com{}", s) } + })) + .collect(); - // Title from .titre - let title = select_text(&album_doc, ".titre") - .or_else(|| { - Selector::parse(".titre a") - .ok() - .and_then(|s| album_doc.select(&s).next()) - .map(|el| el.text().collect::().trim().to_string()) - }) - .unwrap_or_default(); + for (idx, album_el) in doc.select(&album_sel).enumerate() { + // Title from — the title attribute is clean + let title_sel = Selector::parse("a.titre").ok(); + let title_el = title_sel.as_ref().and_then(|s| album_el.select(s).next()); + let title = title_el + .and_then(|el| el.value().attr("title")) + .unwrap_or("") + .trim() + .to_string(); if title.is_empty() { continue; } - // Volume number from title or .num span - let volume_number = select_text(&album_doc, ".num") - .and_then(|s| { - s.trim_end_matches('.') - .trim() - .parse::() - .ok() - }) - .or_else(|| extract_volume_from_title(&title)); - - // Album URL - let album_url = Selector::parse("a[href*='/BD-']") + // External book ID from album URL (e.g. "...-1063.html") + let album_url = title_el.and_then(|el| el.value().attr("href")).unwrap_or(""); + let external_book_id = regex::Regex::new(r"-(\d+)\.html") .ok() - .and_then(|s| album_doc.select(&s).next()) - .and_then(|el| el.value().attr("href")) - .map(String::from); - - // External book id from URL - let external_book_id = album_url - .as_deref() - .and_then(|u| { - regex::Regex::new(r"-(\d+)\.html") - .ok() - .and_then(|re| re.captures(u)) - .map(|c| c[1].to_string()) - }) + .and_then(|re| re.captures(album_url)) + .map(|c| c[1].to_string()) .unwrap_or_default(); - // Cover - let cover_url = Selector::parse("img[src*='cache/thb_couv']") + // Volume number from URL pattern "Tome-{N}-" or from itemprop name + let volume_number = regex::Regex::new(r"(?i)Tome-(\d+)-") .ok() - .and_then(|s| album_doc.select(&s).next()) - .and_then(|el| el.value().attr("src")) - .map(|s| { - if s.starts_with("http") { - s.to_string() - } else { - format!("https://www.bedetheque.com{}", s) - } - }); + .and_then(|re| re.captures(album_url)) + .and_then(|c| c[1].parse::().ok()) + .or_else(|| extract_volume_from_title(&title)); - // Extract info fields - let album_text = album_el.text().collect::(); - let authors = extract_all_authors(&album_text); - let isbn = extract_info_value(&album_text, "EAN/ISBN") - .or_else(|| extract_info_value(&album_text, "ISBN")) - .map(|s| s.trim().to_string()); - let page_count = extract_info_value(&album_text, "Planches") - .and_then(|s| s.trim().parse::().ok()); - let publish_date = extract_info_value(&album_text, "Dépot légal") - .or_else(|| extract_info_value(&album_text, "Depot legal")) - .map(|s| s.trim().to_string()); + // Authors from itemprop="author" and itemprop="illustrator" + let mut authors = Vec::new(); + let author_sel = Selector::parse(r#"[itemprop="author"]"#).ok(); + let illustrator_sel = Selector::parse(r#"[itemprop="illustrator"]"#).ok(); + for sel in [&author_sel, &illustrator_sel].into_iter().flatten() { + for el in album_el.select(sel) { + let name = el.text().collect::().trim().to_string(); + // Names are "Last, First" format — normalize to "First Last" + let normalized = if let Some((last, first)) = name.split_once(',') { + format!("{} {}", first.trim(), last.trim()) + } else { + name + }; + if !normalized.is_empty() && is_real_author(&normalized) && !authors.contains(&normalized) { + authors.push(normalized); + } + } + } + + // ISBN from + let isbn = Selector::parse(r#"[itemprop="isbn"]"#) + .ok() + .and_then(|s| album_el.select(&s).next()) + .map(|el| el.text().collect::().trim().to_string()) + .filter(|s| !s.is_empty()); + + // Page count from + let page_count = Selector::parse(r#"[itemprop="numberOfPages"]"#) + .ok() + .and_then(|s| album_el.select(&s).next()) + .and_then(|el| el.text().collect::().trim().parse::().ok()); + + // Publish date from + let publish_date = Selector::parse(r#"[itemprop="datePublished"]"#) + .ok() + .and_then(|s| album_el.select(&s).next()) + .and_then(|el| el.value().attr("content").map(|c| c.trim().to_string())) + .filter(|s| !s.is_empty()); + + // Cover from pre-collected covers (same index) + let cover_url = covers.get(idx).cloned(); books.push(BookCandidate { external_book_id, @@ -517,26 +587,9 @@ async fn get_series_books_impl( Ok(books) } -fn select_text(doc: &Html, selector: &str) -> Option { - Selector::parse(selector) - .ok() - .and_then(|s| doc.select(&s).next()) - .map(|el| el.text().collect::().trim().to_string()) - .filter(|s| !s.is_empty()) -} - -fn extract_all_authors(text: &str) -> Vec { - let mut authors = Vec::new(); - for label in ["Scénario", "Scenario", "Dessin"] { - if let Some(val) = extract_info_value(text, label) { - for a in val.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if !authors.contains(&a.to_string()) { - authors.push(a.to_string()); - } - } - } - } - authors +/// Filter out placeholder author names from Bédéthèque +fn is_real_author(name: &str) -> bool { + !name.starts_with('<') && !name.ends_with('>') && name != "Collectif" } fn extract_volume_from_title(title: &str) -> Option { diff --git a/apps/api/src/metadata_providers/mod.rs b/apps/api/src/metadata_providers/mod.rs index 349055c..3dce916 100644 --- a/apps/api/src/metadata_providers/mod.rs +++ b/apps/api/src/metadata_providers/mod.rs @@ -79,3 +79,217 @@ pub fn get_provider(name: &str) -> Option> { _ => None, } } + +// --------------------------------------------------------------------------- +// End-to-end provider tests +// +// These tests hit real external APIs — run them explicitly with: +// cargo test -p api providers_e2e -- --ignored --nocapture +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod providers_e2e { + use super::*; + + fn config_fr() -> ProviderConfig { + ProviderConfig { api_key: None, language: "fr".to_string() } + } + + fn config_en() -> ProviderConfig { + ProviderConfig { api_key: None, language: "en".to_string() } + } + + fn print_candidate(name: &str, c: &SeriesCandidate) { + println!("\n=== {name} — best candidate ==="); + println!(" title: {:?}", c.title); + println!(" external_id: {:?}", c.external_id); + println!(" authors: {:?}", c.authors); + println!(" description: {:?}", c.description.as_deref().map(|d| &d[..d.len().min(120)])); + println!(" publishers: {:?}", c.publishers); + println!(" start_year: {:?}", c.start_year); + println!(" total_volumes: {:?}", c.total_volumes); + println!(" cover_url: {}", c.cover_url.is_some()); + println!(" external_url: {}", c.external_url.is_some()); + println!(" confidence: {:.2}", c.confidence); + println!(" metadata_json: {}", serde_json::to_string_pretty(&c.metadata_json).unwrap_or_default()); + } + + fn print_books(name: &str, books: &[BookCandidate]) { + println!("\n=== {name} — {} books ===", books.len()); + for (i, b) in books.iter().take(5).enumerate() { + println!( + " [{}] vol={:?} title={:?} authors={} isbn={:?} pages={:?} lang={:?} date={:?} cover={}", + i, b.volume_number, b.title, b.authors.len(), b.isbn, b.page_count, b.language, b.publish_date, b.cover_url.is_some() + ); + } + if books.len() > 5 { println!(" ... and {} more", books.len() - 5); } + + let with_vol = books.iter().filter(|b| b.volume_number.is_some()).count(); + let with_isbn = books.iter().filter(|b| b.isbn.is_some()).count(); + let with_authors = books.iter().filter(|b| !b.authors.is_empty()).count(); + let with_date = books.iter().filter(|b| b.publish_date.is_some()).count(); + let with_cover = books.iter().filter(|b| b.cover_url.is_some()).count(); + let with_pages = books.iter().filter(|b| b.page_count.is_some()).count(); + println!(" --- field coverage ---"); + println!(" volume_number: {with_vol}/{}", books.len()); + println!(" isbn: {with_isbn}/{}", books.len()); + println!(" authors: {with_authors}/{}", books.len()); + println!(" publish_date: {with_date}/{}", books.len()); + println!(" cover_url: {with_cover}/{}", books.len()); + println!(" page_count: {with_pages}/{}", books.len()); + } + + // --- Google Books --- + + #[tokio::test] + #[ignore] + async fn google_books_search_and_books() { + let p = get_provider("google_books").unwrap(); + let cfg = config_en(); + + let candidates = p.search_series("Blacksad", &cfg).await.unwrap(); + assert!(!candidates.is_empty(), "google_books: no results for Blacksad"); + print_candidate("google_books", &candidates[0]); + + let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap(); + print_books("google_books", &books); + assert!(!books.is_empty(), "google_books: no books returned"); + } + + // --- Open Library --- + + #[tokio::test] + #[ignore] + async fn open_library_search_and_books() { + let p = get_provider("open_library").unwrap(); + let cfg = config_en(); + + let candidates = p.search_series("Sandman Neil Gaiman", &cfg).await.unwrap(); + assert!(!candidates.is_empty(), "open_library: no results for Sandman"); + print_candidate("open_library", &candidates[0]); + + let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap(); + print_books("open_library", &books); + assert!(!books.is_empty(), "open_library: no books returned"); + } + + // --- AniList --- + + #[tokio::test] + #[ignore] + async fn anilist_search_finished() { + let p = get_provider("anilist").unwrap(); + let cfg = config_fr(); + + let candidates = p.search_series("Death Note", &cfg).await.unwrap(); + assert!(!candidates.is_empty(), "anilist: no results for Death Note"); + print_candidate("anilist (finished)", &candidates[0]); + + let best = &candidates[0]; + assert!(best.total_volumes.is_some(), "anilist: finished series should have total_volumes"); + assert!(best.description.is_some(), "anilist: should have description"); + assert!(!best.authors.is_empty(), "anilist: should have authors"); + + let status = best.metadata_json.get("status").and_then(|s| s.as_str()); + assert_eq!(status, Some("FINISHED"), "anilist: Death Note should be FINISHED"); + + let books = p.get_series_books(&best.external_id, &cfg).await.unwrap(); + print_books("anilist (Death Note)", &books); + assert!(books.len() >= 12, "anilist: Death Note should have ≥12 volumes, got {}", books.len()); + } + + #[tokio::test] + #[ignore] + async fn anilist_search_ongoing() { + let p = get_provider("anilist").unwrap(); + let cfg = config_fr(); + + let candidates = p.search_series("One Piece", &cfg).await.unwrap(); + assert!(!candidates.is_empty(), "anilist: no results for One Piece"); + print_candidate("anilist (ongoing)", &candidates[0]); + + let best = &candidates[0]; + let status = best.metadata_json.get("status").and_then(|s| s.as_str()); + assert_eq!(status, Some("RELEASING"), "anilist: One Piece should be RELEASING"); + + let volume_source = best.metadata_json.get("volume_source").and_then(|s| s.as_str()); + println!(" volume_source: {:?}", volume_source); + println!(" total_volumes: {:?}", best.total_volumes); + } + + // --- Bédéthèque --- + + #[tokio::test] + #[ignore] + async fn bedetheque_search_and_books() { + let p = get_provider("bedetheque").unwrap(); + let cfg = config_fr(); + + let candidates = p.search_series("De Cape et de Crocs", &cfg).await.unwrap(); + assert!(!candidates.is_empty(), "bedetheque: no results"); + print_candidate("bedetheque", &candidates[0]); + + let best = &candidates[0]; + assert!(best.description.is_some(), "bedetheque: should have description"); + assert!(!best.authors.is_empty(), "bedetheque: should have authors"); + assert!(!best.publishers.is_empty(), "bedetheque: should have publishers"); + assert!(best.start_year.is_some(), "bedetheque: should have start_year"); + assert!(best.total_volumes.is_some(), "bedetheque: should have total_volumes"); + + // Enriched metadata_json + let mj = &best.metadata_json; + assert!(mj.get("genres").and_then(|g| g.as_array()).map(|a| !a.is_empty()).unwrap_or(false), "bedetheque: should have genres"); + assert!(mj.get("status").and_then(|s| s.as_str()).is_some(), "bedetheque: should have status"); + + let books = p.get_series_books(&best.external_id, &cfg).await.unwrap(); + print_books("bedetheque", &books); + assert!(books.len() >= 12, "bedetheque: De Cape et de Crocs should have ≥12 volumes, got {}", books.len()); + } + + // --- ComicVine (needs API key) --- + + #[tokio::test] + #[ignore] + async fn comicvine_no_key() { + let p = get_provider("comicvine").unwrap(); + let cfg = config_en(); + + let result = p.search_series("Batman", &cfg).await; + println!("\n=== comicvine (no key) ==="); + match result { + Ok(c) => println!(" returned {} candidates (unexpected without key)", c.len()), + Err(e) => println!(" expected error: {e}"), + } + } + + // --- Cross-provider comparison --- + + #[tokio::test] + #[ignore] + async fn cross_provider_blacksad() { + println!("\n{}", "=".repeat(60)); + println!(" Cross-provider comparison: Blacksad"); + println!("{}\n", "=".repeat(60)); + + let providers: Vec<(&str, ProviderConfig)> = vec![ + ("google_books", config_en()), + ("open_library", config_en()), + ("anilist", config_fr()), + ("bedetheque", config_fr()), + ]; + + for (name, cfg) in &providers { + let p = get_provider(name).unwrap(); + match p.search_series("Blacksad", cfg).await { + Ok(candidates) if !candidates.is_empty() => { + let b = &candidates[0]; + println!("[{name}] title={:?} authors={} desc={} pubs={} year={:?} vols={:?} cover={} url={} conf={:.2}", + b.title, b.authors.len(), b.description.is_some(), b.publishers.len(), + b.start_year, b.total_volumes, b.cover_url.is_some(), b.external_url.is_some(), b.confidence); + } + Ok(_) => println!("[{name}] no results"), + Err(e) => println!("[{name}] error: {e}"), + } + } + } +} diff --git a/apps/backoffice/app/components/EditSeriesForm.tsx b/apps/backoffice/app/components/EditSeriesForm.tsx index 86c3ce2..92d57bb 100644 --- a/apps/backoffice/app/components/EditSeriesForm.tsx +++ b/apps/backoffice/app/components/EditSeriesForm.tsx @@ -39,6 +39,15 @@ function LockButton({ ); } +const SERIES_STATUSES = [ + { value: "", label: "Non défini" }, + { value: "ongoing", label: "En cours" }, + { value: "ended", label: "Terminée" }, + { value: "hiatus", label: "Hiatus" }, + { value: "cancelled", label: "Annulée" }, + { value: "upcoming", label: "À paraître" }, +] as const; + interface EditSeriesFormProps { libraryId: string; seriesName: string; @@ -49,6 +58,7 @@ interface EditSeriesFormProps { currentDescription: string | null; currentStartYear: number | null; currentTotalVolumes: number | null; + currentStatus: string | null; currentLockedFields: Record; } @@ -62,6 +72,7 @@ export function EditSeriesForm({ currentDescription, currentStartYear, currentTotalVolumes, + currentStatus, currentLockedFields, }: EditSeriesFormProps) { const router = useRouter(); @@ -80,6 +91,7 @@ export function EditSeriesForm({ const [description, setDescription] = useState(currentDescription ?? ""); const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? ""); const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? ""); + const [status, setStatus] = useState(currentStatus ?? ""); // Lock states const [lockedFields, setLockedFields] = useState>(currentLockedFields); @@ -142,6 +154,7 @@ export function EditSeriesForm({ setDescription(currentDescription ?? ""); setStartYear(currentStartYear?.toString() ?? ""); setTotalVolumes(currentTotalVolumes?.toString() ?? ""); + setStatus(currentStatus ?? ""); setLockedFields(currentLockedFields); setShowApplyToBooks(false); setBookAuthor(currentBookAuthor ?? ""); @@ -182,6 +195,7 @@ export function EditSeriesForm({ description: description.trim() || null, start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null, total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null, + status: status || null, locked_fields: lockedFields, }; if (showApplyToBooks) { @@ -285,6 +299,23 @@ export function EditSeriesForm({ /> + +
    + Statut + toggleLock("status")} disabled={isPending} /> +
    + +
    + {/* Auteurs — multi-valeur */}
    diff --git a/apps/backoffice/app/components/MetadataSearchModal.tsx b/apps/backoffice/app/components/MetadataSearchModal.tsx index 8f99655..dd0b0a0 100644 --- a/apps/backoffice/app/components/MetadataSearchModal.tsx +++ b/apps/backoffice/app/components/MetadataSearchModal.tsx @@ -13,6 +13,7 @@ const FIELD_LABELS: Record = { publishers: "Éditeurs", start_year: "Année", total_volumes: "Nb volumes", + status: "Statut", summary: "Résumé", isbn: "ISBN", publish_date: "Date de publication", @@ -338,7 +339,14 @@ export function MetadataSearchModal({
    {c.publishers.length > 0 && {c.publishers[0]}} {c.start_year != null && {c.start_year}} - {c.total_volumes != null && {c.total_volumes} vol.} + {c.total_volumes != null && ( + + {c.total_volumes} {c.metadata_json?.volume_source === "chapters" ? "ch." : "vol."} + + )} + {c.metadata_json?.status === "RELEASING" && ( + en cours + )}
    @@ -366,8 +374,11 @@ export function MetadataSearchModal({ {selectedCandidate.authors.length > 0 && (

    {selectedCandidate.authors.join(", ")}

    )} - {selectedCandidate.total_volumes && ( -

    {selectedCandidate.total_volumes} volumes

    + {selectedCandidate.total_volumes != null && ( +

    + {selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? "chapitres" : "volumes"} + {selectedCandidate.metadata_json?.status === "RELEASING" && (en cours)} +

    )}

    via {providerLabel(selectedCandidate.provider)} @@ -458,8 +469,15 @@ export function MetadataSearchModal({ )} + {/* Books message (e.g. provider has no volume data) */} + {syncReport.books_message && ( +

    +

    {syncReport.books_message}

    +
    + )} + {/* Books report */} - {(syncReport.books.length > 0 || syncReport.books_unmatched > 0) && ( + {!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (

    Livres — {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`} diff --git a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx index 91e8add..6d342ae 100644 --- a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx +++ b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx @@ -100,9 +100,27 @@ export default async function SeriesDetailPage({

    {displayName}

    - {seriesMeta && seriesMeta.authors.length > 0 && ( -

    {seriesMeta.authors.join(", ")}

    - )} +
    + {seriesMeta && seriesMeta.authors.length > 0 && ( +

    {seriesMeta.authors.join(", ")}

    + )} + {seriesMeta?.status && ( + + {seriesMeta.status === "ongoing" ? "En cours" : + seriesMeta.status === "ended" ? "Terminée" : + seriesMeta.status === "hiatus" ? "Hiatus" : + seriesMeta.status === "cancelled" ? "Annulée" : + seriesMeta.status === "upcoming" ? "À paraître" : + seriesMeta.status} + + )} +
    {seriesMeta?.description && ( @@ -153,6 +171,7 @@ export default async function SeriesDetailPage({ currentDescription={seriesMeta?.description ?? null} currentStartYear={seriesMeta?.start_year ?? null} currentTotalVolumes={seriesMeta?.total_volumes ?? null} + currentStatus={seriesMeta?.status ?? null} currentLockedFields={seriesMeta?.locked_fields ?? {}} /> ; @@ -657,6 +658,7 @@ export type SyncReport = { books: BookSyncReport[]; books_matched: number; books_unmatched: number; + books_message?: string; }; export type MissingBooksDto = { diff --git a/infra/migrations/0033_add_status_to_series_metadata.sql b/infra/migrations/0033_add_status_to_series_metadata.sql new file mode 100644 index 0000000..6b7e79c --- /dev/null +++ b/infra/migrations/0033_add_status_to_series_metadata.sql @@ -0,0 +1 @@ +ALTER TABLE series_metadata ADD COLUMN status TEXT;