diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 0c5c8522..5a0b1c72 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -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); + } +}); diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index b55f3c3c..efd65f76 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -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, ); diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index 4a768f3e..b0fedbc7 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -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'); +}); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index eab49290..cccfc6ad 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -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; });