diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 9162eda..9b01b2f 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -4,7 +4,7 @@ import { comicSpec } from 'packages/obsidian/src/library/comic'; import { gameSpec } from 'packages/obsidian/src/library/game'; import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine'; import { mangaSpec } from 'packages/obsidian/src/library/manga'; -import type { HttpJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; import type MediaDbPlugin from 'packages/obsidian/src/main'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; @@ -91,12 +91,35 @@ export class LibraryController { }; } + // POST-JSON counterpart of makeHttp -- needed for GraphQL APIs (AniList) that require a + // POST body instead of query-string params. Same 429/error handling as makeHttp. + private makeHttpPostJson(): HttpPostJsonFn { + return async (url: string, body: unknown, headers: Record): Promise => { + const res = await obsidianFetch( + new Request(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers }, + }), + ); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.json(); + }; + } + /** dryRun=true routes flip notifications to the log instead of a real Notice -- a dry-run * preview shouldn't pop user-facing notices for changes that were never actually written. */ private makeSpecDeps(dryRun = false): SpecDeps { return { http: this.makeHttp(), httpText: this.makeHttpText(), + httpPostJson: this.makeHttpPostJson(), getKey: name => this.getKey(name), log: msg => console.log(`[media-db-library] ${msg}`), notify: msg => { diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 3e29880..61faddc 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -3,9 +3,61 @@ import { parseFeed, latestChapter } from 'packages/obsidian/src/library/rss'; import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse'; import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; const JIKAN_BASE = 'https://api.jikan.moe/v4'; const MANGADEX_BASE = 'https://api.mangadex.org'; +const ANILIST_BASE = 'https://graphql.anilist.co'; + +// AniList field selection shared by the search + both by-id queries below. `status` comes back +// as an ALL_CAPS enum (mapped via ANILIST_STATUS_MAP); dates come back as {year,month,day} +// objects (mapped via aniListDate); description is HTML (mapped via stripAniListHtml). +const ANILIST_MEDIA_FIELDS = ` + id + idMal + title { romaji english } + status + chapters + volumes + averageScore + genres + staff(perPage: 6) { + edges { role node { name { full } } } + } + startDate { year month day } + endDate { year month day } + description(asHtml: false) + coverImage { large } + siteUrl +`; + +const ANILIST_SEARCH_QUERY = `query ($q: String) { + Page(perPage: 8) { + media(search: $q, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } + } +}`; + +const ANILIST_BY_ID_QUERY = `query ($id: Int) { + Media(id: $id, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } +}`; + +const ANILIST_BY_IDMAL_QUERY = `query ($id: Int) { + Media(idMal: $id, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } +}`; + +const ANILIST_STATUS_MAP: Record = { + RELEASING: 'Publishing', + FINISHED: 'Finished', + HIATUS: 'On Hiatus', + CANCELLED: 'Canceled', + NOT_YET_RELEASED: 'Not Yet Published', +}; export interface MangaRecord { title: string; @@ -25,6 +77,7 @@ export interface MangaRecord { publishedFrom: string | null; publishedTo: string | null; malId: string; + anilistId: string; mangadexId: string; rss: string; poster: string | null; @@ -82,6 +135,7 @@ export function buildManga(jikan: any, prev: Record): MangaRecor publishedFrom: jikan.published?.from ? String(jikan.published.from).slice(0, 10) : null, publishedTo: jikan.published?.to ? String(jikan.published.to).slice(0, 10) : null, malId: jikan.mal_id != null ? String(jikan.mal_id) : '', + anilistId: stripQuotes(prev['anilist_id']), // Jikan has no AniList concept -- carried through untouched mangadexId, rss, poster: jikan.images?.jpg?.large_image_url ?? null, @@ -90,6 +144,118 @@ export function buildManga(jikan: any, prev: Record): MangaRecor }; } +/** AniList `{year,month,day}` date object -> ISO `YYYY-MM-DD`, or null when any part is missing + * (AniList leaves in-progress end dates as all-null rather than omitting the object). */ +function aniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string | null { + if (!d || d.year == null || d.month == null || d.day == null) return null; + const mm = String(d.month).padStart(2, '0'); + const dd = String(d.day).padStart(2, '0'); + return `${d.year}-${mm}-${dd}`; +} + +/** AniList `description(asHtml:false)` still carries a handful of literal tags (`
`, ``, + * etc.) and HTML entities -- strip both down to plain text for the synopsis field. */ +function stripAniListHtml(html: string | null | undefined): string { + if (!html) return ''; + return html + .replace(//gi, '\n') + .replace(/<\/?[^>]+(>|$)/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim(); +} + +/** + * Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter -> + * canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only + * the upstream field mapping differs. `mal_id` prefers AniList's `idMal` bridge but falls back to + * whatever mal_id the note already carried -- AniList entries without a MAL counterpart (e.g. + * webtoons) must not clobber a manually-set mal_id. + */ +export function buildMangaFromAniList(media: any, prev: Record): MangaRecord { + const romaji: string = media.title?.romaji ?? ''; + const english: string = media.title?.english ?? ''; + const engName = english && english !== romaji ? english : ''; + + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const lastReadChapter = stripQuotes(prev['last_read_chapter']); + const rss = stripNullSentinel(stripQuotes(prev['rss'])); + const mangadexId = stripQuotes(prev['mangadex_id']); + + const score = typeof media.averageScore === 'number' ? Math.round(media.averageScore) / 10 : null; + + const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']); + const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']); + + const authors: string[] = ((media.staff?.edges ?? []) as any[]) + .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) + .map(e => e?.node?.name?.full) + .filter(Boolean); + + return { + title: romaji, + engName, + readStatus, + rating, + ratingStars, + lastReadChapter, + latestChapter: parseNumOrNull(prev['latest_chapter']), + lastChapterDate: stripNullSentinel(stripQuotes(prev['last_chapter_date'])), + chapters: media.chapters ?? null, + volumes: media.volumes ?? null, + status: ANILIST_STATUS_MAP[media.status as string] ?? '', + authors, + genre: (media.genres ?? []).filter(Boolean), + score, + publishedFrom: aniListDate(media.startDate), + publishedTo: aniListDate(media.endDate), + malId, + anilistId, + mangadexId, + rss, + poster: media.coverImage?.large ?? null, + // existing url convention = MAL page; only fall back to AniList's own page when the note + // (still) has no mal_id at all -- e.g. AniList-only webtoons with no MAL counterpart + url: malId ? `https://myanimelist.net/manga/${malId}` : (media.siteUrl ?? ''), + synopsis: stripAniListHtml(media.description), + }; +} + +/** `Media(id|idMal: ...)` fetch -- throws (never returns a partial/null record) on GraphQL + * errors or a missing `Media` so callers can uniformly catch-and-fallback to Jikan. */ +async function fetchAniListMedia(deps: SpecDeps, query: string, variables: Record): Promise { + const res = await deps.httpPostJson(ANILIST_BASE, { query, variables }, {}); + if (res?.errors?.length) throw new Error(`AniList error: ${res.errors[0]?.message ?? 'unknown'}`); + const media = res?.data?.Media; + if (!media) throw new Error('AniList: media not found'); + return media; +} + +/** `Page.media` search -- unlike `fetchAniListMedia`, a genuine empty result set is NOT an + * error (it's a legitimate "no matches", same as Jikan's search returning `data: []`); only + * GraphQL/network failures throw. */ +async function fetchAniListSearch(deps: SpecDeps, q: string): Promise { + const res = await deps.httpPostJson(ANILIST_BASE, { query: ANILIST_SEARCH_QUERY, variables: { q } }, {}); + if (res?.errors?.length) throw new Error(`AniList error: ${res.errors[0]?.message ?? 'unknown'}`); + return res?.data?.Page?.media ?? []; +} + +function aniListTitles(m: any): string[] { + const titles = [m?.title?.romaji, m?.title?.english]; + return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); +} + +/** Top-candidate identifying info for ambiguous-resolve logging (AniList side). */ +function candidateAniListSummary(m: any): string { + const year = m?.startDate?.year ? String(m.startDate.year) : ''; + return `anilist_id=${m?.id ?? ''} «${m?.title?.romaji ?? ''}»${year ? ` (${year})` : ''}`; +} + export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -111,6 +277,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus `published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`, `published_to: ${r.publishedTo ? r.publishedTo : 'null'}`, `mal_id: ${r.malId}`, + `anilist_id: ${r.anilistId}`, `mangadex_id: ${r.mangadexId}`, `rss: ${quotedOrNull(r.rss)}`, `poster: ${quotedOrNull(r.poster)}`, @@ -234,29 +401,66 @@ export const mangaSpec: MediaTypeSpec = { itemType: 'manga_item', folderSettingKey: 'libraryMangaFolder', enabledSettingKey: 'libraryMangaEnabled', - throttleMs: 350, + throttleMs: 700, // AniList's keyless rate limit is 90 req/min hasId(fm: Record): boolean { - return !!stripQuotes(fm['mal_id']); + return !!stripQuotes(fm['mal_id']) || !!stripQuotes(fm['anilist_id']); }, isActive(fm: Record): boolean { const malId = stripQuotes(fm['mal_id']); + const anilistId = stripQuotes(fm['anilist_id']); const status = stripQuotes(fm['status']); - if (!malId || !status) return true; // never enriched -> needs first pass + if ((!malId && !anilistId) || !status) return true; // never enriched -> needs first pass if (status === 'Publishing' || status === 'On Hiatus') return true; if (stripQuotes(fm['read_status']) === 'Reading') return true; if (stripNullSentinel(stripQuotes(fm['rss']))) return true; return false; // Finished + Read/Unread/Dropped -> static }, + // AniList-primary: search AniList first (Page search, unique-exact vs romaji+english). A + // unique hit patches anilist_id (+ mal_id via idMal, when AniList has a MAL bridge for it). + // AniList miss/ambiguous/throw all fall back to the existing Jikan title-search resolve. + // TmdbRateLimitError is never treated as a fallback trigger -- it propagates so the engine's + // withRateLimitRetry wrapper (around the whole resolve() call) retries instead of masking a + // transient 429 as an AniList miss. async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); if (!query) return null; + + let anilistResults: any[] = []; + try { + anilistResults = await fetchAniListSearch(deps, query); + } catch (e) { + if (e instanceof TmdbRateLimitError) throw e; + deps.log(`manga anilist resolve failed for "${query}": ${String(e)}`); + anilistResults = []; + } + + const q = query.toLowerCase(); + const exactAni = anilistResults.filter(m => aniListTitles(m).includes(q)); + const pickAni = exactAni.length === 1 ? exactAni[0] : exactAni.length === 0 && anilistResults.length === 1 ? anilistResults[0] : null; + + if (pickAni) { + const patch: Record = { anilist_id: String(pickAni.id) }; + if (pickAni.idMal != null) patch.mal_id = String(pickAni.idMal); + if (!stripQuotes(ctx.frontmatter['mangadex_id'])) { + const mangadexId = await resolveMangadexId(query, deps); + if (mangadexId) patch.mangadex_id = mangadexId; + } + return patch; + } + + if (anilistResults.length > 1) { + deps.log(`ambiguous "${query}": candidates: ${anilistResults.slice(0, 3).map(candidateAniListSummary).join('; ')}`); + } + + // Jikan fallback (unchanged behavior) let malId: string | null; try { malId = await resolveMalId(query, deps); } catch (e) { + if (e instanceof TmdbRateLimitError) throw e; deps.log(`manga resolve failed for "${query}": ${String(e)}`); return null; } @@ -271,22 +475,41 @@ export const mangaSpec: MediaTypeSpec = { return patch; }, + // AniList-primary: anilist_id present -> fetch by id; else mal_id -> fetch by idMal bridge. + // AniList throwing (network error, GraphQL error, not-found) falls back to the existing Jikan + // full-record fetch when a mal_id is available; with no mal_id there's no fallback path, so + // the sync is skipped (skippedNoData upstream). TmdbRateLimitError propagates uncaught (same + // retry-not-fallback reasoning as resolve() above). async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { const fm = ctx.frontmatter; const malId = stripQuotes(fm['mal_id']); - if (!malId) return null; // needs resolve() first + const anilistId = stripQuotes(fm['anilist_id']); + if (!malId && !anilistId) return null; // needs resolve() first - let jikanData: any; + let record: MangaRecord; try { - const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {}); - jikanData = res?.data; + const media = anilistId + ? await fetchAniListMedia(deps, ANILIST_BY_ID_QUERY, { id: Number(anilistId) }) + : await fetchAniListMedia(deps, ANILIST_BY_IDMAL_QUERY, { id: Number(malId) }); + record = buildMangaFromAniList(media, fm); } catch (e) { - deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e)}`); - return null; - } - if (!jikanData) return null; + if (e instanceof TmdbRateLimitError) throw e; + deps.log(`manga anilist fetch failed (${anilistId ? `anilist_id ${anilistId}` : `mal_id ${malId}`}): ${String(e)}`); + if (!malId) return null; // no mal_id -> no Jikan fallback possible + + let jikanData: any; + try { + const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {}); + jikanData = res?.data; + } catch (e2) { + if (e2 instanceof TmdbRateLimitError) throw e2; + deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e2)}`); + return null; + } + if (!jikanData) return null; + record = buildManga(jikanData, fm); + } - const record = buildManga(jikanData, fm); const prevStatus = stripQuotes(fm['status']); const prevLatestChapter = parseNumOrNull(fm['latest_chapter']); const prevLastChapterDate = stripNullSentinel(stripQuotes(fm['last_chapter_date'])); diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index e326e2b..0a60417 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -1,5 +1,6 @@ export type HttpJsonFn = (url: string, headers: Record) => Promise; export type HttpTextFn = (url: string, headers: Record) => Promise; +export type HttpPostJsonFn = (url: string, body: unknown, headers: Record) => Promise; export interface LibraryNoteCtx { frontmatter: Record; @@ -22,6 +23,7 @@ export interface MediaTypeSpec { export interface SpecDeps { http: HttpJsonFn; httpText: HttpTextFn; + httpPostJson: HttpPostJsonFn; getKey(name: 'rawg' | 'comicvine'): string; // '' when unset log(msg: string): void; notify(msg: string): void; diff --git a/packages/obsidian/src/utils/Utils.ts b/packages/obsidian/src/utils/Utils.ts index 5e8a804..9a7818a 100644 --- a/packages/obsidian/src/utils/Utils.ts +++ b/packages/obsidian/src/utils/Utils.ts @@ -286,10 +286,16 @@ export async function obsidianFetch(input: Request): Promise { obs_headers[key] = value; }); + // Request bodies (e.g. POST JSON) must reach requestUrl -- read them off the Request once + // here. GET/HEAD requests have no body, .text() resolves '' for them, so `body` stays + // undefined and existing GET-only call sites are unaffected. + const rawBody = await input.text(); + const res = await requestUrl({ url: input.url, method: input.method, headers: obs_headers, + body: rawBody || undefined, throw: false, // Do not throw on error, handle it manually }); diff --git a/tests/fixtures/anilist-manga-csm.json b/tests/fixtures/anilist-manga-csm.json new file mode 100644 index 0000000..7455d11 --- /dev/null +++ b/tests/fixtures/anilist-manga-csm.json @@ -0,0 +1,30 @@ +{ + "data": { + "Media": { + "id": 105778, + "idMal": 116778, + "title": { + "romaji": "Chainsaw Man", + "english": "Chainsaw Man" + }, + "status": "RELEASING", + "chapters": null, + "volumes": null, + "averageScore": 85, + "genres": ["Action", "Comedy", "Horror", "Supernatural"], + "staff": { + "edges": [ + { "role": "Story & Art", "node": { "name": { "full": "Tatsuki Fujimoto" } } }, + { "role": "Letterer", "node": { "name": { "full": "Some Letterer" } } } + ] + }, + "startDate": { "year": 2018, "month": 12, "day": 3 }, + "endDate": { "year": null, "month": null, "day": null }, + "description": "Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.
Now he hunts devils for a living.", + "coverImage": { + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105778-JCftt5T5vNAY.jpg" + }, + "siteUrl": "https://anilist.co/manga/105778" + } + } +} diff --git a/tests/fixtures/canonical-manga.md b/tests/fixtures/canonical-manga.md index f892d3d..d7a0950 100644 --- a/tests/fixtures/canonical-manga.md +++ b/tests/fixtures/canonical-manga.md @@ -17,6 +17,7 @@ score: 8.7 published_from: 2018-12-03 published_to: null mal_id: 116778 +anilist_id: 105778 mangadex_id: abc-123 rss: "https://example.com/csm-feed.xml" poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg" diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 3ab039f..6c3e543 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 2d6e2aa..dd01f60 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 7ca14fb..e5bfac1 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -77,7 +77,7 @@ describe('maybeCatchUp', () => { }); function fakeSpecDeps() { - return { http: async () => ({}), httpText: async () => '', getKey: () => '', log: () => {}, notify: () => {} }; + return { http: async () => ({}), httpText: async () => '', httpPostJson: async () => ({}), getKey: () => '', log: () => {}, notify: () => {} }; } function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } { diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index a1fcd24..d089fe6 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -75,6 +75,7 @@ function makeDeps(notes: { path: string; content: string }[], specDeps: Partial< specDeps: { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: () => {}, notify: () => {}, diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 32486e8..a37c60b 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 5a9694e..e464f58 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildManga, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; +import { buildManga, buildMangaFromAniList, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; import jikanFixture from 'tests/fixtures/jikan-manga-csm.json'; import mangadexFixture from 'tests/fixtures/mangadex-feed.json'; +import anilistFixture from 'tests/fixtures/anilist-manga-csm.json'; const JIKAN_DATA = jikanFixture.data; +const ANILIST_MEDIA = anilistFixture.data.Media; const EMPTY_PREV: Record = {}; @@ -16,6 +19,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); @@ -164,6 +168,7 @@ describe('renderManga golden', () => { publishedFrom: '2018-12-03', publishedTo: null, malId: '116778', + anilistId: '105778', mangadexId: 'abc-123', rss: 'https://example.com/csm-feed.xml', poster: 'https://cdn.myanimelist.net/images/manga/3/216464l.jpg', @@ -684,3 +689,335 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { expect(mangadexCalled).toBe(false); }); }); + +describe('buildMangaFromAniList field mapping', () => { + const r = buildMangaFromAniList(ANILIST_MEDIA, EMPTY_PREV); + + test('core fields', () => { + expect(r.title).toBe('Chainsaw Man'); + expect(r.anilistId).toBe('105778'); + expect(r.malId).toBe('116778'); // from idMal bridge + expect(r.status).toBe('Publishing'); // RELEASING -> Publishing + expect(r.chapters).toBeNull(); + expect(r.volumes).toBeNull(); + expect(r.genre).toEqual(['Action', 'Comedy', 'Horror', 'Supernatural']); + expect(r.poster).toBe('https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105778-JCftt5T5vNAY.jpg'); + expect(r.url).toBe('https://myanimelist.net/manga/116778'); // MAL convention, mal_id present + }); + + test('authors: only staff roles containing "Story" -> excludes Letterer', () => { + expect(r.authors).toEqual(['Tatsuki Fujimoto']); + }); + + test('score: averageScore 85 -> 8.5', () => { + expect(r.score).toBe(8.5); + }); + test('averageScore 73 -> 7.3', () => { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, averageScore: 73 }, EMPTY_PREV).score).toBe(7.3); + }); + test('non-number averageScore -> null', () => { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, averageScore: null }, EMPTY_PREV).score).toBeNull(); + }); + + test('dates: startDate full -> ISO padded', () => { + expect(r.publishedFrom).toBe('2018-12-03'); + }); + test('dates: endDate all-null parts -> null', () => { + expect(r.publishedTo).toBeNull(); + }); + test('dates: partial date (missing day) -> null, not a malformed ISO string', () => { + const media = { ...ANILIST_MEDIA, startDate: { year: 2018, month: 12, day: null } }; + expect(buildMangaFromAniList(media, EMPTY_PREV).publishedFrom).toBeNull(); + }); + + test('description:
-> newline, stripped, trimmed', () => { + expect(r.synopsis).toBe('Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.\nNow he hunts devils for a living.'); + }); + + test('eng_name empty when title.english === title.romaji', () => { + expect(r.engName).toBe(''); + }); + test('eng_name set when title.english differs from title.romaji', () => { + const media = { ...ANILIST_MEDIA, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man EN Alt' } }; + expect(buildMangaFromAniList(media, EMPTY_PREV).engName).toBe('Chainsaw Man EN Alt'); + }); + + test('status map: exhaustive', () => { + const map: Record = { + RELEASING: 'Publishing', + FINISHED: 'Finished', + HIATUS: 'On Hiatus', + CANCELLED: 'Canceled', + NOT_YET_RELEASED: 'Not Yet Published', + }; + for (const [anilist, expected] of Object.entries(map)) { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, status: anilist }, EMPTY_PREV).status).toBe(expected); + } + }); + + test('idMal null -> mal_id falls back to prev (preserves existing mal_id, does not clobber)', () => { + const media = { ...ANILIST_MEDIA, idMal: null }; + const result = buildMangaFromAniList(media, { mal_id: '999' }); + expect(result.malId).toBe('999'); + expect(result.url).toBe('https://myanimelist.net/manga/999'); + }); + test('idMal null + no prev mal_id -> mal_id empty, url falls back to AniList siteUrl', () => { + const media = { ...ANILIST_MEDIA, idMal: null }; + const result = buildMangaFromAniList(media, EMPTY_PREV); + expect(result.malId).toBe(''); + expect(result.url).toBe('https://anilist.co/manga/105778'); + }); + + test('user-field preservation: read_status/rating/last_read_chapter/rss/mangadex_id carried from prev (shared with buildManga)', () => { + const prev = { + read_status: 'Reading', + rating: '4', + rating_stars: '⭐️⭐️⭐️⭐️', + last_read_chapter: '150', + rss: 'https://x.y/feed.xml', + mangadex_id: 'abc-123', + latest_chapter: '213', + last_chapter_date: '2026-07-16', + }; + const result = buildMangaFromAniList(ANILIST_MEDIA, prev); + expect(result.readStatus).toBe('Reading'); + expect(result.rating).toBe('4'); + expect(result.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(result.lastReadChapter).toBe('150'); + expect(result.rss).toBe('https://x.y/feed.xml'); + expect(result.mangadexId).toBe('abc-123'); + expect(result.latestChapter).toBe(213); + expect(result.lastChapterDate).toBe('2026-07-16'); + }); +}); + +describe('mangaSpec.hasId / isActive — anilist_id counts as an id too', () => { + test('hasId: anilist_id set, no mal_id -> true', () => { + expect(mangaSpec.hasId({ anilist_id: '105778' })).toBe(true); + }); + test('isActive: never enriched (neither id) -> active', () => { + expect(mangaSpec.isActive({})).toBe(true); + }); + test('isActive: anilist_id only, Publishing -> active', () => { + expect(mangaSpec.isActive({ anilist_id: '1', status: 'Publishing', read_status: 'Unread' })).toBe(true); + }); + test('isActive: anilist_id only, Finished + Read -> static', () => { + expect(mangaSpec.isActive({ anilist_id: '1', status: 'Finished', read_status: 'Read' })).toBe(false); + }); +}); + +describe('mangaSpec.resolve — AniList primary', () => { + function anilistPage(media: any[]) { + return { data: { Page: { media } } }; + } + + test('unique exact romaji match -> patches anilist_id + mal_id (idMal bridge)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), // jikan/mangadex: no exact match -> mangadex_id left unset + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + }); + + test('unique exact match via english title only (case-insensitive) -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'チェンソーマン', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + }); + + test('no exact match, sole AniList result -> accepted (unique-exact fallback rule)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 999, idMal: 888, title: { romaji: 'Some Other Title', english: '' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '888', anilist_id: '999' }); + }); + + test('AniList hit, idMal null (no MAL bridge) -> patch has anilist_id only', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: null, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ anilist_id: '105778' }); + }); + + test('AniList ambiguous (multiple, no exact) -> falls back to Jikan search, logs anilist candidates', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => + anilistPage([ + { id: 1, idMal: 1, title: { romaji: 'Foo', english: '' }, startDate: { year: 2020 } }, + { id: 2, idMal: 2, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, + ]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('anilist_id=1') && m.includes('Foo') && m.includes('anilist_id=2') && m.includes('Bar'))).toBe(true); + }); + + test('AniList miss (empty results) -> falls straight to Jikan, no ambiguous log from AniList side', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => anilistPage([]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.includes('anilist_id='))).toBe(false); + }); + + test('AniList search throws generic error -> log, falls back to Jikan (no overall failure)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => { + throw new Error('anilist down'); + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList search throws TmdbRateLimitError -> propagates, no Jikan fallback attempted', async () => { + let jikanCalled = false; + const deps = makeDeps({ + http: async () => { + jikanCalled = true; + return { data: [] }; + }, + httpPostJson: async () => { + throw new TmdbRateLimitError('429'); + }, + }); + await expect(mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps)).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(jikanCalled).toBe(false); + }); + + test('AniList hit, no mangadex_id in fm -> mangadex resolve still attempted (best-effort, unchanged)', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('mangadex.org') ? { data: [{ id: 'a1b2c3d4-uuid', attributes: { title: { en: 'Chainsaw Man' } } }] } : { data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' }); + }); + + test('mangadex_id already present -> mangadex search skipped, even on an AniList hit', async () => { + let mangadexCalled = false; + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) mangadexCalled = true; + return { data: [] }; + }, + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + expect(mangadexCalled).toBe(false); + }); +}); + +describe('mangaSpec.sync — AniList primary enrich', () => { + test('anilist_id present -> fetches Media(id:...), builds AniList record', async () => { + let captured: any = null; + const deps = makeDeps({ + httpPostJson: async (url: string, body: unknown) => { + captured = body; + return anilistFixture; + }, + }); + const fm = { anilist_id: '105778', read_status: 'Unread' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('title: Chainsaw Man'); + expect(result!.content).toContain('anilist_id: 105778'); + expect(result!.content).toContain('mal_id: 116778'); + expect(result!.content).toContain('status: Publishing'); + expect(result!.content).toContain('score: 8.5'); + expect((captured as any).query).toContain('Media'); + expect((captured as any).query).toContain('MANGA'); + expect((captured as any).variables).toEqual({ id: 105778 }); + }); + + test('mal_id present, no anilist_id -> fetches Media(idMal:...), backfills anilist_id', async () => { + let captured: any = null; + const deps = makeDeps({ + httpPostJson: async (url: string, body: unknown) => { + captured = body; + return anilistFixture; + }, + }); + const fm = { mal_id: '116778', read_status: 'Unread' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect((captured as any).query).toContain('idMal'); + expect((captured as any).variables).toEqual({ id: 116778 }); + expect(result!.content).toContain('anilist_id: 105778'); + }); + + test('AniList throw (generic error), mal_id present -> Jikan fallback, prior anilist_id preserved verbatim', async () => { + const deps = makeDeps({ + httpPostJson: async () => { + throw new Error('anilist down'); + }, + http: async () => jikanFixture, + }); + const fm = { anilist_id: '999999', mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('anilist_id: 999999'); + expect(result!.content).toContain('mal_id: 116778'); + expect(result!.content).toContain('latest_chapter: 213'); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList throw, no mal_id available -> no Jikan fallback possible, returns null', async () => { + let jikanCalled = false; + const deps = makeDeps({ + httpPostJson: async () => { + throw new Error('anilist deleted entry'); + }, + http: async () => { + jikanCalled = true; + return jikanFixture; + }, + }); + const result = await mangaSpec.sync(ctxFor({ anilist_id: '105778' }), deps); + expect(result).toBeNull(); + expect(jikanCalled).toBe(false); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList throws TmdbRateLimitError -> propagates uncaught, no Jikan fallback attempted', async () => { + let jikanCalled = false; + const deps = makeDeps({ + httpPostJson: async () => { + throw new TmdbRateLimitError('429'); + }, + http: async () => { + jikanCalled = true; + return jikanFixture; + }, + }); + await expect(mangaSpec.sync(ctxFor({ mal_id: '116778' }), deps)).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(jikanCalled).toBe(false); + }); + + test('chapter cascade (rss) still works on top of an AniList-built record', async () => { + const deps = makeDeps({ + httpPostJson: async () => anilistFixture, + httpText: async () => RSS_214, + }); + const fm = { anilist_id: '105778', rss: 'https://example.com/csm-feed.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: 214'); + expect(result!.content).toContain('read_status: Unread'); + expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); + }); +});