mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user