mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
fix(anilist): resolve later seasons via sequel relations, not title guessing (#173)
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
type CharacterDictionarySeriesKeyGuess,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||
import { resolveJapaneseNameSplits } from './character-dictionary-runtime/name-split-resolver';
|
||||
@@ -168,6 +169,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
targetPath?: string,
|
||||
options?: CharacterDictionaryGenerateOptions,
|
||||
) => Promise<CharacterDictionaryBuildResult>;
|
||||
resolvePinnedMediaId: (input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
guess: CharacterDictionarySeriesKeyGuess | null;
|
||||
}) => Promise<number | null>;
|
||||
} {
|
||||
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||
const sleepMs = deps.sleep ?? sleep;
|
||||
@@ -272,7 +278,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
: ''
|
||||
}`,
|
||||
);
|
||||
const override = await manualSelectionStore.getOverride(seriesKey);
|
||||
const override = await manualSelectionStore.getOverride(seriesKey, guessed.season);
|
||||
if (override) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
|
||||
@@ -314,7 +320,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest, (message) =>
|
||||
deps.logInfo?.(message),
|
||||
);
|
||||
if (resolved.seasonResolved === false) {
|
||||
deps.logWarn?.(
|
||||
`[dictionary] could not find season ${resolved.requestedSeason} of "${guessed.title}"; using ${resolved.title} (AniList ${resolved.id}). Set a manual AniList match to correct it.`,
|
||||
);
|
||||
// Caching the fallback would make the wrong season stick silently for every later
|
||||
// episode, so leave it uncached and re-resolve (and re-warn) until it is corrected.
|
||||
return resolved;
|
||||
}
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
mediaId: resolved.id,
|
||||
@@ -509,7 +525,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
||||
: [];
|
||||
const [override, current] = await Promise.all([
|
||||
manualSelectionStore.getOverride(seriesKey),
|
||||
manualSelectionStore.getOverride(seriesKey, guessed.season),
|
||||
shouldUseExplicitSearch
|
||||
? Promise.resolve(null)
|
||||
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
@@ -553,6 +569,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
mediaId: selected.id,
|
||||
mediaTitle: selected.title,
|
||||
staleMediaIds,
|
||||
season: guessed.season,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
@@ -605,5 +622,14 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
revision,
|
||||
};
|
||||
},
|
||||
resolvePinnedMediaId: async ({ mediaPath, mediaTitle, guess }) => {
|
||||
const seriesKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: deps.resolveMediaPathForJimaku(mediaPath),
|
||||
mediaTitle,
|
||||
guess,
|
||||
});
|
||||
const override = await manualSelectionStore.getOverride(seriesKey, guess?.season ?? null);
|
||||
return override?.mediaId ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { resolveAnilistSeasonMedia } from '../../core/services/anilist/season-resolver';
|
||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
@@ -73,57 +74,6 @@ type AniListCharacterPageResponse = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function pickAniListSearchResult(
|
||||
title: string,
|
||||
episode: number | null,
|
||||
media: Array<{
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
}>,
|
||||
): ResolvedAniListMedia | null {
|
||||
if (media.length === 0) return null;
|
||||
|
||||
const episodeFiltered =
|
||||
episode && episode > 0
|
||||
? media.filter((entry) => {
|
||||
const totalEpisodes = entry.episodes;
|
||||
return (
|
||||
typeof totalEpisodes !== 'number' || totalEpisodes <= 0 || episode <= totalEpisodes
|
||||
);
|
||||
})
|
||||
: media;
|
||||
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||
const normalizedTitle = normalizeTitle(title);
|
||||
|
||||
const exact = candidates.find((entry) => {
|
||||
const titles = [entry.title?.english, entry.title?.romaji, entry.title?.native]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizeTitle(value));
|
||||
return titles.includes(normalizedTitle);
|
||||
});
|
||||
const selected = exact ?? candidates[0] ?? media[0];
|
||||
if (!selected) return null;
|
||||
|
||||
const selectedTitle =
|
||||
selected.title?.english?.trim() ||
|
||||
selected.title?.romaji?.trim() ||
|
||||
selected.title?.native?.trim() ||
|
||||
title.trim();
|
||||
return {
|
||||
id: selected.id,
|
||||
title: selectedTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function toAniListMediaCandidate(
|
||||
entry: {
|
||||
id: number;
|
||||
@@ -242,35 +192,24 @@ function inferImageExt(contentType: string | null, bytes: Buffer): string {
|
||||
export async function resolveAniListMediaIdFromGuess(
|
||||
guess: AnilistMediaGuess,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
logInfo?: (message: string) => void,
|
||||
): Promise<ResolvedAniListMedia> {
|
||||
const data = await fetchAniList<AniListSearchResponse>(
|
||||
`
|
||||
query($search: String!) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
const resolution = await resolveAnilistSeasonMedia(
|
||||
{ title: guess.title, season: guess.season, episode: guess.episode },
|
||||
{
|
||||
search: guess.title,
|
||||
execute: (query, variables) => fetchAniList(query, variables, beforeRequest),
|
||||
logInfo,
|
||||
},
|
||||
beforeRequest,
|
||||
);
|
||||
|
||||
const media = data.Page?.media ?? [];
|
||||
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
|
||||
if (!resolved) {
|
||||
if (!resolution) {
|
||||
throw new Error(`No AniList media match found for "${guess.title}".`);
|
||||
}
|
||||
return resolved;
|
||||
return {
|
||||
id: resolution.id,
|
||||
title: resolution.title,
|
||||
seasonResolved: resolution.seasonResolved,
|
||||
requestedSeason: resolution.requestedSeason,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchAniListMediaCandidates(
|
||||
|
||||
@@ -160,3 +160,73 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
|
||||
const guess = {
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit' as const,
|
||||
};
|
||||
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => seasonThreePath,
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => guess,
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await runtime.resolvePinnedMediaId({
|
||||
mediaPath: seasonThreePath,
|
||||
mediaTitle: null,
|
||||
guess,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
overridesPath,
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{
|
||||
seriesKey: buildCharacterDictionarySeriesKey({
|
||||
mediaPath: seasonThreePath,
|
||||
mediaTitle: null,
|
||||
guess,
|
||||
}),
|
||||
mediaId: 108489,
|
||||
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
staleMediaIds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await runtime.resolvePinnedMediaId({
|
||||
mediaPath: seasonThreePath,
|
||||
mediaTitle: null,
|
||||
guess,
|
||||
}),
|
||||
108489,
|
||||
);
|
||||
|
||||
// The season 1 file in the same folder must not inherit the season 3 override.
|
||||
assert.equal(
|
||||
await runtime.resolvePinnedMediaId({
|
||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { ...guess, season: 1 },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -312,3 +312,131 @@ test('manual selection store keeps overrides separate for different season direc
|
||||
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
||||
assert.equal(await store.getOverride(secondSeasonKey), null);
|
||||
});
|
||||
|
||||
test('buildCharacterDictionarySeriesKey tags seasons past the first', () => {
|
||||
const base = {
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
episode: 1,
|
||||
source: 'guessit' as const,
|
||||
};
|
||||
const seasonOne = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { ...base, season: 1 },
|
||||
});
|
||||
const seasonThree = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { ...base, season: 3 },
|
||||
});
|
||||
|
||||
// Season 1 keeps the pre-existing key shape so cached snapshots stay valid.
|
||||
assert.equal(seasonOne, 'anime-oregairu--my-teen-romantic-comedy-snafu-2013');
|
||||
assert.equal(seasonThree, 'anime-oregairu--my-teen-romantic-comedy-snafu-s3-2013');
|
||||
});
|
||||
|
||||
test('manual selection store keeps seasons apart inside one flat directory', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const base = {
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
episode: 1,
|
||||
source: 'guessit' as const,
|
||||
};
|
||||
const seasonOneKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { ...base, season: 1 },
|
||||
});
|
||||
const seasonThreeKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { ...base, season: 3 },
|
||||
});
|
||||
|
||||
await store.setOverride({
|
||||
seriesKey: seasonOneKey,
|
||||
mediaId: 14813,
|
||||
mediaTitle: 'My Teen Romantic Comedy SNAFU',
|
||||
staleMediaIds: [],
|
||||
});
|
||||
|
||||
// Same directory, different season: the season 1 override must not leak across.
|
||||
assert.equal(await store.getOverride(seasonThreeKey), null);
|
||||
|
||||
await store.setOverride({
|
||||
seriesKey: seasonThreeKey,
|
||||
mediaId: 108489,
|
||||
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
staleMediaIds: [],
|
||||
});
|
||||
|
||||
assert.equal((await store.getOverride(seasonOneKey))?.mediaId, 14813);
|
||||
assert.equal((await store.getOverride(seasonThreeKey))?.mediaId, 108489);
|
||||
});
|
||||
|
||||
test('override scope uses the recorded season, not season-like text in the title', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
// A title that itself normalizes to a trailing "-s2" would be misparsed as season 2.
|
||||
const trickyTitleKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Mixed/Some Show S2 - S01E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { title: 'Some Show S2', season: 1, episode: 1, source: 'guessit' },
|
||||
});
|
||||
const realSeasonTwoKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: '/anime/Mixed/Other Show - S02E01.mkv',
|
||||
mediaTitle: null,
|
||||
guess: { title: 'Other Show', season: 2, episode: 1, source: 'guessit' },
|
||||
});
|
||||
|
||||
await store.setOverride({
|
||||
seriesKey: trickyTitleKey,
|
||||
mediaId: 111,
|
||||
mediaTitle: 'Some Show S2',
|
||||
staleMediaIds: [],
|
||||
season: 1,
|
||||
});
|
||||
await store.setOverride({
|
||||
seriesKey: realSeasonTwoKey,
|
||||
mediaId: 222,
|
||||
mediaTitle: 'Other Show 2',
|
||||
staleMediaIds: [],
|
||||
season: 2,
|
||||
});
|
||||
|
||||
// Same directory, genuinely different seasons: neither override may replace the other.
|
||||
assert.equal((await store.getOverride(trickyTitleKey, 1))?.mediaId, 111);
|
||||
assert.equal((await store.getOverride(realSeasonTwoKey, 2))?.mediaId, 222);
|
||||
});
|
||||
|
||||
test('override records without a stored season still resolve via the key', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const legacyKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
overridesPath,
|
||||
JSON.stringify({
|
||||
overrides: [
|
||||
{ seriesKey: legacyKey, mediaId: 21355, mediaTitle: 'Re:ZERO', staleMediaIds: [] },
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal((await store.getOverride(legacyKey, 1))?.mediaId, 21355);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ export type CharacterDictionaryManualSelection = {
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
staleMediaIds: number[];
|
||||
/**
|
||||
* Season this override was saved for. Recorded explicitly because inferring it from the
|
||||
* key text is ambiguous for titles that themselves end in a season-like token.
|
||||
*/
|
||||
season?: number | null;
|
||||
};
|
||||
|
||||
type ManualSelectionStoreFile = {
|
||||
@@ -20,6 +25,11 @@ function normalizeManualMediaId(value: unknown): number | null {
|
||||
return mediaId > 0 ? mediaId : null;
|
||||
}
|
||||
|
||||
function normalizeSeason(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSeriesKeyPart(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
@@ -73,11 +83,13 @@ function normalizeOverride(value: unknown): CharacterDictionaryManualSelection |
|
||||
const mediaId = normalizeManualMediaId(raw.mediaId);
|
||||
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||
if (!seriesKey || mediaId === null || !mediaTitle) return null;
|
||||
const season = normalizeSeason(raw.season);
|
||||
return {
|
||||
seriesKey,
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
|
||||
...(season === null ? {} : { season }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,10 +124,29 @@ function getDirectoryScope(seriesKey: string): string | null {
|
||||
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback season for override records saved before the season was stored explicitly:
|
||||
* reads the "-s<N>" segment emitted by buildCharacterDictionarySeriesKey, which sits just
|
||||
* before the optional trailing year. Season 1 keys carry no segment and parse as 1.
|
||||
*/
|
||||
function getSeasonScopeFromKey(seriesKey: string): number {
|
||||
const match = /-s(\d{1,2})(?:-\d{4})?$/.exec(seriesKey);
|
||||
if (!match) return 1;
|
||||
const season = Number.parseInt(match[1]!, 10);
|
||||
return Number.isInteger(season) && season > 0 ? season : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a parsed guess the series key is built from. Kept structural so callers
|
||||
* holding a narrower guess (the AniList post-watch runtime) can build the same key.
|
||||
*/
|
||||
export type CharacterDictionarySeriesKeyGuess = Pick<AnilistMediaGuess, 'title' | 'season'> &
|
||||
Partial<Omit<AnilistMediaGuess, 'title' | 'season'>>;
|
||||
|
||||
export function buildCharacterDictionarySeriesKey(input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
guess: AnilistMediaGuess | null;
|
||||
guess: CharacterDictionarySeriesKeyGuess | null;
|
||||
}): string {
|
||||
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
|
||||
const sourceTitle =
|
||||
@@ -130,22 +161,40 @@ export function buildCharacterDictionarySeriesKey(input: {
|
||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
||||
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
||||
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
|
||||
// Season 1 stays unsuffixed so existing keys, snapshots and overrides keep matching.
|
||||
const season = input.guess?.season;
|
||||
const seasonSegment =
|
||||
typeof season === 'number' && Number.isInteger(season) && season > 1 ? `-s${season}` : '';
|
||||
const withSeason = `${scopedBase}${seasonSegment}`;
|
||||
return input.guess?.year ? `${withSeason}-${input.guess.year}` : withSeason;
|
||||
}
|
||||
|
||||
/** Season an override applies to: the recorded value, else parsed from its key. */
|
||||
function getOverrideSeason(entry: CharacterDictionaryManualSelection): number {
|
||||
return normalizeSeason(entry.season) ?? getSeasonScopeFromKey(entry.seriesKey);
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
getOverride: async (
|
||||
seriesKey: string,
|
||||
season?: number | null,
|
||||
): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||
const overrides = readOverrides(filePath);
|
||||
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
|
||||
if (exactMatch) return exactMatch;
|
||||
const directoryScope = getDirectoryScope(seriesKey);
|
||||
if (directoryScope) {
|
||||
// Same folder is only evidence of the same show when it is also the same season;
|
||||
// a flat multi-season folder would otherwise spread one override across all of them.
|
||||
const seasonScope = normalizeSeason(season) ?? getSeasonScopeFromKey(seriesKey);
|
||||
const scopedMatches = overrides.filter(
|
||||
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
|
||||
(entry) =>
|
||||
getDirectoryScope(entry.seriesKey) === directoryScope &&
|
||||
getOverrideSeason(entry) === seasonScope,
|
||||
);
|
||||
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
|
||||
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
|
||||
@@ -168,9 +217,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
||||
throw new Error('Invalid character dictionary manual selection.');
|
||||
}
|
||||
const directoryScope = getDirectoryScope(normalized.seriesKey);
|
||||
const seasonScope = getOverrideSeason(normalized);
|
||||
const remaining = readOverrides(filePath).filter((entry) =>
|
||||
directoryScope
|
||||
? getDirectoryScope(entry.seriesKey) !== directoryScope
|
||||
? getDirectoryScope(entry.seriesKey) !== directoryScope ||
|
||||
getOverrideSeason(entry) !== seasonScope
|
||||
: entry.seriesKey !== normalized.seriesKey,
|
||||
);
|
||||
writeOverrides(filePath, [...remaining, normalized]);
|
||||
|
||||
@@ -324,3 +324,102 @@ test('generateForCurrentMedia keeps same-version snapshots without images when i
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('an unresolvable season is not cached as a normal AniList match', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let searchCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url !== GRAPHQL_URL) {
|
||||
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
variables?: { search?: string; id?: number };
|
||||
};
|
||||
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'MAIN',
|
||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof body.variables?.id === 'number') {
|
||||
// No sequel edges: season 3 is unreachable from the season 1 anchor.
|
||||
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
searchCalls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () =>
|
||||
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
sleep: async () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
const searchesAfterFirst = searchCalls;
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
|
||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
||||
assert.ok(searchCalls > searchesAfterFirst);
|
||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,4 +181,7 @@ export type ResolvedAniListMedia = {
|
||||
id: number;
|
||||
title: string;
|
||||
staleMediaIds?: number[];
|
||||
/** False when a season >= 2 was requested but only the season 1 entry could be found. */
|
||||
seasonResolved?: boolean;
|
||||
requestedSeason?: number | null;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,8 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
mediaId?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
|
||||
markSuccess: (key: string) => deps.markSuccess(key),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||
@@ -52,8 +53,19 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
|
||||
deps.enqueueRetry(key, title, episode, season),
|
||||
enqueueRetry: (
|
||||
key: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
mediaId?: number | null,
|
||||
) => deps.enqueueRetry(key, title, episode, season, mediaId),
|
||||
getCurrentMediaTitle: deps.getCurrentMediaTitle
|
||||
? () => deps.getCurrentMediaTitle!()
|
||||
: undefined,
|
||||
resolvePinnedAnilistMediaId: deps.resolvePinnedAnilistMediaId
|
||||
? (input) => deps.resolvePinnedAnilistMediaId!(input)
|
||||
: undefined,
|
||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||
@@ -62,7 +74,8 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
mediaId?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
|
||||
@@ -380,3 +380,155 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
|
||||
const updateArgs: Array<number | null | undefined> = [];
|
||||
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
|
||||
[];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => {},
|
||||
getCurrentMediaTitle: () => 'Show S03E01.mkv',
|
||||
resolvePinnedAnilistMediaId: async (input) => {
|
||||
pinInputs.push(input);
|
||||
return 108489;
|
||||
},
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
updateArgs.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(updateArgs, [108489]);
|
||||
// Resolved against the media this run captured, not whatever is playing now.
|
||||
assert.equal(pinInputs.length, 1);
|
||||
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
|
||||
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
|
||||
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
|
||||
const enqueued: Array<number | null | undefined> = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
|
||||
enqueued.push(mediaId);
|
||||
},
|
||||
resolvePinnedAnilistMediaId: async () => 108489,
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(enqueued, [108489]);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
|
||||
const updateArgs: Array<number | null | undefined> = [];
|
||||
const warnings: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => {},
|
||||
resolvePinnedAnilistMediaId: async () => {
|
||||
throw new Error('store unreadable');
|
||||
},
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
updateArgs.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(updateArgs, [null]);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /override lookup failed/i);
|
||||
});
|
||||
|
||||
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
|
||||
const received: Array<number | null | undefined> = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
|
||||
refreshRetryQueueState: () => {},
|
||||
setLastAttemptAt: () => {},
|
||||
setLastError: () => {},
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
received.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
markSuccess: () => {},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
markFailure: () => {},
|
||||
logInfo: () => {},
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(received, [108489]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ type AnilistGuess = {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
alternativeTitle?: string;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
type AnilistUpdateResult = {
|
||||
@@ -16,6 +18,7 @@ type RetryQueueItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
mediaId?: number | null;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
@@ -58,6 +61,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
mediaId?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
markSuccess: (key: string) => void;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
@@ -84,6 +88,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
queued.title,
|
||||
queued.episode,
|
||||
queued.season ?? null,
|
||||
queued.mediaId ?? null,
|
||||
);
|
||||
if (result.status === 'updated' || result.status === 'skipped') {
|
||||
deps.markSuccess(queued.key);
|
||||
@@ -119,7 +124,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
enqueueRetry: (
|
||||
key: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
mediaId?: number | null,
|
||||
) => void;
|
||||
getCurrentMediaTitle?: () => string | null;
|
||||
/**
|
||||
* Pinned AniList media id from a manual dictionary override. Takes the media captured
|
||||
* by this run, so a mid-run file change cannot pin the update to a different show.
|
||||
*/
|
||||
resolvePinnedAnilistMediaId?: (input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
guess: AnilistGuess;
|
||||
}) => Promise<number | null>;
|
||||
markRetryFailure: (key: string, message: string) => void;
|
||||
markRetrySuccess: (key: string) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
@@ -128,6 +149,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
mediaId?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
@@ -157,6 +179,8 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||
deps.resetTrackedMedia(mediaKey);
|
||||
}
|
||||
// Captured before any await: playback can advance while the update is in flight.
|
||||
const mediaTitle = deps.getCurrentMediaTitle?.() ?? null;
|
||||
|
||||
let watchedSeconds = 0;
|
||||
if (!force) {
|
||||
@@ -202,9 +226,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
// A manual dictionary override is the user's explicit answer to "which AniList
|
||||
// entry is this?", so it wins over anything the title search would resolve.
|
||||
let pinnedMediaId: number | null = null;
|
||||
try {
|
||||
pinnedMediaId =
|
||||
(await deps.resolvePinnedAnilistMediaId?.({
|
||||
mediaPath: mediaKey,
|
||||
mediaTitle,
|
||||
guess,
|
||||
})) ?? null;
|
||||
} catch (error) {
|
||||
deps.logWarn(`AniList override lookup failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
|
||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd('AniList: access token not configured');
|
||||
@@ -216,6 +254,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
guess.title,
|
||||
guess.episode,
|
||||
guess.season,
|
||||
pinnedMediaId,
|
||||
);
|
||||
if (result.status === 'updated') {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
@@ -241,7 +280,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
|
||||
deps.markRetryFailure(attemptKey, result.message);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||
|
||||
Reference in New Issue
Block a user