diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 64d3a80..54a8687 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.62.0" +version = "0.63.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b391c11..b29143e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.62.0" +version = "0.63.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 17899a3..befe6d0 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -21,6 +21,7 @@ pub fn routes() -> Router { 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, + Path(id): Path, +) -> AppResult> { + 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 diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 53d2ace..2213ae6 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -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::>() + .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, 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, 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> { + 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) -> AppResult> { let ids: Vec = 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 { 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) diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs index 87e57ec..e454aa0 100644 --- a/backend/tests/api_mangas.rs +++ b/backend/tests/api_mangas.rs @@ -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 { + 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"); +} diff --git a/frontend/e2e/mobile-manga-detail.spec.ts b/frontend/e2e/mobile-manga-detail.spec.ts index 67e4953..1d8c74c 100644 --- a/frontend/e2e/mobile-manga-detail.spec.ts +++ b/frontend/e2e/mobile-manga-detail.spec.ts @@ -68,6 +68,7 @@ async function mockDetail( page: number; } | null; authed?: boolean; + similar?: Array>; } = {} ) { const authed = opts.authed ?? false; @@ -128,6 +129,13 @@ async function mockDetail( }) }) ); + await page.route(`**/api/v1/mangas/${mangaId}/similar`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: opts.similar ?? [] }) + }) + ); await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => route.fulfill({ status: opts.readProgress ? 200 : 404, @@ -241,4 +249,39 @@ test.describe('mobile manga detail', () => { await expect(page.getByTestId('manga-title')).toBeVisible(); await expect(page.getByTestId('bookmark-toggle')).toBeVisible(); }); + + test('renders the Similar section with recommended cards when present', async ({ + page + }) => { + await mockDetail(page, { + similar: [ + { + id: 'b2222222-2222-2222-2222-222222222222', + title: 'Vinland Saga', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'au2', name: 'Makoto Yukimura' }], + genres: [] + } + ] + }); + await page.goto(`/manga/${mangaId}`); + + const section = page.getByTestId('similar-section'); + await expect(section).toBeVisible(); + await expect(section.getByText('Vinland Saga')).toBeVisible(); + }); + + test('omits the Similar section when there are no recommendations', async ({ page }) => { + await mockDetail(page, { similar: [] }); + await page.goto(`/manga/${mangaId}`); + + // The chapter list is the anchor that the page has rendered. + await expect(page.getByTestId('manga-title')).toBeVisible(); + await expect(page.getByTestId('similar-section')).toHaveCount(0); + }); }); diff --git a/frontend/package.json b/frontend/package.json index 4aef920..cfe5ac8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.62.0", + "version": "0.63.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts index e8ca4a0..b0d1e2a 100644 --- a/frontend/src/lib/api/mangas.test.ts +++ b/frontend/src/lib/api/mangas.test.ts @@ -7,7 +7,8 @@ import { updateMangaCover, deleteMangaCover, attachTag, - detachTag + detachTag, + getSimilarMangas } from './mangas'; function ok(body: unknown, status = 200): Response { @@ -251,6 +252,21 @@ describe('mangas api client', () => { expect(init.method).toBe('DELETE'); }); + it('getSimilarMangas hits /v1/mangas/:id/similar and unwraps items', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [cardFixture({ id: 's1' }), cardFixture({ id: 's2' })] }) + ); + const items = await getSimilarMangas('b1'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1\/similar$/); + expect(items.map((m) => m.id)).toEqual(['s1', 's2']); + }); + + it('getSimilarMangas returns an empty array when there are no matches', async () => { + fetchSpy.mockResolvedValueOnce(ok({ items: [] })); + expect(await getSimilarMangas('b1')).toEqual([]); + }); + it('getManga throws ApiError carrying the envelope code on non-2xx', async () => { fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found')); await expect(getManga('missing')).rejects.toMatchObject({ diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts index 0351ef6..199be90 100644 --- a/frontend/src/lib/api/mangas.ts +++ b/frontend/src/lib/api/mangas.ts @@ -60,6 +60,20 @@ export async function getManga(id: string): Promise { return request(`/v1/mangas/${encodeURIComponent(id)}`); } +/** + * GET /v1/mangas/:id/similar — up to 5 mangas ranked by tag overlap with + * `id`. Returns a plain `{ items }` object (a fixed top-N, not a paginated + * collection), so we unwrap to the card array the page wants. + */ +export async function getSimilarMangas(id: string): Promise { + const res = await request<{ items: MangaCard[] }>( + `/v1/mangas/${encodeURIComponent(id)}/similar` + ); + // Defensive: a malformed 200 body (items omitted) must still yield an + // array so the page's `similar.length` guard can't throw. + return res.items ?? []; +} + export type NewManga = { title: string; status?: MangaStatus; diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css index 5cea82f..ec36f0a 100644 --- a/frontend/src/lib/styles/tokens.css +++ b/frontend/src/lib/styles/tokens.css @@ -303,3 +303,24 @@ img { animation-duration: 0ms !important; } } + +/* Shared grid for manga cover cards — used by the catalog, author and + collection pages, and the similar-mangas section. Single-sourced here + so the responsive layout can't drift between pages. Four covers per + row on phones is a deliberate user preference; `minmax(0, 1fr)` is + load-bearing so the grid never overflows the viewport. */ +.manga-grid { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-4); +} + +@media (max-width: 640px) { + .manga-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-2); + } +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 27294c0..8782627 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -870,14 +870,8 @@ margin: var(--space-2) 0; } - .manga-grid { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--space-4); - } + /* `.manga-grid` is a shared global (see lib/styles/tokens.css), including + its four-per-row phone layout. */ /* Mobile-only helpers. Defaults flip below the 640px breakpoint so the same chrome doesn't render twice. */ @@ -899,15 +893,5 @@ the 28rem cap is desktop-only. */ max-width: none; } - - .manga-grid { - /* Four covers per row on phones (per user preference) at a - tight gap. `minmax(0, 1fr)` is load-bearing — without it - the implicit `minmax(auto, 1fr)` lets grid cells grow to - their intrinsic min-content width and push the whole - grid past the viewport edge. */ - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--space-2); - } } diff --git a/frontend/src/routes/authors/[id]/+page.svelte b/frontend/src/routes/authors/[id]/+page.svelte index a4ad3de..1da8cf4 100644 --- a/frontend/src/routes/authors/[id]/+page.svelte +++ b/frontend/src/routes/authors/[id]/+page.svelte @@ -109,12 +109,5 @@ color: var(--text-muted); } - .manga-grid { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--space-4); - } + /* `.manga-grid` is a shared global (see lib/styles/tokens.css). */ diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 4993660..981e4d1 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -314,14 +314,7 @@ color: var(--text-muted); } - .manga-grid { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--space-4); - } + /* `.manga-grid` is a shared global (see lib/styles/tokens.css). */ .section-heading { margin: var(--space-5) 0 var(--space-3); diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 2403f1b..625d7b0 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -16,6 +16,7 @@ import { listTags, type Tag } from '$lib/api/tags'; import { session } from '$lib/session.svelte'; import Chip from '$lib/components/Chip.svelte'; + import MangaCard from '$lib/components/MangaCard.svelte'; import Sheet from '$lib/components/Sheet.svelte'; import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte'; import Plus from '@lucide/svelte/icons/plus'; @@ -62,6 +63,9 @@ const authors = $derived(manga.authors); const genres = $derived(manga.genres); + /** Up to 5 mangas with the most similar tags; empty hides the section. */ + const similar = $derived(data.similar); + // svelte-ignore state_referenced_locally let tags = $state([...manga.tags]); // svelte-ignore state_referenced_locally @@ -605,6 +609,17 @@ {/if} + {#if similar.length > 0} +
+

Similar

+
    + {#each similar as m (m.id)} + + {/each} +
+
+ {/if} + { - const [manga, chapters, bookmarks, readProgress] = await Promise.all([ + const [manga, chapters, bookmarks, readProgress, similar] = await Promise.all([ getManga(params.id), listChapters(params.id), listMyBookmarksOrEmpty(), // Null when guest or never-read — page handles both cases. - getMyReadProgressForManga(params.id) + getMyReadProgressForManga(params.id), + // Recommendations are non-critical: a failure here must not break + // the detail page, so fall back to an empty list. + getSimilarMangas(params.id).catch(() => [] as MangaCard[]) ]); return { manga, chapters: chapters.items, bookmarks: bookmarks.items, - readProgress + readProgress, + similar }; };