fix(history): count distinct new chapter numbers, authoritative on detail
new_chapters_count over-counted when scanlations share a chapter number ((manga_id, number) is non-unique, migration 0013): COUNT(*) tallied rows, so duplicate-numbered chapters inflated the badge. Switch both the list and per-manga read-progress queries to COUNT(DISTINCT number), and drop the dead COALESCE (an empty set counts as 0, and the NULL-number case is handled by the predicate). Fix the misleading comment. Expose new_chapters_count on GET /me/read-progress/:manga_id and drive the detail page's "N new" badge from it, instead of recomputing over the paginated chapter list — which under-counted series past 50 chapters and disagreed with the homepage shelf. Read markers stay client-side over the loaded chapters and are documented as by-number (all we persist). Adds duplicate-scanlation tests at both endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.102.0"
|
||||
version = "0.103.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.102.0"
|
||||
version = "0.103.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ pub struct ReadProgressForManga {
|
||||
pub chapter_number: Option<i32>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Distinct chapter numbers past the last-read chapter — the detail
|
||||
/// page's authoritative "new since last read" count (computed over all
|
||||
/// chapters, not the client's paginated list). `0` when caught up or
|
||||
/// the position is unknown. See [`ReadProgressSummary::new_chapters_count`].
|
||||
pub new_chapters_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -80,7 +80,17 @@ pub async fn get_for_manga(
|
||||
rp.chapter_id,
|
||||
c.number AS chapter_number,
|
||||
rp.page,
|
||||
rp.updated_at
|
||||
rp.updated_at,
|
||||
-- Distinct chapter numbers past the reader's last-read chapter
|
||||
-- (see list_for_user for the non-unique-number rationale). 0
|
||||
-- when the last-read chapter is unknown.
|
||||
(
|
||||
SELECT count(DISTINCT c2.number)
|
||||
FROM chapters c2
|
||||
WHERE c2.manga_id = rp.manga_id
|
||||
AND c.number IS NOT NULL
|
||||
AND c2.number > c.number
|
||||
) AS new_chapters_count
|
||||
FROM read_progress rp
|
||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||
WHERE rp.user_id = $1 AND rp.manga_id = $2
|
||||
@@ -133,20 +143,20 @@ pub async fn list_for_user(
|
||||
c.number AS chapter_number,
|
||||
rp.page,
|
||||
rp.updated_at,
|
||||
-- Personal "new since last read": chapters numbered past the
|
||||
-- reader's last-read chapter. `c.number` is NULL for
|
||||
-- manga-level progress or a deleted chapter, in which case
|
||||
-- the guard yields 0 rather than counting every chapter.
|
||||
-- (manga_id, number) is non-unique — translations share a
|
||||
-- number — so this counts positions past the reader, not
|
||||
-- distinct files.
|
||||
COALESCE((
|
||||
SELECT count(*)
|
||||
-- Personal "new since last read": how many distinct chapter
|
||||
-- numbers sit past the reader's last-read chapter.
|
||||
-- COUNT(DISTINCT number) — not COUNT(*) — because
|
||||
-- (manga_id, number) is non-unique (scanlations share a
|
||||
-- number, migration 0013), so raw rows would over-count. When
|
||||
-- `c.number` is NULL (manga-level progress or a deleted
|
||||
-- chapter) the predicate matches no rows, yielding 0.
|
||||
(
|
||||
SELECT count(DISTINCT c2.number)
|
||||
FROM chapters c2
|
||||
WHERE c2.manga_id = rp.manga_id
|
||||
AND c.number IS NOT NULL
|
||||
AND c2.number > c.number
|
||||
), 0) AS new_chapters_count
|
||||
) AS new_chapters_count
|
||||
FROM read_progress rp
|
||||
JOIN mangas m ON m.id = rp.manga_id
|
||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||
|
||||
@@ -242,6 +242,95 @@ async fn list_new_chapters_count_is_zero_without_a_read_chapter(pool: PgPool) {
|
||||
assert_eq!(body["items"][0]["new_chapters_count"], 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_new_chapters_count_dedupes_same_numbered_scanlations(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
// (manga_id, number) is non-unique — two scanlations of chapter 2.
|
||||
// "New since last read" should count distinct chapter numbers past the
|
||||
// reader (2, 3 → 2), not raw rows (2, 2, 3 → 3).
|
||||
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
|
||||
let _ = upsert_progress(
|
||||
&h.app,
|
||||
&cookie,
|
||||
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"][0]["new_chapters_count"], 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_single_manga_reports_new_chapters_count(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
||||
// Duplicate scanlation of chapter 3 must not inflate the count.
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
|
||||
let _ = upsert_progress(
|
||||
&h.app,
|
||||
&cookie,
|
||||
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/me/read-progress/{manga_id}"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Distinct numbers past chapter 1: {2, 3} → 2.
|
||||
assert_eq!(body["new_chapters_count"], 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_single_manga_new_chapters_count_zero_without_read_chapter(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
|
||||
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
|
||||
// Manga-level progress (no chapter) → position unknown → 0.
|
||||
let _ = upsert_progress(
|
||||
&h.app,
|
||||
&cookie,
|
||||
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/me/read-progress/{manga_id}"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["new_chapters_count"], 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_single_manga_returns_404_when_unread(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
Reference in New Issue
Block a user