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