feat: add series status, improve providers & e2e tests

- Add series status concept (ongoing/ended/hiatus/cancelled/upcoming)
  with normalization across all providers
- Add status field to series_metadata table (migration 0033)
- AniList: use chapters as fallback for volume count on ongoing series,
  add books_message when both volumes and chapters are null
- Bedetheque: extract description from meta tag, genres, parution status,
  origin/language; rewrite book parsing with itemprop microdata for
  clean ISBN, dates, page counts, covers; filter placeholder authors
- Add comprehensive e2e provider tests with field coverage reporting
- Wire status into EditSeriesForm, MetadataSearchModal, and series page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:10:45 +01:00
parent 51ef2fa725
commit 52b9b0e00e
10 changed files with 566 additions and 156 deletions

View File

@@ -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)