diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a8a0910..ebbec68 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.21" +version = "0.124.22" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 66fb869..b5f3f5b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.21" +version = "0.124.22" edition = "2021" default-run = "mangalord" diff --git a/backend/src/repo/chapter.rs b/backend/src/repo/chapter.rs index 0d80740..6d78b87 100644 --- a/backend/src/repo/chapter.rs +++ b/backend/src/repo/chapter.rs @@ -12,14 +12,19 @@ pub async fn list_for_manga( limit: i64, offset: i64, ) -> AppResult> { - // Display order = source-site order reversed. The crawler stamps - // `source_index` = position in the source DOM (0 = first = newest - // on this site, see migration 0021), so DESC puts the oldest - // 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. + // Display order. Crawled chapters carry `source_index` = position in the + // source DOM (0 = newest on this site, migration 0021); we display them + // reversed (oldest first) via the `-source_index` key, which keeps the + // site's variant grouping and non-numeric entries (e.g. "notice.") in the + // spot the site placed them — NOT clustered at number 0. + // + // User-uploaded chapters have no `source_index`. Instead of dumping them + // after every crawled chapter (the old `NULLS LAST` bug, which put an + // uploaded chapter 5 after crawled chapter 100), each is slotted by NUMBER: + // just before the crawled chapter with the smallest number greater than it, + // so it interleaves. An upload newer than every crawled chapter goes last; + // when there are no crawled chapters at all, uploads fall back to the + // `number, created_at` tail. let rows = sqlx::query_as::<_, Chapter>( r#" SELECT id, manga_id, number, title, page_count, created_at, @@ -31,7 +36,22 @@ pub async fn list_for_manga( FROM pages p WHERE p.chapter_id = chapters.id)::bigint AS size_bytes FROM chapters WHERE manga_id = $1 - ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC + ORDER BY + CASE + WHEN source_index IS NOT NULL THEN (-source_index)::float8 + ELSE COALESCE( + (SELECT MIN(-c2.source_index)::float8 - 0.5 + FROM chapters c2 + WHERE c2.manga_id = chapters.manga_id + AND c2.source_index IS NOT NULL + AND c2.number > chapters.number), + (SELECT COALESCE(MAX(-c3.source_index), 0)::float8 + 0.5 + FROM chapters c3 + WHERE c3.manga_id = chapters.manga_id + AND c3.source_index IS NOT NULL) + ) + END, + number ASC, created_at ASC LIMIT $2 OFFSET $3 "#, ) diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index 6267b8e..e0c60ec 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -148,6 +148,49 @@ async fn list_chapters_returned_in_number_order(pool: PgPool) { assert_eq!(body["items"][1]["title"], serde_json::Value::Null); } +#[sqlx::test(migrations = "./migrations")] +async fn list_interleaves_uploaded_and_crawled_chapters_by_number(pool: PgPool) { + // Mixed source: crawled chapters carry a `source_index`; an uploaded + // chapter has none. The uploaded chapter must interleave by NUMBER, not sort + // after every crawled chapter (the old `source_index DESC NULLS LAST` bug + // dumped uploaded chapter 2 to the end, giving [1, 3, 2]). + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = seed_manga(&h, &cookie, "Berserk").await; + + // Two crawled chapters (1 and 3) with DOM positions — newest-first, so + // chapter 3 is at source_index 0 and chapter 1 at source_index 1. + let c1 = seed_chapter(&pool, manga_id, 1, Some("Crawled One")).await; + let c3 = seed_chapter(&pool, manga_id, 3, Some("Crawled Three")).await; + sqlx::query("UPDATE chapters SET source_index = 1 WHERE id = $1") + .bind(c1) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE chapters SET source_index = 0 WHERE id = $1") + .bind(c3) + .execute(&pool) + .await + .unwrap(); + // Uploaded chapter 2 — no source_index. + seed_chapter(&pool, manga_id, 2, Some("Uploaded Two")).await; + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let numbers: Vec = body["items"] + .as_array() + .unwrap() + .iter() + .map(|c| c["number"].as_i64().unwrap()) + .collect(); + assert_eq!(numbers, vec![1, 2, 3], "uploaded chapter 2 must slot between 1 and 3"); +} + #[sqlx::test(migrations = "./migrations")] async fn list_chapters_returns_404_for_unknown_manga(pool: PgPool) { let h = common::harness(pool); diff --git a/backend/tests/crawler_sync.rs b/backend/tests/crawler_sync.rs index f7fbfa5..f57b249 100644 --- a/backend/tests/crawler_sync.rs +++ b/backend/tests/crawler_sync.rs @@ -1175,7 +1175,7 @@ async fn list_for_manga_returns_source_order_reversed(pool: PgPool) { } #[sqlx::test(migrations = "./migrations")] -async fn list_for_manga_places_null_source_index_last(pool: PgPool) { +async fn list_for_manga_interleaves_null_source_index_by_number(pool: PgPool) { crawler::ensure_source(&pool, "target", "T", "https://x.example") .await .unwrap(); @@ -1184,23 +1184,24 @@ async fn list_for_manga_places_null_source_index_last(pool: PgPool) { .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. + // Crawled chapters, newest-first in the source DOM (so chapter 3 is at + // source_index 0 and chapter 1 at source_index 1). Reversed for display that + // is [Ch.1, Ch.3]. The uploaded chapter 2 must slot BETWEEN them by number, + // not fall to the end — that was the bug (NULLS LAST dumped it last + // regardless of its number). 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(), }, + SourceChapterRef { + source_chapter_key: "a".into(), + number: 1, + title: Some("Ch.1".into()), + url: "https://x.example/foo/a".into(), + }, ]; crawler::sync_manga_chapters(&pool, "target", up.manga_id, &crawled) .await @@ -1220,11 +1221,11 @@ async fn list_for_manga_places_null_source_index_last(pool: PgPool) { assert_eq!( titles, vec![ - "Ch.3".to_string(), "Ch.1".to_string(), "User upload Ch.2".to_string(), + "Ch.3".to_string(), ], - "crawled rows ordered by reversed source_index; user upload \ - (NULL source_index) falls through to the end", + "uploaded chapter (NULL source_index) interleaves by number between the \ + crawled rows instead of falling to the end", ); } diff --git a/frontend/package.json b/frontend/package.json index c6d614c..fcc8bb8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.21", + "version": "0.124.22", "private": true, "type": "module", "scripts": {