diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 5a0b1c72..1d2462dc 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -357,7 +357,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async ( } }); -test('fetchIfMissing keeps the cover but withholds metadata when the season is unresolved', async () => { +test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); ensureSchema(db); @@ -420,9 +420,12 @@ test('fetchIfMissing keeps the cover but withholds metadata when the season is u const fetched = await fetcher.fetchIfMissing(db, videoId, 'Unresolved Show'); const stored = getCoverArt(db, videoId); - assert.equal(fetched, true); - // Artwork is a reasonable stand-in; the season 1 identity and episode count are not. - assert.ok(stored?.coverBlob); + assert.equal(fetched, false); + // Storing the season 1 artwork would leave a blob with no AniList id, which the + // `existing.coverBlob` early return serves forever - the season could then never + // re-resolve. A plain no-match keeps the existing retry window in play instead. + assert.equal(stored?.coverBlob, null); + assert.equal(stored?.coverUrl, null); assert.equal(stored?.anilistId, null); assert.equal(stored?.episodesTotal, null); } finally { @@ -431,3 +434,109 @@ test('fetchIfMissing keeps the cover but withholds metadata when the season is u cleanupDbPath(dbPath); } }); + +test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', { + canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv', + sourcePath: '/tmp/cover-fetcher-recovers.mkv', + sourceType: SOURCE_TYPE_LOCAL, + sourceUrl: null, + }); + + let sequelPublished = false; + const originalFetch = globalThis.fetch; + const originalNow = Date.now; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.includes('graphql')) { + return Promise.resolve( + new Response(Buffer.from('01020304'), { + status: 200, + headers: { 'content-type': 'image/png' }, + }), + ); + } + + const payload = JSON.parse(String(init?.body ?? '{}')) as { + variables?: { search?: string; id?: number }; + }; + if (typeof payload.variables?.id === 'number') { + return Promise.resolve( + createJsonResponse({ + data: { + Media: { + relations: { + edges: sequelPublished + ? [ + { + relationType: 'SEQUEL', + node: { + id: 66, + type: 'ANIME', + episodes: 12, + format: 'TV', + seasonYear: 2015, + coverImage: { large: 'https://images.test/s2.jpg', medium: null }, + title: { romaji: 'Recovering Show 2', english: null, native: null }, + }, + }, + ] + : [], + }, + }, + }, + }), + ); + } + return Promise.resolve( + createJsonResponse({ + data: { + Page: { + media: [ + { + id: 65, + episodes: 13, + format: 'TV', + seasonYear: 2013, + coverImage: { large: 'https://images.test/s1.jpg', medium: null }, + title: { romaji: 'Recovering Show', english: null, native: null }, + }, + ], + }, + }, + }), + ); + }) as typeof fetch; + + try { + const fetcher = createCoverArtFetcher( + { acquire: async () => {}, recordResponse: () => {} }, + console, + { + runGuessit: async () => + JSON.stringify({ title: 'Recovering Show', season: 2, episode: 1, year: 2013 }), + }, + ); + + assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), false); + assert.equal(getCoverArt(db, videoId)?.anilistId, null); + + // AniList publishes the sequel relation, and the no-match retry window elapses. + sequelPublished = true; + const base = originalNow(); + Date.now = () => base + 10 * 60 * 1000; + + assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), true); + const recovered = getCoverArt(db, videoId); + assert.equal(recovered?.anilistId, 66); + assert.equal(recovered?.episodesTotal, 12); + } finally { + Date.now = originalNow; + globalThis.fetch = originalFetch; + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index efd65f76..82e4dd5a 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -271,12 +271,25 @@ export function createCoverArtFetcher( } if (resolution && !resolution.seasonResolved) { + // Only the season 1 entry was found. Storing its artwork would leave a cover with + // no AniList id, which the `existing.coverBlob` early return above serves forever, + // so the season could never re-resolve once AniList publishes the relation. + // Caching a plain no-match instead reuses the NO_MATCH_RETRY_MS retry window. logger.warn( - 'cover-art: could not find season %d of "%s", using "%s"', + 'cover-art: could not find season %d of "%s" (only matched "%s"), caching no-match', resolution.requestedSeason, searchBase, resolution.title, ); + upsertCoverArt(db, videoId, { + anilistId: null, + coverUrl: null, + coverBlob: null, + titleRomaji: null, + titleEnglish: null, + episodesTotal: null, + }); + return false; } const selected = resolution?.media ?? null; @@ -299,34 +312,27 @@ export function createCoverArtFetcher( coverBlob = await downloadImage(coverUrl); } - // An unresolved season means `selected` is the season 1 entry. Its artwork is a - // reasonable stand-in, but its id and episode count are not: they feed anime-level - // metadata that would otherwise be cached wrong and never re-resolved. - const seasonResolved = resolution?.seasonResolved !== false; - upsertCoverArt(db, videoId, { - anilistId: seasonResolved ? selected.id : null, + anilistId: selected.id, coverUrl, coverBlob, - titleRomaji: seasonResolved ? (selected.title?.romaji ?? null) : null, - titleEnglish: seasonResolved ? (selected.title?.english ?? null) : null, - episodesTotal: seasonResolved ? (selected.episodes ?? null) : null, + titleRomaji: selected.title?.romaji ?? null, + titleEnglish: selected.title?.english ?? null, + episodesTotal: selected.episodes ?? null, }); - if (seasonResolved) { - updateAnimeAnilistInfo(db, videoId, { - anilistId: selected.id, - titleRomaji: selected.title?.romaji ?? null, - titleEnglish: selected.title?.english ?? null, - titleNative: selected.title?.native ?? null, - episodesTotal: selected.episodes ?? null, - }); - } + updateAnimeAnilistInfo(db, videoId, { + anilistId: selected.id, + titleRomaji: selected.title?.romaji ?? null, + titleEnglish: selected.title?.english ?? null, + titleNative: selected.title?.native ?? null, + episodesTotal: selected.episodes ?? null, + }); logger.info( - 'cover-art: cached art for videoId=%d anilistId=%s title="%s"', + 'cover-art: cached art for videoId=%d anilistId=%d title="%s"', videoId, - seasonResolved ? String(selected.id) : 'none (season unresolved)', + selected.id, selected.title?.romaji ?? searchBase, );