fix(anilist): cache a no-match for unresolved seasons instead of season 1 art

Storing the season 1 artwork left a cover row with a blob but no AniList id,
which fetchIfMissing's `existing.coverBlob` early return serves forever. The
video's metadata would then stay null permanently, even after AniList published
the sequel relation, because the lookup was never reached again. The existing
no-match retry window did not apply since it requires a null coverUrl.

Cache a plain no-match instead, so the unresolved season reuses NO_MATCH_RETRY_MS
and recovers on its own once the relation exists.
This commit is contained in:
2026-07-28 02:46:39 -07:00
parent d25950c041
commit 57ac9d73e7
2 changed files with 140 additions and 25 deletions
@@ -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 dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
ensureSchema(db); 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 fetched = await fetcher.fetchIfMissing(db, videoId, 'Unresolved Show');
const stored = getCoverArt(db, videoId); const stored = getCoverArt(db, videoId);
assert.equal(fetched, true); assert.equal(fetched, false);
// Artwork is a reasonable stand-in; the season 1 identity and episode count are not. // Storing the season 1 artwork would leave a blob with no AniList id, which the
assert.ok(stored?.coverBlob); // `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?.anilistId, null);
assert.equal(stored?.episodesTotal, null); assert.equal(stored?.episodesTotal, null);
} finally { } finally {
@@ -431,3 +434,109 @@ test('fetchIfMissing keeps the cover but withholds metadata when the season is u
cleanupDbPath(dbPath); 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);
}
});
+27 -21
View File
@@ -271,12 +271,25 @@ export function createCoverArtFetcher(
} }
if (resolution && !resolution.seasonResolved) { 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( 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, resolution.requestedSeason,
searchBase, searchBase,
resolution.title, resolution.title,
); );
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
return false;
} }
const selected = resolution?.media ?? null; const selected = resolution?.media ?? null;
@@ -299,34 +312,27 @@ export function createCoverArtFetcher(
coverBlob = await downloadImage(coverUrl); 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, { upsertCoverArt(db, videoId, {
anilistId: seasonResolved ? selected.id : null, anilistId: selected.id,
coverUrl, coverUrl,
coverBlob, coverBlob,
titleRomaji: seasonResolved ? (selected.title?.romaji ?? null) : null, titleRomaji: selected.title?.romaji ?? null,
titleEnglish: seasonResolved ? (selected.title?.english ?? null) : null, titleEnglish: selected.title?.english ?? null,
episodesTotal: seasonResolved ? (selected.episodes ?? null) : null, episodesTotal: selected.episodes ?? null,
}); });
if (seasonResolved) { updateAnimeAnilistInfo(db, videoId, {
updateAnimeAnilistInfo(db, videoId, { anilistId: selected.id,
anilistId: selected.id, titleRomaji: selected.title?.romaji ?? null,
titleRomaji: selected.title?.romaji ?? null, titleEnglish: selected.title?.english ?? null,
titleEnglish: selected.title?.english ?? null, titleNative: selected.title?.native ?? null,
titleNative: selected.title?.native ?? null, episodesTotal: selected.episodes ?? null,
episodesTotal: selected.episodes ?? null, });
});
}
logger.info( 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, videoId,
seasonResolved ? String(selected.id) : 'none (season unresolved)', selected.id,
selected.title?.romaji ?? searchBase, selected.title?.romaji ?? searchBase,
); );