feat(library): anilist-primary manga metadata with jikan fallback
This commit is contained in:
parent
d76ed2631b
commit
8a046a0ea2
12 changed files with 641 additions and 15 deletions
|
|
@ -4,7 +4,7 @@ import { comicSpec } from 'packages/obsidian/src/library/comic';
|
||||||
import { gameSpec } from 'packages/obsidian/src/library/game';
|
import { gameSpec } from 'packages/obsidian/src/library/game';
|
||||||
import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine';
|
import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine';
|
||||||
import { mangaSpec } from 'packages/obsidian/src/library/manga';
|
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 type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||||
import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine';
|
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<string, string>): Promise<any> => {
|
||||||
|
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
|
/** 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. */
|
* preview shouldn't pop user-facing notices for changes that were never actually written. */
|
||||||
private makeSpecDeps(dryRun = false): SpecDeps {
|
private makeSpecDeps(dryRun = false): SpecDeps {
|
||||||
return {
|
return {
|
||||||
http: this.makeHttp(),
|
http: this.makeHttp(),
|
||||||
httpText: this.makeHttpText(),
|
httpText: this.makeHttpText(),
|
||||||
|
httpPostJson: this.makeHttpPostJson(),
|
||||||
getKey: name => this.getKey(name),
|
getKey: name => this.getKey(name),
|
||||||
log: msg => console.log(`[media-db-library] ${msg}`),
|
log: msg => console.log(`[media-db-library] ${msg}`),
|
||||||
notify: msg => {
|
notify: msg => {
|
||||||
|
|
|
||||||
|
|
@ -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 { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||||
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||||
import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert';
|
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 JIKAN_BASE = 'https://api.jikan.moe/v4';
|
||||||
const MANGADEX_BASE = 'https://api.mangadex.org';
|
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<string, string> = {
|
||||||
|
RELEASING: 'Publishing',
|
||||||
|
FINISHED: 'Finished',
|
||||||
|
HIATUS: 'On Hiatus',
|
||||||
|
CANCELLED: 'Canceled',
|
||||||
|
NOT_YET_RELEASED: 'Not Yet Published',
|
||||||
|
};
|
||||||
|
|
||||||
export interface MangaRecord {
|
export interface MangaRecord {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -25,6 +77,7 @@ export interface MangaRecord {
|
||||||
publishedFrom: string | null;
|
publishedFrom: string | null;
|
||||||
publishedTo: string | null;
|
publishedTo: string | null;
|
||||||
malId: string;
|
malId: string;
|
||||||
|
anilistId: string;
|
||||||
mangadexId: string;
|
mangadexId: string;
|
||||||
rss: string;
|
rss: string;
|
||||||
poster: string | null;
|
poster: string | null;
|
||||||
|
|
@ -82,6 +135,7 @@ export function buildManga(jikan: any, prev: Record<string, string>): MangaRecor
|
||||||
publishedFrom: jikan.published?.from ? String(jikan.published.from).slice(0, 10) : null,
|
publishedFrom: jikan.published?.from ? String(jikan.published.from).slice(0, 10) : null,
|
||||||
publishedTo: jikan.published?.to ? String(jikan.published.to).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) : '',
|
malId: jikan.mal_id != null ? String(jikan.mal_id) : '',
|
||||||
|
anilistId: stripQuotes(prev['anilist_id']), // Jikan has no AniList concept -- carried through untouched
|
||||||
mangadexId,
|
mangadexId,
|
||||||
rss,
|
rss,
|
||||||
poster: jikan.images?.jpg?.large_image_url ?? null,
|
poster: jikan.images?.jpg?.large_image_url ?? null,
|
||||||
|
|
@ -90,6 +144,118 @@ export function buildManga(jikan: any, prev: Record<string, string>): 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 (`<br>`, `<i>`,
|
||||||
|
* 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(/<br\s*\/?>/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<string, string>): 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<string, unknown>): Promise<any> {
|
||||||
|
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<any[]> {
|
||||||
|
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 {
|
export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
const fm = [
|
const fm = [
|
||||||
'---',
|
'---',
|
||||||
|
|
@ -111,6 +277,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus
|
||||||
`published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`,
|
`published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`,
|
||||||
`published_to: ${r.publishedTo ? r.publishedTo : 'null'}`,
|
`published_to: ${r.publishedTo ? r.publishedTo : 'null'}`,
|
||||||
`mal_id: ${r.malId}`,
|
`mal_id: ${r.malId}`,
|
||||||
|
`anilist_id: ${r.anilistId}`,
|
||||||
`mangadex_id: ${r.mangadexId}`,
|
`mangadex_id: ${r.mangadexId}`,
|
||||||
`rss: ${quotedOrNull(r.rss)}`,
|
`rss: ${quotedOrNull(r.rss)}`,
|
||||||
`poster: ${quotedOrNull(r.poster)}`,
|
`poster: ${quotedOrNull(r.poster)}`,
|
||||||
|
|
@ -234,29 +401,66 @@ export const mangaSpec: MediaTypeSpec = {
|
||||||
itemType: 'manga_item',
|
itemType: 'manga_item',
|
||||||
folderSettingKey: 'libraryMangaFolder',
|
folderSettingKey: 'libraryMangaFolder',
|
||||||
enabledSettingKey: 'libraryMangaEnabled',
|
enabledSettingKey: 'libraryMangaEnabled',
|
||||||
throttleMs: 350,
|
throttleMs: 700, // AniList's keyless rate limit is 90 req/min
|
||||||
|
|
||||||
hasId(fm: Record<string, string>): boolean {
|
hasId(fm: Record<string, string>): boolean {
|
||||||
return !!stripQuotes(fm['mal_id']);
|
return !!stripQuotes(fm['mal_id']) || !!stripQuotes(fm['anilist_id']);
|
||||||
},
|
},
|
||||||
|
|
||||||
isActive(fm: Record<string, string>): boolean {
|
isActive(fm: Record<string, string>): boolean {
|
||||||
const malId = stripQuotes(fm['mal_id']);
|
const malId = stripQuotes(fm['mal_id']);
|
||||||
|
const anilistId = stripQuotes(fm['anilist_id']);
|
||||||
const status = stripQuotes(fm['status']);
|
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 (status === 'Publishing' || status === 'On Hiatus') return true;
|
||||||
if (stripQuotes(fm['read_status']) === 'Reading') return true;
|
if (stripQuotes(fm['read_status']) === 'Reading') return true;
|
||||||
if (stripNullSentinel(stripQuotes(fm['rss']))) return true;
|
if (stripNullSentinel(stripQuotes(fm['rss']))) return true;
|
||||||
return false; // Finished + Read/Unread/Dropped -> static
|
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<Record<string, string> | null> {
|
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
|
||||||
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
||||||
if (!query) return null;
|
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<string, string> = { 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;
|
let malId: string | null;
|
||||||
try {
|
try {
|
||||||
malId = await resolveMalId(query, deps);
|
malId = await resolveMalId(query, deps);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof TmdbRateLimitError) throw e;
|
||||||
deps.log(`manga resolve failed for "${query}": ${String(e)}`);
|
deps.log(`manga resolve failed for "${query}": ${String(e)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -271,22 +475,41 @@ export const mangaSpec: MediaTypeSpec = {
|
||||||
return patch;
|
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> {
|
async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> {
|
||||||
const fm = ctx.frontmatter;
|
const fm = ctx.frontmatter;
|
||||||
const malId = stripQuotes(fm['mal_id']);
|
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 {
|
try {
|
||||||
const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {});
|
const media = anilistId
|
||||||
jikanData = res?.data;
|
? 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) {
|
} catch (e) {
|
||||||
deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e)}`);
|
if (e instanceof TmdbRateLimitError) throw e;
|
||||||
return null;
|
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
|
||||||
if (!jikanData) return null;
|
|
||||||
|
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 prevStatus = stripQuotes(fm['status']);
|
||||||
const prevLatestChapter = parseNumOrNull(fm['latest_chapter']);
|
const prevLatestChapter = parseNumOrNull(fm['latest_chapter']);
|
||||||
const prevLastChapterDate = stripNullSentinel(stripQuotes(fm['last_chapter_date']));
|
const prevLastChapterDate = stripNullSentinel(stripQuotes(fm['last_chapter_date']));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export type HttpJsonFn = (url: string, headers: Record<string, string>) => Promise<any>;
|
export type HttpJsonFn = (url: string, headers: Record<string, string>) => Promise<any>;
|
||||||
export type HttpTextFn = (url: string, headers: Record<string, string>) => Promise<string>;
|
export type HttpTextFn = (url: string, headers: Record<string, string>) => Promise<string>;
|
||||||
|
export type HttpPostJsonFn = (url: string, body: unknown, headers: Record<string, string>) => Promise<any>;
|
||||||
|
|
||||||
export interface LibraryNoteCtx {
|
export interface LibraryNoteCtx {
|
||||||
frontmatter: Record<string, string>;
|
frontmatter: Record<string, string>;
|
||||||
|
|
@ -22,6 +23,7 @@ export interface MediaTypeSpec {
|
||||||
export interface SpecDeps {
|
export interface SpecDeps {
|
||||||
http: HttpJsonFn;
|
http: HttpJsonFn;
|
||||||
httpText: HttpTextFn;
|
httpText: HttpTextFn;
|
||||||
|
httpPostJson: HttpPostJsonFn;
|
||||||
getKey(name: 'rawg' | 'comicvine'): string; // '' when unset
|
getKey(name: 'rawg' | 'comicvine'): string; // '' when unset
|
||||||
log(msg: string): void;
|
log(msg: string): void;
|
||||||
notify(msg: string): void;
|
notify(msg: string): void;
|
||||||
|
|
|
||||||
|
|
@ -286,10 +286,16 @@ export async function obsidianFetch(input: Request): Promise<Response> {
|
||||||
obs_headers[key] = value;
|
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({
|
const res = await requestUrl({
|
||||||
url: input.url,
|
url: input.url,
|
||||||
method: input.method,
|
method: input.method,
|
||||||
headers: obs_headers,
|
headers: obs_headers,
|
||||||
|
body: rawBody || undefined,
|
||||||
throw: false, // Do not throw on error, handle it manually
|
throw: false, // Do not throw on error, handle it manually
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
30
tests/fixtures/anilist-manga-csm.json
vendored
Normal file
30
tests/fixtures/anilist-manga-csm.json
vendored
Normal file
|
|
@ -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.<br><i>Now he hunts devils for a living.</i>",
|
||||||
|
"coverImage": {
|
||||||
|
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105778-JCftt5T5vNAY.jpg"
|
||||||
|
},
|
||||||
|
"siteUrl": "https://anilist.co/manga/105778"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tests/fixtures/canonical-manga.md
vendored
1
tests/fixtures/canonical-manga.md
vendored
|
|
@ -17,6 +17,7 @@ score: 8.7
|
||||||
published_from: 2018-12-03
|
published_from: 2018-12-03
|
||||||
published_to: null
|
published_to: null
|
||||||
mal_id: 116778
|
mal_id: 116778
|
||||||
|
anilist_id: 105778
|
||||||
mangadex_id: abc-123
|
mangadex_id: abc-123
|
||||||
rss: "https://example.com/csm-feed.xml"
|
rss: "https://example.com/csm-feed.xml"
|
||||||
poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg"
|
poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls:
|
||||||
return {
|
return {
|
||||||
http: async () => ({}),
|
http: async () => ({}),
|
||||||
httpText: async () => '',
|
httpText: async () => '',
|
||||||
|
httpPostJson: async () => ({}),
|
||||||
getKey: () => '',
|
getKey: () => '',
|
||||||
log: (msg: string) => {
|
log: (msg: string) => {
|
||||||
logCalls.push(msg);
|
logCalls.push(msg);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls:
|
||||||
return {
|
return {
|
||||||
http: async () => ({}),
|
http: async () => ({}),
|
||||||
httpText: async () => '',
|
httpText: async () => '',
|
||||||
|
httpPostJson: async () => ({}),
|
||||||
getKey: () => '',
|
getKey: () => '',
|
||||||
log: (msg: string) => {
|
log: (msg: string) => {
|
||||||
logCalls.push(msg);
|
logCalls.push(msg);
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ describe('maybeCatchUp', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeSpecDeps() {
|
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 } {
|
function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ function makeDeps(notes: { path: string; content: string }[], specDeps: Partial<
|
||||||
specDeps: {
|
specDeps: {
|
||||||
http: async () => ({}),
|
http: async () => ({}),
|
||||||
httpText: async () => '',
|
httpText: async () => '',
|
||||||
|
httpPostJson: async () => ({}),
|
||||||
getKey: () => '',
|
getKey: () => '',
|
||||||
log: () => {},
|
log: () => {},
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls:
|
||||||
return {
|
return {
|
||||||
http: async () => ({}),
|
http: async () => ({}),
|
||||||
httpText: async () => '',
|
httpText: async () => '',
|
||||||
|
httpPostJson: async () => ({}),
|
||||||
getKey: () => '',
|
getKey: () => '',
|
||||||
log: (msg: string) => {
|
log: (msg: string) => {
|
||||||
logCalls.push(msg);
|
logCalls.push(msg);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 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 jikanFixture from 'tests/fixtures/jikan-manga-csm.json';
|
||||||
import mangadexFixture from 'tests/fixtures/mangadex-feed.json';
|
import mangadexFixture from 'tests/fixtures/mangadex-feed.json';
|
||||||
|
import anilistFixture from 'tests/fixtures/anilist-manga-csm.json';
|
||||||
|
|
||||||
const JIKAN_DATA = jikanFixture.data;
|
const JIKAN_DATA = jikanFixture.data;
|
||||||
|
const ANILIST_MEDIA = anilistFixture.data.Media;
|
||||||
|
|
||||||
const EMPTY_PREV: Record<string, string> = {};
|
const EMPTY_PREV: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
@ -16,6 +19,7 @@ function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls:
|
||||||
return {
|
return {
|
||||||
http: async () => ({}),
|
http: async () => ({}),
|
||||||
httpText: async () => '',
|
httpText: async () => '',
|
||||||
|
httpPostJson: async () => ({}),
|
||||||
getKey: () => '',
|
getKey: () => '',
|
||||||
log: (msg: string) => {
|
log: (msg: string) => {
|
||||||
logCalls.push(msg);
|
logCalls.push(msg);
|
||||||
|
|
@ -164,6 +168,7 @@ describe('renderManga golden', () => {
|
||||||
publishedFrom: '2018-12-03',
|
publishedFrom: '2018-12-03',
|
||||||
publishedTo: null,
|
publishedTo: null,
|
||||||
malId: '116778',
|
malId: '116778',
|
||||||
|
anilistId: '105778',
|
||||||
mangadexId: 'abc-123',
|
mangadexId: 'abc-123',
|
||||||
rss: 'https://example.com/csm-feed.xml',
|
rss: 'https://example.com/csm-feed.xml',
|
||||||
poster: 'https://cdn.myanimelist.net/images/manga/3/216464l.jpg',
|
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);
|
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: <br> -> newline, <i> 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<string, string> = {
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue