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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user