Compare commits

..

1 Commits

Author SHA1 Message Date
MechaCat02
ee070d8878 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>
2026-06-01 21:51:46 +02:00
18 changed files with 37 additions and 767 deletions

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.52.0" version = "0.50.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.52.0" version = "0.50.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -1,18 +0,0 @@
-- Capture each chapter's position in the source site's chapter list so
-- the user-facing list can preserve site order: variants of the same
-- chapter number (e.g. "Ch.14 : PH" next to "Ch.14 : Official") stay
-- adjacent, and non-numeric entries like "notice. : Officials" land
-- where the site placed them rather than clustering at the top under
-- number = 0.
--
-- Lower source_index = closer to the top of the source DOM = newer
-- chapter on this site (it renders newest-first). The list query
-- reverses this with ORDER BY source_index DESC so the oldest chapter
-- appears first in our UI.
--
-- NULL is the sentinel for user-uploaded chapters (no source row) and
-- for crawled rows that pre-date this migration. The list query keeps
-- the existing (number, created_at) tiebreak via NULLS LAST so those
-- fall through to the prior behaviour until the next crawler tick
-- populates the column.
ALTER TABLE chapters ADD COLUMN source_index INTEGER;

View File

@@ -104,12 +104,6 @@ pub async fn enqueue(pool: &PgPool, payload: &JobPayload) -> sqlx::Result<Enqueu
/// ///
/// `kind_filter` matches against `payload->>'kind'`; `None` means /// `kind_filter` matches against `payload->>'kind'`; `None` means
/// any kind. /// any kind.
///
/// Ties on `scheduled_at` (the common case: a cron batch enqueues
/// everything with the same default `now()`) break by `created_at`, so
/// jobs come off the queue in insertion order. The enqueue paths insert
/// chapter-content jobs in ascending `chapters.number` order, so this
/// tiebreaker is what propagates that intent through to dequeue.
pub async fn lease( pub async fn lease(
pool: &PgPool, pool: &PgPool,
kind_filter: Option<&str>, kind_filter: Option<&str>,
@@ -124,7 +118,7 @@ pub async fn lease(
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now())) WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
AND scheduled_at <= now() AND scheduled_at <= now()
AND ($1::text IS NULL OR payload->>'kind' = $1) AND ($1::text IS NULL OR payload->>'kind' = $1)
ORDER BY scheduled_at, created_at ORDER BY scheduled_at
LIMIT $2 LIMIT $2
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )

View File

@@ -429,8 +429,8 @@ pub async fn enqueue_bookmarked_pending(pool: &PgPool) -> anyhow::Result<Enqueue
AND cj.state = 'dead' AND cj.state = 'dead'
AND cj.updated_at > now() - ($1::bigint || ' days')::interval AND cj.updated_at > now() - ($1::bigint || ' days')::interval
) )
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.number, c.created_at GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.created_at
ORDER BY c.manga_id, c.number ASC, c.created_at ASC ORDER BY c.manga_id, c.created_at ASC
"#, "#,
) )
.bind(CHAPTER_DEAD_QUARANTINE_DAYS) .bind(CHAPTER_DEAD_QUARANTINE_DAYS)
@@ -471,7 +471,7 @@ pub async fn enqueue_pending_for_manga(
) -> anyhow::Result<EnqueueSummary> { ) -> anyhow::Result<EnqueueSummary> {
let rows: Vec<(String, Uuid, String)> = sqlx::query_as( let rows: Vec<(String, Uuid, String)> = sqlx::query_as(
r#" r#"
SELECT cs.source_id, c.id AS chapter_id, cs.source_chapter_key SELECT DISTINCT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
FROM chapters c FROM chapters c
JOIN chapter_sources cs ON cs.chapter_id = c.id JOIN chapter_sources cs ON cs.chapter_id = c.id
WHERE c.manga_id = $1 WHERE c.manga_id = $1
@@ -484,8 +484,7 @@ pub async fn enqueue_pending_for_manga(
AND cj.state = 'dead' AND cj.state = 'dead'
AND cj.updated_at > now() - ($2::bigint || ' days')::interval AND cj.updated_at > now() - ($2::bigint || ' days')::interval
) )
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.number, c.created_at ORDER BY cs.source_id, c.id
ORDER BY c.number ASC, c.created_at ASC, cs.source_id
"#, "#,
) )
.bind(manga_id) .bind(manga_id)

View File

@@ -12,20 +12,15 @@ pub async fn list_for_manga(
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> AppResult<Vec<Chapter>> { ) -> AppResult<Vec<Chapter>> {
// Display order = source-site order reversed. The crawler stamps // Secondary sort by created_at gives duplicate-numbered chapters
// `source_index` = position in the source DOM (0 = first = newest // (multiple uploaders/translations of the same number) a stable
// on this site, see migration 0021), so DESC puts the oldest // order in lists and prev/next reader navigation.
// chapter first and keeps the site's variant grouping and the
// placement of non-numeric entries (e.g. "notice. : Officials")
// intact. NULLS LAST keeps user-uploaded chapters (no source row)
// and rows that pre-date the migration below crawled rows; the
// (number, created_at) tail then orders them deterministically.
let rows = sqlx::query_as::<_, Chapter>( let rows = sqlx::query_as::<_, Chapter>(
r#" r#"
SELECT id, manga_id, number, title, page_count, created_at SELECT id, manga_id, number, title, page_count, created_at
FROM chapters FROM chapters
WHERE manga_id = $1 WHERE manga_id = $1
ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC ORDER BY number ASC, created_at ASC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
"#, "#,
) )

View File

@@ -352,14 +352,7 @@ pub async fn sync_manga_chapters(
.map(|c| c.source_chapter_key.clone()) .map(|c| c.source_chapter_key.clone())
.collect(); .collect();
for (idx, c) in chapters.iter().enumerate() { for c in chapters {
// `source_index` captures the chapter's position in the source
// DOM (0 = first = newest on this site) so the list query can
// reverse it for the user-facing list — see migration 0021.
// Every sync overwrites the value on both branches, so a new
// chapter inserted at the top of the source shifts every other
// row down by one on the next tick.
let source_index = idx as i32;
// Lookup is constrained by manga_id (via the chapters join) so a // Lookup is constrained by manga_id (via the chapters join) so a
// source whose chapter slugs collide across mangas (e.g. // source whose chapter slugs collide across mangas (e.g.
// "chapter-1" appearing under two different mangas) attributes // "chapter-1" appearing under two different mangas) attributes
@@ -389,15 +382,14 @@ pub async fn sync_manga_chapters(
// identity is the UUID, not the number. // identity is the UUID, not the number.
let (chapter_id,): (Uuid,) = sqlx::query_as( let (chapter_id,): (Uuid,) = sqlx::query_as(
r#" r#"
INSERT INTO chapters (manga_id, number, title, page_count, source_index) INSERT INTO chapters (manga_id, number, title, page_count)
VALUES ($1, $2, $3, 0, $4) VALUES ($1, $2, $3, 0)
RETURNING id RETURNING id
"#, "#,
) )
.bind(manga_id) .bind(manga_id)
.bind(c.number) .bind(c.number)
.bind(c.title.as_deref()) .bind(c.title.as_deref())
.bind(source_index)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
sqlx::query( sqlx::query(
@@ -416,11 +408,8 @@ pub async fn sync_manga_chapters(
diff.new += 1; diff.new += 1;
} }
Some((chapter_id,)) => { Some((chapter_id,)) => {
sqlx::query( sqlx::query("UPDATE chapters SET title = $1 WHERE id = $2")
"UPDATE chapters SET title = $1, source_index = $2 WHERE id = $3",
)
.bind(c.title.as_deref()) .bind(c.title.as_deref())
.bind(source_index)
.bind(chapter_id) .bind(chapter_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;

View File

@@ -517,132 +517,3 @@ async fn enqueue_bookmarked_pending_resumes_after_quarantine_expires(pool: PgPoo
); );
} }
/// Helper: insert a chapter with the given `number` and a non-dropped
/// source row, returning the chapter id. Used by the ordering tests so
/// the setup boilerplate doesn't drown the assertion.
async fn insert_pending_chapter(
pool: &PgPool,
manga_id: Uuid,
number: i32,
source_chapter_key: &str,
) -> Uuid {
let chapter_id: Uuid = sqlx::query_scalar(
"INSERT INTO chapters (manga_id, number, page_count) VALUES ($1, $2, 0) RETURNING id",
)
.bind(manga_id)
.bind(number)
.fetch_one(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url) \
VALUES ($1, $2, $3, $4)",
)
.bind("target")
.bind(source_chapter_key)
.bind(chapter_id)
.bind(format!("https://example.com/{source_chapter_key}"))
.execute(pool)
.await
.unwrap();
chapter_id
}
#[sqlx::test(migrations = "./migrations")]
async fn enqueue_bookmarked_pending_queues_chapters_in_ascending_number_order(pool: PgPool) {
// Insert chapters with `number` values 3, 1, 2 in that insertion
// order — so `created_at` order (the previous tiebreaker) does NOT
// match number order. After enqueue + lease, the worker should see
// chapters 1, 2, 3 in that sequence.
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id",
)
.bind("alice")
.bind("not-a-real-hash")
.fetch_one(&pool)
.await
.unwrap();
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind("Test")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
)
.bind("target")
.bind("Target")
.bind("https://example.com")
.execute(&pool)
.await
.unwrap();
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
sqlx::query("INSERT INTO bookmarks (user_id, manga_id) VALUES ($1, $2)")
.bind(user_id)
.bind(manga_id)
.execute(&pool)
.await
.unwrap();
let summary = pipeline::enqueue_bookmarked_pending(&pool).await.unwrap();
assert_eq!(summary.inserted, 3);
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
.await
.unwrap();
let leased_chapter_ids: Vec<Uuid> = leases
.iter()
.map(|l| match &l.payload {
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
other => panic!("unexpected payload kind: {other:?}"),
})
.collect();
assert_eq!(
leased_chapter_ids,
vec![c1, c2, c3],
"chapters must be leased in ascending chapter-number order, not insertion order"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn enqueue_pending_for_manga_queues_chapters_in_ascending_number_order(pool: PgPool) {
// Same scenario as above but exercising the bookmark-create hook path
// (`enqueue_pending_for_manga`) which has its own ORDER BY.
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind("Test")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
)
.bind("target")
.bind("Target")
.bind("https://example.com")
.execute(&pool)
.await
.unwrap();
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
let summary = pipeline::enqueue_pending_for_manga(&pool, manga_id)
.await
.unwrap();
assert_eq!(summary.inserted, 3);
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
.await
.unwrap();
let leased_chapter_ids: Vec<Uuid> = leases
.iter()
.map(|l| match &l.payload {
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
other => panic!("unexpected payload kind: {other:?}"),
})
.collect();
assert_eq!(leased_chapter_ids, vec![c1, c2, c3]);
}

View File

@@ -531,89 +531,6 @@ async fn reap_done_deletes_old_rows_keeps_fresh(pool: PgPool) {
assert_eq!(remaining, vec![fresh_id], "only fresh row remains"); assert_eq!(remaining, vec![fresh_id], "only fresh row remains");
} }
#[sqlx::test(migrations = "./migrations")]
async fn lease_ties_on_scheduled_at_break_by_created_at(pool: PgPool) {
// Locks in the tiebreaker that lets enqueue order survive the lease
// step: when many jobs share `scheduled_at` (the common cron-batch
// case), the worker must pick the earliest-inserted row, not whatever
// Postgres returns in heap order. The enqueue path inserts chapters
// in chapter-number order, so this tiebreaker is what makes "queue
// in rising order" observable at the dequeue side too.
let a = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
let b = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
let c = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
.await
.unwrap()
{
EnqueueResult::Inserted(id) => id,
_ => unreachable!(),
};
// Pin `scheduled_at` to a single literal instant (shared across all
// three rows — `now()` would yield a different microsecond per UPDATE
// and make scheduled_at the actual sort key). Reverse `created_at`
// against insertion order so heap order would give the wrong answer.
let shared_scheduled = chrono::Utc::now() - chrono::Duration::hours(1);
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(a)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(10))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(b)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(20))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE crawler_jobs \
SET scheduled_at = $2, \
created_at = $3 \
WHERE id = $1",
)
.bind(c)
.bind(shared_scheduled)
.bind(chrono::Utc::now() - chrono::Duration::seconds(30))
.execute(&pool)
.await
.unwrap();
let leases = jobs::lease(&pool, None, 10, Duration::from_secs(60))
.await
.unwrap();
let order: Vec<Uuid> = leases.iter().map(|l| l.id).collect();
assert_eq!(
order,
vec![c, b, a],
"lease must return jobs in created_at order when scheduled_at ties"
);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn reap_done_zero_is_a_no_op(pool: PgPool) { async fn reap_done_zero_is_a_no_op(pool: PgPool) {
let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4())) let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))

View File

@@ -6,7 +6,6 @@
use mangalord::crawler::source::{SourceChapterRef, SourceManga}; use mangalord::crawler::source::{SourceChapterRef, SourceManga};
use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus}; use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus};
use mangalord::repo::chapter as chapter_repo;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -962,261 +961,3 @@ async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
assert!(dropped.0.is_none()); assert!(dropped.0.is_none());
assert_eq!(dropped.1, up.manga_id); assert_eq!(dropped.1, up.manga_id);
} }
// ---- source_index: site-order preservation ----
//
// The user-facing chapter list reverses the source-site order so that
// the oldest chapter appears first. The crawler records each row's DOM
// position in `chapters.source_index` (0 = first in source DOM = newest
// on this site) on every sync; the list query orders by source_index
// DESC NULLS LAST, falling through to number/created_at for rows with
// no source row (e.g. user uploads).
#[sqlx::test(migrations = "./migrations")]
async fn source_index_set_on_insert_matches_dom_order(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
let chapters = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 30,
title: Some("Ch.30".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 29,
title: Some("Ch.29".into()),
url: "https://x.example/foo/b".into(),
},
SourceChapterRef {
source_chapter_key: "c".into(),
number: 28,
title: Some("Ch.28".into()),
url: "https://x.example/foo/c".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
.await
.unwrap();
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
"SELECT cs.source_chapter_key, c.source_index \
FROM chapters c \
JOIN chapter_sources cs ON cs.chapter_id = c.id \
WHERE c.manga_id = $1 \
ORDER BY cs.source_chapter_key",
)
.bind(up.manga_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
rows,
vec![
("a".to_string(), Some(0)),
("b".to_string(), Some(1)),
("c".to_string(), Some(2)),
],
"source_index reflects enumerate() position in the input slice",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn source_index_rewritten_on_resync_when_new_chapter_prepended(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
let first = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 2,
title: Some("Ch.2".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &first)
.await
.unwrap();
// Second sync: a brand-new chapter appears at the top of the source
// (newest first on the site). All existing rows must shift their
// source_index down by one so the display order stays correct.
let second = vec![
SourceChapterRef {
source_chapter_key: "new".into(),
number: 3,
title: Some("Ch.3".into()),
url: "https://x.example/foo/new".into(),
},
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 2,
title: Some("Ch.2".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &second)
.await
.unwrap();
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
"SELECT cs.source_chapter_key, c.source_index \
FROM chapters c \
JOIN chapter_sources cs ON cs.chapter_id = c.id \
WHERE c.manga_id = $1 \
ORDER BY cs.source_chapter_key",
)
.bind(up.manga_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
rows,
vec![
("a".to_string(), Some(1)),
("b".to_string(), Some(2)),
("new".to_string(), Some(0)),
],
"new chapter takes index 0, existing rows shift down on UPDATE",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_for_manga_returns_source_order_reversed(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
// Site DOM order (top-down = newest-first):
// ch11 (number = 11)
// notice (number = 0, non-numeric label on the site)
// ch10 (number = 10)
// Numbers deliberately disagree with DOM order: a number-based sort
// would put notice first, but the site places it between ch10 and
// ch11. Reversed-DOM display should yield [ch10, notice, ch11].
let chapters = vec![
SourceChapterRef {
source_chapter_key: "ch11".into(),
number: 11,
title: Some("Ch.11 : Official".into()),
url: "https://x.example/foo/11".into(),
},
SourceChapterRef {
source_chapter_key: "notice".into(),
number: 0,
title: Some("notice. : Officials".into()),
url: "https://x.example/foo/notice".into(),
},
SourceChapterRef {
source_chapter_key: "ch10".into(),
number: 10,
title: Some("Ch.10 : Official".into()),
url: "https://x.example/foo/10".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
.await
.unwrap();
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
.await
.unwrap();
let keys: Vec<String> = listed
.iter()
.map(|c| c.title.clone().unwrap_or_default())
.collect();
assert_eq!(
keys,
vec![
"Ch.10 : Official".to_string(),
"notice. : Officials".to_string(),
"Ch.11 : Official".to_string(),
],
"list returns chapters in reversed source-DOM order, so the \
oldest appears first and non-numeric entries land where the \
site placed them",
);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
crawler::ensure_source(&pool, "target", "T", "https://x.example")
.await
.unwrap();
let m = sample_manga("foo", "Foo Manga", "hash-1");
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
.await
.unwrap();
// Crawled chapters get source_index 0 and 1; the upload path leaves
// it NULL. NULLS LAST plus the (number, created_at) tail means the
// upload sits after both crawled rows even though its number is in
// the middle.
let crawled = vec![
SourceChapterRef {
source_chapter_key: "a".into(),
number: 1,
title: Some("Ch.1".into()),
url: "https://x.example/foo/a".into(),
},
SourceChapterRef {
source_chapter_key: "b".into(),
number: 3,
title: Some("Ch.3".into()),
url: "https://x.example/foo/b".into(),
},
];
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &crawled)
.await
.unwrap();
chapter_repo::create(&pool, up.manga_id, 2, Some("User upload Ch.2"), None)
.await
.unwrap();
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
.await
.unwrap();
let titles: Vec<String> = listed
.iter()
.map(|c| c.title.clone().unwrap_or_default())
.collect();
assert_eq!(
titles,
vec![
"Ch.3".to_string(),
"Ch.1".to_string(),
"User upload Ch.2".to_string(),
],
"crawled rows ordered by reversed source_index; user upload \
(NULL source_index) falls through to the end",
);
}

View File

@@ -1,167 +0,0 @@
import { test, expect, type Page } from '@playwright/test';
const mangaId = '33333333-3333-3333-3333-333333333333';
const chapter1Id = 'c1111111-3333-3333-3333-333333333333';
const chapter2Id = 'c2222222-3333-3333-3333-333333333333';
const chapter3Id = 'c3333333-3333-3333-3333-333333333333';
const mangaFixture = {
id: mangaId,
title: 'Vinland Saga',
author: 'Makoto Yukimura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
const chaptersFixture = [
{
id: chapter1Id,
manga_id: mangaId,
number: 1,
title: 'Somewhere, Not Here',
page_count: 1,
created_at: '2026-01-01T00:00:00Z'
},
{
id: chapter2Id,
manga_id: mangaId,
number: 2,
title: null,
page_count: 1,
created_at: '2026-01-02T00:00:00Z'
},
{
id: chapter3Id,
manga_id: mangaId,
number: 3,
title: 'Sword Dance',
page_count: 1,
created_at: '2026-01-03T00:00:00Z'
}
];
function pageFixture(chapterId: string) {
return [
{
id: `p1111111-${chapterId.slice(1, 8)}-3333-3333-333333333333`,
chapter_id: chapterId,
page_number: 1,
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
content_type: 'image/png'
}
];
}
async function mockReaderApis(page: Page) {
// Force public mode so the layout doesn't bounce anonymous visitors
// to /login (the dev backend on this machine runs with
// PRIVATE_MODE=true, which the layout's universal load respects).
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route(new RegExp(`/api/v1/mangas/${mangaId}/chapters(\\?.*)?$`), (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: chaptersFixture,
page: { limit: 200, offset: 0, total: chaptersFixture.length }
})
})
);
for (const c of chaptersFixture) {
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${c.id}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(c)
})
);
await page.route(
`**/api/v1/mangas/${mangaId}/chapters/${c.id}/pages`,
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pages: pageFixture(c.id) })
})
);
}
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
);
}
test('reader chapter select lists every chapter with the manga-detail-style label', async ({
page
}) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapter2Id}`);
const select = page.getByTestId('reader-chapter-select');
await expect(select).toBeVisible();
// The current chapter is preselected.
await expect(select).toHaveValue(chapter2Id);
// Each chapter rendered as "Ch. N — Title" (or "Ch. N" when title is null),
// in ascending number order — matching the prev/next sort.
const labels = await select.locator('option').allTextContents();
expect(labels.map((l) => l.trim())).toEqual([
'Ch. 1 — Somewhere, Not Here',
'Ch. 2',
'Ch. 3 — Sword Dance'
]);
});
test('choosing a chapter from the select navigates to that chapter', async ({ page }) => {
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapter1Id}`);
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter1Id);
await page.getByTestId('reader-chapter-select').selectOption(chapter3Id);
await expect(page).toHaveURL(
new RegExp(`/manga/${mangaId}/chapter/${chapter3Id}$`)
);
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter3Id);
});

View File

@@ -120,7 +120,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) =
await expect(page.getByTestId('manga-title')).toHaveText('Berserk'); await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura'); await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
await expect(page.getByTestId('manga-cover')).toBeVisible(); await expect(page.getByTestId('manga-cover')).toBeVisible();
await expect(page.getByTestId('chapter-list')).toContainText('The Brand'); await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1');
await expect(page.getByTestId('bookmark-signin')).toBeVisible(); await expect(page.getByTestId('bookmark-signin')).toBeVisible();
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.52.0", "version": "0.50.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -11,8 +11,7 @@ import {
listChapters, listChapters,
getChapter, getChapter,
getChapterPages, getChapterPages,
createChapter, createChapter
chapterLabel
} from './chapters'; } from './chapters';
function ok(body: unknown): Response { function ok(body: unknown): Response {
@@ -130,18 +129,6 @@ describe('chapters api client', () => {
} }
}); });
describe('chapterLabel', () => {
it('returns the site title verbatim when present', () => {
expect(chapterLabel({ number: 7, title: 'Ch.7 : Official' })).toBe(
'Ch.7 : Official'
);
});
it('falls back to "Chapter {number}" when title is null', () => {
expect(chapterLabel({ number: 3, title: null })).toBe('Chapter 3');
});
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => { it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
ok({ ok({

View File

@@ -14,10 +14,6 @@ export type ChaptersPage = {
page: Page; page: Page;
}; };
export function chapterLabel(c: Pick<Chapter, 'number' | 'title'>): string {
return c.title ?? `Chapter ${c.number}`;
}
export type ListOptions = { export type ListOptions = {
limit?: number; limit?: number;
offset?: number; offset?: number;

View File

@@ -10,7 +10,6 @@
type TagRef type TagRef
} from '$lib/api/mangas'; } from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin'; import { resyncManga } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { listTags, type Tag } from '$lib/api/tags'; import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte'; import Chip from '$lib/components/Chip.svelte';
@@ -46,11 +45,6 @@
continueChapter?.number ?? readProgress?.chapter_number ?? null continueChapter?.number ?? readProgress?.chapter_number ?? null
); );
const continueChapterTitle = $derived(continueChapter?.title ?? null); const continueChapterTitle = $derived(continueChapter?.title ?? null);
const continueLabel = $derived(
continueChapterNumber != null
? chapterLabel({ number: continueChapterNumber, title: continueChapterTitle })
: null
);
const authors = $derived<AuthorRef[]>(manga.authors); const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres); const genres = $derived<GenreRef[]>(manga.genres);
@@ -437,7 +431,7 @@
> >
<span class="continue-label">Continue reading</span> <span class="continue-label">Continue reading</span>
<span class="continue-target"> <span class="continue-target">
{continueLabel} Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if}
{#if readProgress && readProgress.page > 1} {#if readProgress && readProgress.page > 1}
— page {readProgress.page} — page {readProgress.page}
{/if} {/if}
@@ -451,7 +445,7 @@
{#each chapters as c (c.id)} {#each chapters as c (c.id)}
<li> <li>
<a href="/manga/{manga.id}/chapter/{c.id}"> <a href="/manga/{manga.id}/chapter/{c.id}">
{chapterLabel(c)} Chapter {c.number}{#if c.title}: {c.title}{/if}
</a> </a>
<span class="pages">({c.page_count} pages)</span> <span class="pages">({c.page_count} pages)</span>
</li> </li>

View File

@@ -5,7 +5,6 @@
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress'; import { updateReadProgress } from '$lib/api/read_progress';
import { chapterLabel } from '$lib/api/chapters';
import { resyncChapter } from '$lib/api/admin'; import { resyncChapter } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte'; import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
@@ -29,25 +28,28 @@
const gapPx = $derived(GAP_PX[preferences.readerPageGap]); const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
const pageTitle = $derived( const pageTitle = $derived(
`Mangalord | ${manga.title} · ${chapterLabel(chapter)}` chapter.title
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
: `Mangalord | ${manga.title} · Ch. ${chapter.number}`
); );
// Prev/next chapter computed from the chapter list. listChapters // Prev/next chapter computed from the chapter list. listChapters
// returns chapters in display order (reversed source-site order, so // returns chapters in number ASC order; we still resolve via find
// oldest first — see backend repo::chapter::list_for_manga), and // rather than index because the current chapter's position may
// prev/next walks that order positionally. Resolving the current // not be `chapter.number - 1` (sparse numbering / chapter 0.5 /
// index via `find` rather than `chapter.number - 1` matters because // future skipped numbers).
// numbers aren't a reliable index: variants share numbers, non- const sortedChapters = $derived(
// numeric entries pin to 0, and uploads can sparse-fill. [...chapters].sort((a, b) => a.number - b.number)
);
const currentIdx = $derived( const currentIdx = $derived(
chapters.findIndex((c) => c.id === chapter.id) sortedChapters.findIndex((c) => c.id === chapter.id)
); );
const prevChapter = $derived( const prevChapter = $derived(
currentIdx > 0 ? chapters[currentIdx - 1] : null currentIdx > 0 ? sortedChapters[currentIdx - 1] : null
); );
const nextChapter = $derived( const nextChapter = $derived(
currentIdx >= 0 && currentIdx < chapters.length - 1 currentIdx >= 0 && currentIdx < sortedChapters.length - 1
? chapters[currentIdx + 1] ? sortedChapters[currentIdx + 1]
: null : null
); );
@@ -457,27 +459,6 @@
</a> </a>
<div class="controls" role="group" aria-label="reader options"> <div class="controls" role="group" aria-label="reader options">
<label class="chapter-field">
<span class="visually-hidden">Jump to chapter</span>
<select
class="chapter-select"
value={chapter.id}
onchange={(e) => {
const target = (e.currentTarget as HTMLSelectElement).value;
if (target && target !== chapter.id) {
void goto(`/manga/${manga.id}/chapter/${target}`);
}
}}
data-testid="reader-chapter-select"
>
{#each chapters as c (c.id)}
<option value={c.id}>
{chapterLabel(c)}
</option>
{/each}
</select>
</label>
<div class="mode-toggle" role="radiogroup" aria-label="layout"> <div class="mode-toggle" role="radiogroup" aria-label="layout">
<button <button
type="button" type="button"
@@ -683,7 +664,7 @@
</span> </span>
</button> </button>
<span class="chapter-bar-current" aria-hidden="true"> <span class="chapter-bar-current" aria-hidden="true">
{chapterLabel(chapter)} Ch. {chapter.number}{#if chapter.title}{chapter.title}{/if}
</span> </span>
<button <button
type="button" type="button"
@@ -820,8 +801,7 @@
outline-offset: -2px; outline-offset: -2px;
} }
.gap-field select, .gap-field select {
.chapter-select {
height: 32px; height: 32px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
background: var(--surface); background: var(--surface);
@@ -831,13 +811,6 @@
font-size: var(--font-sm); font-size: var(--font-sm);
} }
/* Cap the chapter dropdown's resting width so long titles don't
push the rest of the nav off-screen; the native control's
expanded menu still shows full option text on focus. */
.chapter-select {
max-width: 16rem;
}
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fileUrl } from '$lib/api/client'; import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress'; import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image'; import BookImage from '@lucide/svelte/icons/book-image';
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
@@ -187,7 +186,7 @@
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a> <a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
<span class="target"> <span class="target">
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}"> <a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
{chapterLabel(u.chapter)} Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
</a> </a>
<span class="muted">({u.chapter.page_count} pages)</span> <span class="muted">({u.chapter.page_count} pages)</span>
</span> </span>