feat: route reader by chapter id, allow duplicate-numbered chapters (0.24.0)
Real-world sources publish multiple chapters at the same number:
different scanlators ("Ch.52 from bloomingdale" + "Ch.52 from mina"),
translator notices and farewells, alt-translations. The (manga_id,
number) UNIQUE constraint from 0001 silently collapsed all of those
into a single row via the upsert path in repo::crawler. Migration 0013
drops the constraint; sync_manga_chapters now plain-INSERTs each
SourceChapterRef so every parsed chapter survives as its own row.
Identity moves from the (manga_id, number) tuple to the chapter UUID:
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id` (replaces :number)
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id/pages`
- `repo::chapter::find_by_id_in_manga` (replaces find_by_manga_and_number)
- Frontend reader route renamed to `/manga/[id]/chapter/[chapter_id]`
- Chapter links throughout (manga page list, continue-reading CTA,
reader prev/next, history rows, bookmark cards) use chapter.id
- API clients getChapter/getChapterPages take a chapter id string
read_progress + bookmarks already FK chapter_id; they only enrich with
chapter_number for display, which is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,82 @@ async fn sync_chapters_adds_new_refreshes_existing_and_drops_vanished(pool: PgPo
|
||||
assert!(dropped.0.is_some(), "ch2 should be soft-dropped");
|
||||
}
|
||||
|
||||
/// Real-world sources publish multiple chapters at the same number
|
||||
/// (different uploaders, translator notes, re-releases). After the
|
||||
/// (manga_id, number) UNIQUE drop in 0013, each `SourceChapterRef`
|
||||
/// becomes its own `chapters` row even when the parsed number matches
|
||||
/// — chapter identity is now the chapter id, not the number.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn sync_chapters_keeps_duplicate_numbered_chapters_as_separate_rows(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();
|
||||
|
||||
// Two distinct uploads of Ch.52 (different uploaders → different
|
||||
// URLs/keys, same parsed number) plus a notice/hiatus row that
|
||||
// parses to number=0 alongside a real chapter at number 1.
|
||||
let chapters = vec![
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "br_chapter-A".into(),
|
||||
number: 52,
|
||||
title: Some("Ch.52 : Official".into()),
|
||||
url: "https://x.example/foo/A/pg-1/".into(),
|
||||
},
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "br_chapter-B".into(),
|
||||
number: 52,
|
||||
title: Some("Ch.52 : Official (alt)".into()),
|
||||
url: "https://x.example/foo/B/pg-1/".into(),
|
||||
},
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "br_chapter-NOTICE".into(),
|
||||
number: 0,
|
||||
title: Some("hitaus.".into()),
|
||||
url: "https://x.example/foo/notice/pg-1/".into(),
|
||||
},
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "br_chapter-1".into(),
|
||||
number: 1,
|
||||
title: Some("Ch.1 : Official".into()),
|
||||
url: "https://x.example/foo/1/pg-1/".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let diff = crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
diff,
|
||||
ChapterDiff {
|
||||
new: 4,
|
||||
refreshed: 0,
|
||||
dropped: 0
|
||||
},
|
||||
"every source ref yields a new chapter row"
|
||||
);
|
||||
|
||||
let rows: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM chapters WHERE manga_id = $1")
|
||||
.bind(up.manga_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.0, 4, "4 distinct chapter rows even with duplicate numbers");
|
||||
|
||||
let ch52_count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM chapters WHERE manga_id = $1 AND number = 52",
|
||||
)
|
||||
.bind(up.manga_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ch52_count.0, 2, "both Ch.52 uploads survive as separate rows");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn mark_dropped_mangas_only_drops_unseen(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
|
||||
Reference in New Issue
Block a user