feat: cover retry backfill + admin force-resync for manga & chapter (0.50.0)
Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose cover download failed on first attempt get retried — the metadata pass's early-stop optimisation otherwise prevents the walk from revisiting them. Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync that refetch metadata + cover (or chapter content with force_refetch) from the crawler source synchronously and return the refreshed row. Surfaced in the UI as "Force resync" buttons on the manga detail and reader pages, admin-only via session.user.is_admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -829,6 +829,107 @@ async fn sync_tags_garbage_collects_orphan_user_attachments(pool: PgPool) {
|
||||
assert_eq!(orphan_rows, 0, "orphan user-attached tag should be reaped");
|
||||
}
|
||||
|
||||
// ---- list_missing_covers ---------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_only_returns_rows_without_cover(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let with_cover = sample_manga("with", "With Cover", "h1");
|
||||
let without_cover = sample_manga("without", "No Cover", "h2");
|
||||
let _w = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/with", &with_cover)
|
||||
.await
|
||||
.unwrap();
|
||||
let nc = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/without", &without_cover)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Manually set a cover for `with` only.
|
||||
sqlx::query("UPDATE mangas SET cover_image_path = 'mangas/x/cover.jpg' WHERE id = $1")
|
||||
.bind(_w.manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert_eq!(entries.len(), 1, "exactly the manga without a cover");
|
||||
assert_eq!(entries[0].manga_id, nc.manga_id);
|
||||
assert_eq!(entries[0].source_manga_key, "without");
|
||||
assert_eq!(entries[0].source_url, "https://x.example/without");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_skips_dropped_source_rows(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let m = sample_manga("foo", "Foo", "h1");
|
||||
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE manga_sources SET dropped_at = NOW() WHERE manga_id = $1")
|
||||
.bind(up.manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"dropped-source mangas must not be backfilled — no live source to fetch from"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_respects_limit(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
let key = format!("m{i}");
|
||||
let url = format!("https://x.example/{key}");
|
||||
let m = sample_manga(&key, &format!("M{i}"), &format!("h{i}"));
|
||||
let _ = crawler::upsert_manga_from_source(&pool, "target", &url, &m)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let entries = crawler::list_missing_covers(&pool, 3).await.unwrap();
|
||||
assert_eq!(entries.len(), 3, "limit caps the result set");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_missing_covers_deduplicates_per_manga(pool: PgPool) {
|
||||
// A manga surfaced by two sources should produce ONE backfill
|
||||
// entry, not two — otherwise the per-tick cap could be eaten by
|
||||
// duplicates and starve other mangas.
|
||||
crawler::ensure_source(&pool, "src-a", "A", "https://a.example")
|
||||
.await
|
||||
.unwrap();
|
||||
crawler::ensure_source(&pool, "src-b", "B", "https://b.example")
|
||||
.await
|
||||
.unwrap();
|
||||
let m = sample_manga("foo", "Foo", "h1");
|
||||
let up = crawler::upsert_manga_from_source(&pool, "src-a", "https://a.example/foo", &m)
|
||||
.await
|
||||
.unwrap();
|
||||
// Second source attaches to the SAME manga row.
|
||||
sqlx::query(
|
||||
"INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind("src-b")
|
||||
.bind("foo-on-b")
|
||||
.bind(up.manga_id)
|
||||
.bind("https://b.example/foo")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = crawler::list_missing_covers(&pool, 50).await.unwrap();
|
||||
assert_eq!(entries.len(), 1, "DISTINCT ON (m.id) collapses duplicate source rows");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
|
||||
Reference in New Issue
Block a user