fix(anilist): stop persisting wrong-season data on unresolved seasons

Two cases where an unresolved season still produced a confident wrong answer:

- Cover art warned but then wrote the season 1 anilistId and episodesTotal via
  upsertCoverArt/updateAnimeAnilistInfo. Since a cached coverBlob short-circuits
  the next call, that metadata never re-resolved. Keep the artwork as a stand-in
  but withhold the id, titles and episode count when the season is unresolved.
- pickByAirOrder sorted entries with no air year last, shifting every later
  season by one while still reporting seasonResolved. Decline instead when any
  franchise entry lacks a year.
This commit is contained in:
2026-07-28 02:27:44 -07:00
parent 59c305e023
commit d25950c041
4 changed files with 118 additions and 15 deletions
@@ -356,3 +356,78 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing keeps the cover but withholds metadata when the season is unresolved', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-unresolved.mkv',
sourceType: SOURCE_TYPE_LOCAL,
sourceUrl: null,
});
const originalFetch = globalThis.fetch;
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') {
// No sequel edges, so season 3 cannot be reached from the season 1 anchor.
return Promise.resolve(createJsonResponse({ data: { Media: { relations: { edges: [] } } } }));
}
return Promise.resolve(
createJsonResponse({
data: {
Page: {
media: [
{
id: 55,
episodes: 13,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
title: { romaji: 'Unresolved Show', english: 'Unresolved Show', native: null },
},
],
},
},
}),
);
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () =>
JSON.stringify({ title: 'Unresolved Show', season: 3, episode: 1, year: 2013 }),
},
);
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(stored?.anilistId, null);
assert.equal(stored?.episodesTotal, null);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+20 -13
View File
@@ -299,27 +299,34 @@ 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: selected.id,
anilistId: seasonResolved ? selected.id : null,
coverUrl,
coverBlob,
titleRomaji: selected.title?.romaji ?? null,
titleEnglish: selected.title?.english ?? null,
episodesTotal: selected.episodes ?? null,
titleRomaji: seasonResolved ? (selected.title?.romaji ?? null) : null,
titleEnglish: seasonResolved ? (selected.title?.english ?? null) : null,
episodesTotal: seasonResolved ? (selected.episodes ?? null) : 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,
});
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,
});
}
logger.info(
'cover-art: cached art for videoId=%d anilistId=%d title="%s"',
'cover-art: cached art for videoId=%d anilistId=%s title="%s"',
videoId,
selected.id,
seasonResolved ? String(selected.id) : 'none (season unresolved)',
selected.title?.romaji ?? searchBase,
);
@@ -287,3 +287,21 @@ test('refuses a season beyond the sequel-hop cap instead of returning a partial
// Declines before spending any relation requests.
assert.deepEqual(relationLookups, []);
});
test('air-order fallback declines when a franchise entry has no air year', async () => {
// The middle season has no year, so ordering it would shift every later season by one.
const { execute } = createExecutor(
[
{ id: 1, episodes: 12, format: 'TV', seasonYear: 2013, title: { english: 'Show' } },
{ id: 2, episodes: 12, format: 'TV', title: { english: 'Show Zoku' } },
{ id: 3, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show Kan' } },
],
{},
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
assert.equal(result?.id, 1);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
});
+5 -2
View File
@@ -325,9 +325,12 @@ export function pickByAirOrder(
});
if (franchise.length < season) return null;
// Ordering by air date is only meaningful when every entry has one: an unknown year
// would sort last and shift the season index while still reporting a resolved match.
if (franchise.some((entry) => airYear(entry) === null)) return null;
const ordered = [...franchise].sort((a, b) => {
const yearDelta =
(airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER);
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
});