diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a6fc1e9..a7ea865 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.107.0" +version = "0.108.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6219956..40ec794 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.107.0" +version = "0.108.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index bc5da96..41e81ea 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -22,6 +22,7 @@ pub fn routes() -> Router { .route("/mangas", get(list).post(create)) .route("/mangas/:id", get(get_one).patch(update)) .route("/mangas/:id/similar", get(list_similar)) + .route("/me/recommendations", get(list_recommendations)) .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)) @@ -188,6 +189,28 @@ async fn list_similar( Ok(Json(json!({ "items": items }))) } +const RECOMMENDATIONS_LIMIT: i64 = 12; + +#[derive(Debug, Deserialize)] +pub struct RecommendationParams { + #[serde(default)] + pub limit: Option, +} + +/// `GET /api/v1/me/recommendations` — content-based "Recommended for you", +/// ranked by tag overlap with the signed-in user's likes/bookmarks (minus +/// dislikes). Returns `{ "items": [...] }` (a fixed top-N, like `/similar`); +/// empty when the user has no taste signals yet. +async fn list_recommendations( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult> { + let limit = params.limit.unwrap_or(RECOMMENDATIONS_LIMIT).clamp(1, 50); + let items = repo::manga::list_recommendations(&state.db, user.id, limit).await?; + Ok(Json(json!({ "items": items }))) +} + /// `POST /api/v1/mangas` is multipart/form-data. Parts: /// /// - `metadata` (required): JSON body matching `NewManga` — title, optional diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index fd259c9..a01894c 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -307,6 +307,73 @@ pub async fn list_similar( cards_from_rows(pool, rows).await } +/// Content-based "Recommended for you": rank mangas by weighted tag overlap +/// with the user's taste. Signals: explicit like = +1.0, bookmark = +0.5, +/// dislike = -1.0 (a reaction overrides a bookmark on the same manga). Per +/// tag we sum those weights into an affinity, then score each candidate by +/// the sum of its tags' affinities, normalized by the candidate's tag count +/// (same anti-tag-stuffing rationale as `list_similar`). Candidates the user +/// already reacted to, bookmarked, or read are excluded; net-negative +/// candidates (dominated by disliked-tag affinity) are dropped, so a dislike +/// down-ranks rather than the manga being hidden from normal browse. No +/// signals → empty. Reuses `cards_from_rows` for author/genre hydration. +pub async fn list_recommendations( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> AppResult> { + let sql = format!( + r#" + WITH signals AS ( + SELECT s.manga_id, + CASE WHEN r.reaction = 'dislike' THEN -1.0 + WHEN r.reaction = 'like' THEN 1.0 + ELSE 0.5 END AS weight + FROM ( + SELECT manga_id FROM manga_reactions WHERE user_id = $1 + UNION + SELECT manga_id FROM bookmarks WHERE user_id = $1 + ) s + LEFT JOIN manga_reactions r + ON r.user_id = $1 AND r.manga_id = s.manga_id + ), + tag_affinity AS ( + SELECT mt.tag_id, SUM(sig.weight) AS affinity + FROM signals sig + JOIN manga_tags mt ON mt.manga_id = sig.manga_id + GROUP BY mt.tag_id + ) + SELECT {cols} + FROM manga_tags cand + JOIN tag_affinity ta ON ta.tag_id = cand.tag_id + JOIN mangas m ON m.id = cand.manga_id + WHERE cand.manga_id NOT IN (SELECT manga_id FROM signals) + AND cand.manga_id NOT IN ( + SELECT manga_id FROM read_progress WHERE user_id = $1 + ) + GROUP BY m.id + HAVING SUM(ta.affinity) > 0 + ORDER BY + SUM(ta.affinity) + / (SELECT count(*) FROM manga_tags WHERE manga_id = m.id) DESC, + SUM(ta.affinity) DESC, + m.updated_at DESC, + lower(m.title) ASC, + m.id + LIMIT $2 + "#, + cols = manga_cols("m"), + ); + + let rows = sqlx::query_as::<_, Manga>(&sql) + .bind(user_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 diff --git a/backend/tests/api_recommendations.rs b/backend/tests/api_recommendations.rs new file mode 100644 index 0000000..f90c9db --- /dev/null +++ b/backend/tests/api_recommendations.rs @@ -0,0 +1,182 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: 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().is_success(), "attach_tag: {}", resp.status()); +} + +async fn set_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid, reaction: &str) { + let resp = app + .clone() + .oneshot(common::put_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/reaction"), + json!({ "reaction": reaction }), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +async fn bookmark(app: &axum::Router, cookie: &str, manga_id: Uuid) { + let resp = app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/bookmarks", + json!({ "manga_id": manga_id.to_string() }), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); +} + +async fn mark_read(app: &axum::Router, cookie: &str, manga_id: Uuid) { + let resp = app + .clone() + .oneshot(common::put_json_with_cookie( + "/api/v1/me/read-progress", + json!({ "manga_id": manga_id.to_string(), "page": 1 }), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +/// Recommended manga titles, in ranked order. +async fn recommend(app: &axum::Router, cookie: &str) -> Vec { + let resp = app + .clone() + .oneshot(common::get_with_cookie("/api/v1/me/recommendations", cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap().to_string()) + .collect() +} + +#[sqlx::test(migrations = "./migrations")] +async fn likes_drive_recommendations(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await; + let similar = common::seed_manga_via_api(&h.app, &cookie, "Similar").await; + let unrelated = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await; + attach_tag(&h.app, &cookie, liked, "action").await; + attach_tag(&h.app, &cookie, similar, "action").await; + attach_tag(&h.app, &cookie, unrelated, "sports").await; + + set_reaction(&h.app, &cookie, liked, "like").await; + + let recs = recommend(&h.app, &cookie).await; + assert!(recs.contains(&"Similar".to_string()), "recs: {recs:?}"); + assert!(!recs.contains(&"Unrelated".to_string()), "recs: {recs:?}"); + // The liked manga itself is not recommended back. + assert!(!recs.contains(&"Liked".to_string()), "recs: {recs:?}"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn dislike_downranks_shared_tags(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await; + let disliked = common::seed_manga_via_api(&h.app, &cookie, "Disliked").await; + let good = common::seed_manga_via_api(&h.app, &cookie, "Good").await; + let bad = common::seed_manga_via_api(&h.app, &cookie, "Bad").await; + attach_tag(&h.app, &cookie, liked, "action").await; + attach_tag(&h.app, &cookie, good, "action").await; // shares the liked tag + attach_tag(&h.app, &cookie, disliked, "gore").await; + attach_tag(&h.app, &cookie, bad, "gore").await; // shares the disliked tag + + set_reaction(&h.app, &cookie, liked, "like").await; + set_reaction(&h.app, &cookie, disliked, "dislike").await; + + let recs = recommend(&h.app, &cookie).await; + assert!(recs.contains(&"Good".to_string()), "recs: {recs:?}"); + // Net-negative (disliked-tag) candidate is dropped. + assert!(!recs.contains(&"Bad".to_string()), "recs: {recs:?}"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn bookmark_counts_as_half_a_like(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await; + let booked = common::seed_manga_via_api(&h.app, &cookie, "Booked").await; + let from_like = common::seed_manga_via_api(&h.app, &cookie, "FromLike").await; + let from_bookmark = common::seed_manga_via_api(&h.app, &cookie, "FromBookmark").await; + attach_tag(&h.app, &cookie, liked, "tliked").await; + attach_tag(&h.app, &cookie, from_like, "tliked").await; // affinity 1.0 + attach_tag(&h.app, &cookie, booked, "tbooked").await; + attach_tag(&h.app, &cookie, from_bookmark, "tbooked").await; // affinity 0.5 + + set_reaction(&h.app, &cookie, liked, "like").await; + bookmark(&h.app, &cookie, booked).await; + + let recs = recommend(&h.app, &cookie).await; + // Both recommended, but the like-derived one outranks the bookmark-derived. + let i_like = recs.iter().position(|t| t == "FromLike"); + let i_book = recs.iter().position(|t| t == "FromBookmark"); + assert!(i_like.is_some() && i_book.is_some(), "recs: {recs:?}"); + assert!(i_like < i_book, "like should outrank bookmark; recs: {recs:?}"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn excludes_already_seen(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await; + let fresh = common::seed_manga_via_api(&h.app, &cookie, "Fresh").await; + let already_read = common::seed_manga_via_api(&h.app, &cookie, "AlreadyRead").await; + for m in [liked, fresh, already_read] { + attach_tag(&h.app, &cookie, m, "action").await; + } + set_reaction(&h.app, &cookie, liked, "like").await; + mark_read(&h.app, &cookie, already_read).await; + + let recs = recommend(&h.app, &cookie).await; + assert!(recs.contains(&"Fresh".to_string()), "recs: {recs:?}"); + assert!(!recs.contains(&"AlreadyRead".to_string()), "recs: {recs:?}"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn empty_without_signals(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let m = common::seed_manga_via_api(&h.app, &cookie, "Whatever").await; + attach_tag(&h.app, &cookie, m, "action").await; + + assert!(recommend(&h.app, &cookie).await.is_empty()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .clone() + .oneshot(common::get("/api/v1/me/recommendations")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 63104ef..ae57c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.107.0", + "version": "0.108.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.107.0", + "version": "0.108.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 3c27bce..75b7e70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.107.0", + "version": "0.108.0", "private": true, "type": "module", "scripts": {