feat(mangas): add tag-similarity "Similar" section on manga detail

Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.

Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.

Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.

Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 15:48:15 +02:00
parent 6c901e64c9
commit 33f684887d
15 changed files with 423 additions and 54 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -21,6 +21,7 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/similar", get(list_similar))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
@@ -108,6 +109,28 @@ async fn get_one(
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
/// How many similar mangas the recommendation section shows.
const SIMILAR_LIMIT: i64 = 5;
/// `GET /api/v1/mangas/:id/similar` — top-`SIMILAR_LIMIT` mangas ranked by
/// tag overlap with `:id`, as cards. Read-only and unauthenticated, like
/// `get_one`/`list`. Returns a plain `{ items: [...] }` object (not the
/// paginated envelope) since this is a fixed top-N, not a collection.
///
/// The `exists` check is load-bearing: `list_similar` returns an empty list
/// for both an untagged manga and a nonexistent id, so without it an unknown
/// id would 200 with `[]` instead of 404.
async fn list_similar(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
let items = repo::manga::list_similar(&state.db, id, SIMILAR_LIMIT).await?;
Ok(Json(json!({ "items": items })))
}
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
///
/// - `metadata` (required): JSON body matching `NewManga` — title, optional

View File

@@ -40,8 +40,34 @@ pub struct ListQuery {
pub sort: ListSort,
}
const SELECT_COLS: &str =
"id, title, status, alt_titles, description, cover_image_path, created_at, updated_at";
/// Single source of truth for the `mangas` columns that hydrate a [`Manga`],
/// so the plain and join-aliased select lists stay in lockstep with the
/// struct's `FromRow`.
const MANGA_COLS: [&str; 8] = [
"id",
"title",
"status",
"alt_titles",
"description",
"cover_image_path",
"created_at",
"updated_at",
];
/// `MANGA_COLS` rendered as a select list. A non-empty `alias` qualifies each
/// column (`"m"` → `m.id, m.title, …`) for queries that join other tables;
/// an empty alias yields the bare names used by single-table queries.
fn manga_cols(alias: &str) -> String {
if alias.is_empty() {
MANGA_COLS.join(", ")
} else {
MANGA_COLS
.iter()
.map(|c| format!("{alias}.{c}"))
.collect::<Vec<_>>()
.join(", ")
}
}
/// Shared WHERE used by both the rows and the count queries. Filters
/// are AND across facets: every supplied author_id (or genre, or tag)
@@ -100,12 +126,13 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
let list_sql = format!(
r#"
SELECT {SELECT_COLS}
SELECT {cols}
FROM mangas
WHERE {FILTER_WHERE}
ORDER BY {order_by}
LIMIT $6 OFFSET $7
"#
"#,
cols = manga_cols(""),
);
let rows = sqlx::query_as::<_, Manga>(&list_sql)
@@ -145,9 +172,73 @@ pub async fn list_cards(
query: &ListQuery,
) -> AppResult<(Vec<MangaCard>, i64)> {
let (rows, total) = list(pool, query).await?;
let cards = cards_from_rows(pool, rows).await?;
Ok((cards, total))
}
/// Top-`limit` mangas ranked by tag similarity to `id`, as cards.
///
/// Similarity is the Jaccard index of the two tag sets —
/// `shared / (|base| + |other| - shared)` — so a heavily-tagged manga
/// sharing many generic tags does not crowd out genuinely-close matches.
/// (A simpler raw shared-tag count is one ORDER BY term away, but it
/// biases toward mangas with large tag sets; see the tie-break below.)
///
/// The candidate set comes from the self-join on `manga_tags`, which by
/// construction only includes mangas sharing at least one tag and never
/// the source itself. A source with no tags — or no overlap — yields an
/// empty result. Existence of `id` is the caller's concern (the empty
/// result here cannot distinguish "no tags" from "no such manga").
pub async fn list_similar(
pool: &PgPool,
id: Uuid,
limit: i64,
) -> AppResult<Vec<MangaCard>> {
let sql = format!(
r#"
SELECT {cols}
FROM manga_tags base
JOIN manga_tags other
ON other.tag_id = base.tag_id
AND other.manga_id <> base.manga_id
JOIN mangas m ON m.id = other.manga_id
WHERE base.manga_id = $1
GROUP BY m.id
ORDER BY
count(*)::float
/ ( (SELECT count(*) FROM manga_tags WHERE manga_id = $1)
+ (SELECT count(*) FROM manga_tags WHERE manga_id = m.id)
- count(*) ) DESC,
count(*) DESC,
m.updated_at DESC,
lower(m.title) ASC,
m.id
LIMIT $2
"#,
// The query joins `manga_tags`, so the mangas columns need the `m.` alias.
cols = manga_cols("m"),
);
let rows = sqlx::query_as::<_, Manga>(&sql)
.bind(id)
.bind(limit)
.fetch_all(pool)
.await?;
cards_from_rows(pool, rows).await
}
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
/// authors and genres in two batched round-trips. The input order is
/// preserved (callers rely on this to keep list/ranking order), so we
/// iterate `rows` rather than the id-ordered `BTreeMap`s.
async fn cards_from_rows(pool: &PgPool, rows: Vec<Manga>) -> AppResult<Vec<MangaCard>> {
let ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
let mut authors = repo::author::load_for_mangas(pool, &ids).await?;
let mut genres = repo::genre::load_for_mangas(pool, &ids).await?;
// Authors and genres are independent reads — load them concurrently.
let (mut authors, mut genres) = tokio::try_join!(
repo::author::load_for_mangas(pool, &ids),
repo::genre::load_for_mangas(pool, &ids),
)?;
let cards = rows
.into_iter()
.map(|manga| MangaCard {
@@ -156,12 +247,13 @@ pub async fn list_cards(
manga,
})
.collect();
Ok((cards, total))
Ok(cards)
}
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Manga> {
sqlx::query_as::<_, Manga>(&format!(
"SELECT {SELECT_COLS} FROM mangas WHERE id = $1"
"SELECT {} FROM mangas WHERE id = $1",
manga_cols("")
))
.bind(id)
.fetch_optional(pool)
@@ -198,8 +290,9 @@ pub async fn create<'e, E: PgExecutor<'e>>(
r#"
INSERT INTO mangas (title, status, description, alt_titles, uploaded_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING {SELECT_COLS}
"#
RETURNING {cols}
"#,
cols = manga_cols(""),
))
.bind(title)
.bind(status)
@@ -234,8 +327,9 @@ pub async fn update_basics(
alt_titles = COALESCE($6, alt_titles),
updated_at = now()
WHERE id = $1
RETURNING {SELECT_COLS}
"#
RETURNING {cols}
"#,
cols = manga_cols(""),
))
.bind(id)
.bind(title)

View File

@@ -315,3 +315,166 @@ async fn get_unknown_id_is_404_with_envelope(pool: PgPool) {
let msg = body["error"]["message"].as_str().expect("message is string");
assert!(!msg.is_empty(), "message should be non-empty");
}
// ----------------------------------------------------------------------------
// GET /v1/mangas/:id/similar — recommendation by tag overlap (Jaccard).
// ----------------------------------------------------------------------------
/// Attach a tag to a manga via the public API. Asserts the call succeeds
/// (201 created or 200 already-attached) so test setup failures surface loudly.
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: uuid::Uuid, name: &str) {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/tags"),
json!({ "name": name }),
cookie,
))
.await
.unwrap();
assert!(
resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK,
"attach_tag({name}) failed: {}",
resp.status()
);
}
fn ids(body: &serde_json::Value) -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|m| m["id"].as_str().unwrap().to_string())
.collect()
}
#[sqlx::test(migrations = "./migrations")]
async fn similar_ranks_by_jaccard(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let a = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
let b = common::seed_manga_via_api(&h.app, &cookie, "Perfect Match").await;
let c = common::seed_manga_via_api(&h.app, &cookie, "Tight Subset").await;
let d = common::seed_manga_via_api(&h.app, &cookie, "Broad Overlap").await;
let e = common::seed_manga_via_api(&h.app, &cookie, "No Overlap").await;
// Source A: t1..t4
for t in ["t1", "t2", "t3", "t4"] {
attach_tag(&h.app, &cookie, a, t).await;
}
// B shares all 4 -> Jaccard 4/4 = 1.0
for t in ["t1", "t2", "t3", "t4"] {
attach_tag(&h.app, &cookie, b, t).await;
}
// C has t1,t2 -> shares 2 -> Jaccard 2/4 = 0.5
for t in ["t1", "t2"] {
attach_tag(&h.app, &cookie, c, t).await;
}
// D has t1..t8 -> shares 4 -> Jaccard 4/8 = 0.5 (tie with C; loses on shared-count tie-break)
for t in ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"] {
attach_tag(&h.app, &cookie, d, t).await;
}
// E disjoint -> excluded
for t in ["z1", "z2"] {
attach_tag(&h.app, &cookie, e, t).await;
}
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{a}/similar")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let got = ids(&body);
let b = b.to_string();
let c = c.to_string();
let d = d.to_string();
// B (1.0) first; then C and D both 0.5 but C wins the shared-count tie-break (D shares 4 too,
// wait — D shares 4, C shares 2). Tie-break is more-shared-first, so D precedes C.
assert_eq!(got, vec![b, d, c], "ranked B(1.0), D(0.5,4 shared), C(0.5,2 shared)");
// Self and the disjoint manga never appear.
assert!(!got.contains(&a.to_string()));
assert!(!got.contains(&e.to_string()));
// Cards carry the enriched shape.
assert!(body["items"][0]["authors"].is_array());
assert!(body["items"][0]["genres"].is_array());
}
#[sqlx::test(migrations = "./migrations")]
async fn similar_caps_at_five_and_excludes_self(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
attach_tag(&h.app, &cookie, source, "shared").await;
for i in 0..7 {
let m = common::seed_manga_via_api(&h.app, &cookie, &format!("Neighbor {i}")).await;
attach_tag(&h.app, &cookie, m, "shared").await;
}
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let got = ids(&body);
assert_eq!(got.len(), 5, "capped at 5");
assert!(!got.contains(&source.to_string()), "self excluded");
}
#[sqlx::test(migrations = "./migrations")]
async fn similar_empty_when_no_tags(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let source = common::seed_manga_via_api(&h.app, &cookie, "Untagged").await;
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn similar_empty_when_no_overlap(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
let other = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
attach_tag(&h.app, &cookie, source, "alpha").await;
attach_tag(&h.app, &cookie, other, "beta").await;
let resp = h
.app
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn similar_404_for_unknown_manga(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::get(&format!(
"/api/v1/mangas/{}/similar",
uuid::Uuid::new_v4()
)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "not_found");
}