mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
fix(anilist): resolve later seasons via sequel relations, not title guessing (#173)
This commit is contained in:
@@ -143,3 +143,50 @@ test('anilist update queue persists and reloads from disk', () => {
|
||||
});
|
||||
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
||||
});
|
||||
|
||||
test('drops queued items whose persisted mediaId is not a positive integer', () => {
|
||||
const filePath = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'anilist-queue-mediaid-')),
|
||||
'queue.json',
|
||||
);
|
||||
const base = {
|
||||
episode: 1,
|
||||
createdAt: 1,
|
||||
attemptCount: 0,
|
||||
nextAttemptAt: 0,
|
||||
lastError: null,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
pending: [
|
||||
{ ...base, key: 'ok-absent', title: 'A' },
|
||||
{ ...base, key: 'ok-null', title: 'B', mediaId: null },
|
||||
{ ...base, key: 'ok-valid', title: 'C', mediaId: 108489 },
|
||||
{ ...base, key: 'bad-string', title: 'D', mediaId: '108489' },
|
||||
{ ...base, key: 'bad-zero', title: 'E', mediaId: 0 },
|
||||
{ ...base, key: 'bad-negative', title: 'F', mediaId: -3 },
|
||||
{ ...base, key: 'bad-float', title: 'G', mediaId: 1.5 },
|
||||
{ ...base, key: 'bad-object', title: 'H', mediaId: { id: 1 } },
|
||||
],
|
||||
deadLetter: [],
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const queue = createAnilistUpdateQueue(filePath, {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
const keptKeys: string[] = [];
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const next = queue.nextReady(1000);
|
||||
if (!next) break;
|
||||
keptKeys.push(next.key);
|
||||
queue.markSuccess(next.key);
|
||||
}
|
||||
|
||||
assert.deepEqual(keptKeys, ['ok-absent', 'ok-null', 'ok-valid']);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AnilistQueuedUpdate {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
|
||||
mediaId?: number | null;
|
||||
episode: number;
|
||||
createdAt: number;
|
||||
attemptCount: number;
|
||||
@@ -29,13 +31,31 @@ export interface AnilistRetryQueueSnapshot {
|
||||
}
|
||||
|
||||
export interface AnilistUpdateQueue {
|
||||
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
enqueue: (
|
||||
key: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
mediaId?: number | null,
|
||||
) => void;
|
||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||
markSuccess: (key: string) => void;
|
||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted mediaId pins the update to a specific AniList entry, so a corrupted or
|
||||
* hand-edited queue file must not be able to feed a bogus id straight to the mutation.
|
||||
*/
|
||||
function isValidPersistedMediaId(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'number' && Number.isInteger(value) && value > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function clampBackoffMs(attemptCount: number): number {
|
||||
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
||||
return Math.min(MAX_BACKOFF_MS, computed);
|
||||
@@ -82,6 +102,7 @@ export function createAnilistUpdateQueue(
|
||||
typeof item.createdAt === 'number' &&
|
||||
typeof item.attemptCount === 'number' &&
|
||||
typeof item.nextAttemptAt === 'number' &&
|
||||
isValidPersistedMediaId(item.mediaId) &&
|
||||
(typeof item.lastError === 'string' || item.lastError === null),
|
||||
)
|
||||
.slice(0, MAX_ITEMS);
|
||||
@@ -96,6 +117,7 @@ export function createAnilistUpdateQueue(
|
||||
typeof item.createdAt === 'number' &&
|
||||
typeof item.attemptCount === 'number' &&
|
||||
typeof item.nextAttemptAt === 'number' &&
|
||||
isValidPersistedMediaId(item.mediaId) &&
|
||||
(typeof item.lastError === 'string' || item.lastError === null),
|
||||
)
|
||||
.slice(0, MAX_ITEMS);
|
||||
@@ -107,7 +129,13 @@ export function createAnilistUpdateQueue(
|
||||
load();
|
||||
|
||||
return {
|
||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||
enqueue(
|
||||
key: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season: number | null = null,
|
||||
mediaId: number | null = null,
|
||||
): void {
|
||||
const existing =
|
||||
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||
if (existing) {
|
||||
@@ -120,6 +148,7 @@ export function createAnilistUpdateQueue(
|
||||
key,
|
||||
title,
|
||||
season,
|
||||
mediaId,
|
||||
episode,
|
||||
createdAt: Date.now(),
|
||||
attemptCount: 0,
|
||||
|
||||
@@ -376,7 +376,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
|
||||
test('updateAnilistPostWatchProgress resolves later seasons through the sequel chain', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const searchTerms: string[] = [];
|
||||
let call = 0;
|
||||
@@ -389,21 +389,45 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
||||
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
||||
{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } },
|
||||
{ id: 202, episodes: 12, format: 'TV', title: { english: 'Demo Show Sequel' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
assert.equal(body.variables?.mediaId, 202);
|
||||
assert.equal(body.variables?.id, 101);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 202, mediaListEntry: null },
|
||||
Media: {
|
||||
relations: {
|
||||
edges: [
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: {
|
||||
id: 202,
|
||||
type: 'ANIME',
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Demo Show Sequel' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 3) {
|
||||
assert.equal(body.variables?.mediaId, 202);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 202, mediaListEntry: { progress: 1, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
assert.equal(body.variables?.mediaId, 202);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
@@ -415,16 +439,118 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
|
||||
// Searches the bare title: AniList has no "Demo Show Season 2" entry to find.
|
||||
assert.deepEqual(searchTerms, ['Demo Show']);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /Demo Show Sequel/);
|
||||
assert.equal(call, 4);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress refuses to update season 1 when the season is unresolvable', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({ data: { Media: { relations: { edges: [] } } } });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, { season: 3 });
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||
assert.match(result.message, /could not find season 3/i);
|
||||
// Stops after search + relation lookup: no entry lookup, no save.
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress uses a pinned media id without searching', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
assert.equal(body.variables?.mediaId, 108489);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
id: 108489,
|
||||
episodes: 12,
|
||||
title: { english: 'Pinned Show' },
|
||||
mediaListEntry: { progress: 4, status: 'CURRENT' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
assert.equal(body.variables?.mediaId, 108489);
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 5, status: 'CURRENT' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 5, {
|
||||
season: 3,
|
||||
mediaId: 108489,
|
||||
});
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /Pinned Show/);
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress marks the final episode completed for a pinned media id', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
id: 108489,
|
||||
episodes: 12,
|
||||
title: { english: 'Pinned Show' },
|
||||
mediaListEntry: { progress: 11, status: 'CURRENT' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
assert.equal(body.variables?.status, 'COMPLETED');
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Pinned Show', 12, {
|
||||
mediaId: 108489,
|
||||
});
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /completed/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
@@ -479,3 +605,33 @@ test('updateAnilistPostWatchProgress returns error when search fails', async ()
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress does not requeue a search that matched nothing', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
createJsonResponse({ data: { Page: { media: [] } } })) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Unknown Show', 3);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /no matches/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress still allows retry when the search itself fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
createJsonResponse({ errors: [{ message: 'upstream exploded' }] })) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.notEqual(result.retryable, false);
|
||||
assert.match(result.message, /search failed/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from 'path';
|
||||
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
import type { AnilistRateLimiter } from './rate-limiter';
|
||||
import { resolveAnilistSeasonMedia } from './season-resolver';
|
||||
|
||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
|
||||
@@ -24,6 +25,12 @@ export interface AnilistPostWatchUpdateResult {
|
||||
export interface AnilistPostWatchUpdateOptions {
|
||||
rateLimiter?: AnilistRateLimiter;
|
||||
season?: number | null;
|
||||
/**
|
||||
* Pinned AniList media id (from a character dictionary manual override). When set,
|
||||
* the search/season resolution is skipped entirely.
|
||||
*/
|
||||
mediaId?: number | null;
|
||||
logInfo?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface AnilistGraphQlError {
|
||||
@@ -35,23 +42,15 @@ interface AnilistGraphQlResponse<T> {
|
||||
errors?: AnilistGraphQlError[];
|
||||
}
|
||||
|
||||
interface AnilistSearchData {
|
||||
Page?: {
|
||||
media?: Array<{
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnilistMediaEntryData {
|
||||
Media?: {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
} | null;
|
||||
mediaListEntry?: {
|
||||
progress?: number | null;
|
||||
status?: string | null;
|
||||
@@ -154,32 +153,6 @@ function buildGuessitTitle(title: string, alternativeTitle: string | null): stri
|
||||
return title;
|
||||
}
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function titleMentionsSeason(title: string, season: number): boolean {
|
||||
const normalized = normalizeTitle(title);
|
||||
return (
|
||||
normalized.includes(`season ${season}`) ||
|
||||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
|
||||
normalized.includes(`s${season}`)
|
||||
);
|
||||
}
|
||||
|
||||
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return [];
|
||||
const candidates =
|
||||
typeof season === 'number' &&
|
||||
Number.isInteger(season) &&
|
||||
season > 1 &&
|
||||
!titleMentionsSeason(trimmed, season)
|
||||
? [`${trimmed} Season ${season}`, trimmed]
|
||||
: [trimmed];
|
||||
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
|
||||
}
|
||||
|
||||
async function anilistGraphQl<T>(
|
||||
accessToken: string,
|
||||
query: string,
|
||||
@@ -216,38 +189,22 @@ function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | nul
|
||||
return firstError?.message ?? null;
|
||||
}
|
||||
|
||||
function pickBestSearchResult(
|
||||
title: string,
|
||||
episode: number,
|
||||
media: Array<{
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
}>,
|
||||
): { id: number; title: string; episodes: number | null } | null {
|
||||
const filtered = media.filter((item) => {
|
||||
const totalEpisodes = item.episodes;
|
||||
return totalEpisodes === null || totalEpisodes >= episode;
|
||||
});
|
||||
const candidates = filtered.length > 0 ? filtered : media;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const normalizedTarget = normalizeTitle(title);
|
||||
const exact = candidates.find((item) => {
|
||||
const titles = [item.title?.romaji, item.title?.english, item.title?.native]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizeTitle(value));
|
||||
return titles.includes(normalizedTarget);
|
||||
});
|
||||
|
||||
const selected = exact ?? candidates[0]!;
|
||||
const selectedTitle =
|
||||
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
|
||||
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
|
||||
/** The season resolver signals failure by throwing; anilistGraphQl reports it in-band. */
|
||||
function createAnilistSeasonQueryExecutor(
|
||||
accessToken: string,
|
||||
options: AnilistPostWatchUpdateOptions,
|
||||
) {
|
||||
return async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
||||
const response = await anilistGraphQl<T>(accessToken, query, variables, options);
|
||||
const error = firstErrorMessage(response);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
if (!response.data) {
|
||||
throw new Error('AniList response missing data');
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||
@@ -321,52 +278,53 @@ export async function updateAnilistPostWatchProgress(
|
||||
episode: number,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistPostWatchUpdateResult> {
|
||||
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
||||
let searchError: string | null = null;
|
||||
let pickTitle = title;
|
||||
const searchCandidates = buildSearchCandidates(title, options.season);
|
||||
for (const search of searchCandidates) {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
`
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ search },
|
||||
options,
|
||||
);
|
||||
searchError = firstErrorMessage(searchResponse);
|
||||
if (searchError) {
|
||||
break;
|
||||
}
|
||||
media = searchResponse.data?.Page?.media ?? [];
|
||||
if (media.length > 0) {
|
||||
pickTitle = search;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pinnedMediaId =
|
||||
typeof options.mediaId === 'number' && Number.isInteger(options.mediaId) && options.mediaId > 0
|
||||
? options.mediaId
|
||||
: null;
|
||||
|
||||
if (searchError) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `AniList search failed: ${searchError}`,
|
||||
};
|
||||
}
|
||||
let mediaId = pinnedMediaId;
|
||||
let resolvedTitle: string | null = null;
|
||||
let resolvedEpisodes: number | null = null;
|
||||
|
||||
const picked = pickBestSearchResult(pickTitle, episode, media);
|
||||
if (!picked) {
|
||||
return { status: 'error', message: 'AniList search returned no matches.' };
|
||||
if (mediaId === null) {
|
||||
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
|
||||
try {
|
||||
resolution = await resolveAnilistSeasonMedia(
|
||||
{ title, season: options.season, episode },
|
||||
{
|
||||
execute: createAnilistSeasonQueryExecutor(accessToken, options),
|
||||
logInfo: options.logInfo,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `AniList search failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolution) {
|
||||
// A well-formed search that matched nothing is deterministic for this title, so
|
||||
// requeueing it just burns rate limit. Transient failures throw and are caught above.
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message: 'AniList search returned no matches.',
|
||||
};
|
||||
}
|
||||
if (!resolution.seasonResolved) {
|
||||
// Updating the season 1 entry here is worse than not updating at all.
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message: `AniList update skipped: could not find season ${resolution.requestedSeason} of "${title}" (only matched "${resolution.title}"). Pick the right entry with the character dictionary AniList override.`,
|
||||
};
|
||||
}
|
||||
|
||||
mediaId = resolution.id;
|
||||
resolvedTitle = resolution.title;
|
||||
resolvedEpisodes = resolution.episodes;
|
||||
}
|
||||
|
||||
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
||||
@@ -375,6 +333,12 @@ export async function updateAnilistPostWatchProgress(
|
||||
query ($mediaId: Int!) {
|
||||
Media(id: $mediaId, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
status
|
||||
@@ -382,7 +346,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id },
|
||||
{ mediaId },
|
||||
options,
|
||||
);
|
||||
const entryError = firstErrorMessage(entryResponse);
|
||||
@@ -393,21 +357,34 @@ export async function updateAnilistPostWatchProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
|
||||
const entryMedia = entryResponse.data?.Media ?? null;
|
||||
const pickedTitle =
|
||||
resolvedTitle ||
|
||||
entryMedia?.title?.english?.trim() ||
|
||||
entryMedia?.title?.romaji?.trim() ||
|
||||
entryMedia?.title?.native?.trim() ||
|
||||
title;
|
||||
const pickedEpisodes =
|
||||
resolvedEpisodes ??
|
||||
(typeof entryMedia?.episodes === 'number' && entryMedia.episodes > 0
|
||||
? entryMedia.episodes
|
||||
: null);
|
||||
|
||||
const entry = entryMedia?.mediaListEntry ?? null;
|
||||
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||
message: `AniList update not possible: "${pickedTitle}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentProgress = entry.progress ?? 0;
|
||||
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
|
||||
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
|
||||
message: `AniList already at episode ${currentProgress} (${pickedTitle}).`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -422,7 +399,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{
|
||||
mediaId: picked.id,
|
||||
mediaId,
|
||||
progress: episode,
|
||||
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
||||
},
|
||||
@@ -436,7 +413,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
return {
|
||||
status: 'updated',
|
||||
message: shouldMarkCompleted
|
||||
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
|
||||
: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||
? `AniList updated "${pickedTitle}" to episode ${episode} and marked it completed.`
|
||||
: `AniList updated "${pickedTitle}" to episode ${episode}.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,17 +201,45 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
});
|
||||
|
||||
const searchCalls: Array<{ search: string }> = [];
|
||||
const relationCalls: number[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const raw = (init?.body as string | undefined) ?? '';
|
||||
const payload = JSON.parse(raw) as { variables: { search: string } };
|
||||
const search = payload.variables.search;
|
||||
searchCalls.push({ search });
|
||||
const payload = JSON.parse(raw) as { variables: { search?: string; id?: number } };
|
||||
|
||||
if (search.includes('Season 2')) {
|
||||
return Promise.resolve(createJsonResponse({ data: { Page: { media: [] } } }));
|
||||
if (typeof payload.variables.id === 'number') {
|
||||
relationCalls.push(payload.variables.id);
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
relations: {
|
||||
edges: [
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: {
|
||||
id: 20,
|
||||
type: 'ANIME',
|
||||
episodes: 25,
|
||||
format: 'TV',
|
||||
seasonYear: 2017,
|
||||
coverImage: { large: 'https://images.test/cover-s2.jpg', medium: null },
|
||||
title: {
|
||||
romaji: 'Little Witch Academia 2',
|
||||
english: 'Little Witch Academia 2',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
searchCalls.push({ search: String(payload.variables.search) });
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
data: {
|
||||
@@ -220,6 +248,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
{
|
||||
id: 19,
|
||||
episodes: 24,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
||||
title: {
|
||||
romaji: 'Little Witch Academia',
|
||||
@@ -251,9 +281,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
const stored = getCoverArt(db, videoId);
|
||||
|
||||
assert.equal(fetched, true);
|
||||
assert.equal(searchCalls.length, 2);
|
||||
assert.equal(searchCalls[0]!.search, 'Little Witch Academia Season 2');
|
||||
assert.equal(stored?.anilistId, 19);
|
||||
// One search on the bare title, then a sequel hop to reach season 2.
|
||||
assert.equal(searchCalls.length, 1);
|
||||
assert.equal(searchCalls[0]!.search, 'Little Witch Academia');
|
||||
assert.deepEqual(relationCalls, [19]);
|
||||
assert.equal(stored?.anilistId, 20);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
db.close();
|
||||
@@ -324,3 +356,187 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchIfMissing caches a no-match when the season cannot be resolved', 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, 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 {
|
||||
globalThis.fetch = originalFetch;
|
||||
db.close();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,43 +11,15 @@ import {
|
||||
runGuessit,
|
||||
type GuessAnilistMediaInfoDeps,
|
||||
} from './anilist-updater';
|
||||
import {
|
||||
resolveAnilistSeasonMedia,
|
||||
type AnilistQueryExecutor,
|
||||
type AnilistSeasonResolution,
|
||||
} from './season-resolver';
|
||||
|
||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
||||
|
||||
const SEARCH_QUERY = `
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
season
|
||||
seasonYear
|
||||
coverImage { large medium }
|
||||
title { romaji english native }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface AnilistMedia {
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | null;
|
||||
coverImage: { large: string | null; medium: string | null } | null;
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}
|
||||
|
||||
interface AnilistSearchResponse {
|
||||
data?: {
|
||||
Page?: {
|
||||
media?: AnilistMedia[];
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message?: string }>;
|
||||
}
|
||||
|
||||
export interface CoverArtFetcher {
|
||||
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
|
||||
}
|
||||
@@ -99,151 +71,45 @@ export function stripFilenameTags(raw: string): string {
|
||||
return title.trim().replace(/\s{2,}/g, ' ');
|
||||
}
|
||||
|
||||
function removeSeasonHint(title: string): string {
|
||||
return title
|
||||
.replace(/\bseason\s*\d+\b/gi, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function extractCandidateSeasonHints(text: string): Set<number> {
|
||||
const normalized = normalizeTitle(text);
|
||||
const matches = [
|
||||
...normalized.matchAll(/\bseason\s*(\d{1,2})\b/gi),
|
||||
...normalized.matchAll(/\bs(\d{1,2})(?:\b|\D)/gi),
|
||||
];
|
||||
const values = new Set<number>();
|
||||
for (const match of matches) {
|
||||
const value = Number.parseInt(match[1]!, 10);
|
||||
if (Number.isInteger(value)) {
|
||||
values.add(value);
|
||||
}
|
||||
class AnilistRateLimitedError extends Error {
|
||||
constructor() {
|
||||
super('Anilist rate limit reached');
|
||||
this.name = 'AnilistRateLimitedError';
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function isSeasonMentioned(titles: string[], season: number | null): boolean {
|
||||
if (!season) {
|
||||
return false;
|
||||
}
|
||||
const hints = titles.flatMap((title) => [...extractCandidateSeasonHints(title)]);
|
||||
return hints.includes(season);
|
||||
}
|
||||
|
||||
function pickBestSearchResult(
|
||||
title: string,
|
||||
episode: number | null,
|
||||
season: number | null,
|
||||
media: AnilistMedia[],
|
||||
): { id: number; title: string } | null {
|
||||
const cleanedTitle = removeSeasonHint(title);
|
||||
const targets = [title, cleanedTitle]
|
||||
.map(normalizeTitle)
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
const filtered =
|
||||
episode === null
|
||||
? media
|
||||
: media.filter((item) => {
|
||||
const total = item.episodes;
|
||||
return total === null || total >= episode;
|
||||
});
|
||||
const candidates = filtered.length > 0 ? filtered : media;
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scored = candidates.map((item) => {
|
||||
const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizeTitle(value));
|
||||
|
||||
let score = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
if (candidateTitles.includes(target)) {
|
||||
score += 120;
|
||||
continue;
|
||||
}
|
||||
if (candidateTitles.some((itemTitle) => itemTitle.includes(target))) {
|
||||
score += 30;
|
||||
}
|
||||
if (candidateTitles.some((itemTitle) => target.includes(itemTitle))) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
if (episode !== null && item.episodes === episode) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
if (season !== null && isSeasonMentioned(candidateTitles, season)) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
return { item, score };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.item.id - a.item.id;
|
||||
});
|
||||
|
||||
const selected = scored[0]!;
|
||||
const selectedTitle =
|
||||
selected.item.title?.english ??
|
||||
selected.item.title?.romaji ??
|
||||
selected.item.title?.native ??
|
||||
title;
|
||||
return { id: selected.item.id, title: selectedTitle };
|
||||
}
|
||||
|
||||
function buildSearchCandidates(parsed: CoverArtCandidate): string[] {
|
||||
const candidateTitles = [
|
||||
...(parsed.source === 'guessit' && parsed.season !== null && parsed.season > 1
|
||||
? [`${parsed.title} Season ${parsed.season}`]
|
||||
: []),
|
||||
parsed.title,
|
||||
];
|
||||
return candidateTitles
|
||||
.map((title) => title.trim())
|
||||
.filter((title, index, all) => title.length > 0 && all.indexOf(title) === index);
|
||||
}
|
||||
|
||||
async function searchAnilist(
|
||||
async function executeAnilistQuery<T>(
|
||||
rateLimiter: AnilistRateLimiter,
|
||||
title: string,
|
||||
): Promise<{ media: AnilistMedia[]; rateLimited: boolean }> {
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
await rateLimiter.acquire();
|
||||
|
||||
const res = await fetch(ANILIST_GRAPHQL_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: title } }),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
rateLimiter.recordResponse(res.headers);
|
||||
|
||||
if (res.status === 429) {
|
||||
return { media: [], rateLimited: true };
|
||||
throw new AnilistRateLimitedError();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as AnilistSearchResponse;
|
||||
const mediaList = json.data?.Page?.media;
|
||||
if (!mediaList || mediaList.length === 0) {
|
||||
return { media: [], rateLimited: false };
|
||||
const json = (await res.json()) as { data?: T; errors?: Array<{ message?: string }> };
|
||||
const firstError = json.errors?.find((entry) => Boolean(entry?.message));
|
||||
if (firstError?.message) {
|
||||
throw new Error(firstError.message);
|
||||
}
|
||||
|
||||
return { media: mediaList, rateLimited: false };
|
||||
if (!json.data) {
|
||||
throw new Error('Anilist response missing data');
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Buffer | null> {
|
||||
@@ -376,49 +242,57 @@ export function createCoverArtFetcher(
|
||||
|
||||
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
|
||||
const searchBase = parsedInfo?.title ?? cleaned;
|
||||
const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
|
||||
const searchTitles = searchBase === cleaned ? [searchBase] : ([searchBase, cleaned] as const);
|
||||
|
||||
const effectiveCandidates = searchCandidates.includes(cleaned)
|
||||
? searchCandidates
|
||||
: [...searchCandidates, cleaned];
|
||||
const execute: AnilistQueryExecutor = (query, variables) =>
|
||||
executeAnilistQuery(rateLimiter, query, variables);
|
||||
|
||||
let selected: AnilistMedia | null = null;
|
||||
let rateLimited = false;
|
||||
|
||||
for (const candidate of effectiveCandidates) {
|
||||
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', candidate, videoId);
|
||||
|
||||
try {
|
||||
const result = await searchAnilist(rateLimiter, candidate);
|
||||
rateLimited = result.rateLimited;
|
||||
if (result.media.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const picked = pickBestSearchResult(
|
||||
searchBase,
|
||||
parsedInfo?.episode ?? null,
|
||||
parsedInfo?.season ?? null,
|
||||
result.media,
|
||||
let resolution: AnilistSeasonResolution | null = null;
|
||||
try {
|
||||
for (const searchTitle of searchTitles) {
|
||||
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', searchTitle, videoId);
|
||||
resolution = await resolveAnilistSeasonMedia(
|
||||
{
|
||||
title: searchTitle,
|
||||
season: parsedInfo?.season ?? null,
|
||||
episode: parsedInfo?.episode ?? null,
|
||||
},
|
||||
{ execute, logInfo: (message) => logger.info('%s', message) },
|
||||
);
|
||||
if (picked) {
|
||||
const match = result.media.find((media) => media.id === picked.id);
|
||||
if (match) {
|
||||
selected = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('cover-art: Anilist search error for "%s": %s', candidate, err);
|
||||
if (resolution) break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AnilistRateLimitedError) {
|
||||
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rateLimited) {
|
||||
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
||||
logger.error('cover-art: Anilist search error for "%s": %s', searchBase, err);
|
||||
return false;
|
||||
}
|
||||
|
||||
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" (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;
|
||||
if (!selected) {
|
||||
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
||||
upsertCoverArt(db, videoId, {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
ANILIST_SEASON_RELATIONS_QUERY,
|
||||
ANILIST_SEASON_SEARCH_QUERY,
|
||||
pickByAirOrder,
|
||||
resolveAnilistSeasonMedia,
|
||||
stripSeasonSuffix,
|
||||
type AnilistSeasonMedia,
|
||||
} from './season-resolver';
|
||||
|
||||
/** Real AniList payloads for "My Teen Romantic Comedy SNAFU", trimmed to used fields. */
|
||||
const OREGAIRU_SEARCH: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: {
|
||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru.',
|
||||
english: 'My Teen Romantic Comedy SNAFU',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 18753,
|
||||
episodes: 1,
|
||||
format: 'OVA',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU OVA', native: null },
|
||||
},
|
||||
{
|
||||
id: 108489,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2020,
|
||||
title: {
|
||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Kan',
|
||||
english: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 20698,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2015,
|
||||
title: {
|
||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Zoku',
|
||||
english: 'My Teen Romantic Comedy SNAFU TOO!',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const OREGAIRU_RELATIONS: Record<
|
||||
number,
|
||||
Array<{ relationType: string; node: AnilistSeasonMedia }>
|
||||
> = {
|
||||
14813: [
|
||||
{
|
||||
relationType: 'SIDE_STORY',
|
||||
node: OREGAIRU_SEARCH[1]!,
|
||||
},
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: OREGAIRU_SEARCH[3]!,
|
||||
},
|
||||
],
|
||||
20698: [
|
||||
{
|
||||
relationType: 'PREQUEL',
|
||||
node: OREGAIRU_SEARCH[0]!,
|
||||
},
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: OREGAIRU_SEARCH[2]!,
|
||||
},
|
||||
],
|
||||
108489: [
|
||||
{
|
||||
relationType: 'PREQUEL',
|
||||
node: OREGAIRU_SEARCH[3]!,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createExecutor(
|
||||
search: AnilistSeasonMedia[],
|
||||
relations: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {},
|
||||
) {
|
||||
const searches: string[] = [];
|
||||
const relationLookups: number[] = [];
|
||||
const execute = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
||||
if (query === ANILIST_SEASON_SEARCH_QUERY) {
|
||||
searches.push(String(variables.search));
|
||||
return { Page: { media: search } } as T;
|
||||
}
|
||||
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
|
||||
const id = Number(variables.id);
|
||||
relationLookups.push(id);
|
||||
return {
|
||||
Media: {
|
||||
relations: {
|
||||
edges: (relations[id] ?? []).map((edge) => ({
|
||||
relationType: edge.relationType,
|
||||
node: { ...edge.node, type: 'ANIME' },
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
throw new Error(`unexpected query: ${query}`);
|
||||
};
|
||||
return { execute, searches, relationLookups };
|
||||
}
|
||||
|
||||
test('stripSeasonSuffix drops release-name season markers', () => {
|
||||
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
|
||||
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
|
||||
assert.equal(stripSeasonSuffix('Some Show 2nd Season'), 'Some Show');
|
||||
assert.equal(stripSeasonSuffix('Some Show'), 'Some Show');
|
||||
});
|
||||
|
||||
test('resolves season 3 by walking sequel relations', async () => {
|
||||
const { execute, searches, relationLookups } = createExecutor(
|
||||
OREGAIRU_SEARCH,
|
||||
OREGAIRU_RELATIONS,
|
||||
);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 108489);
|
||||
assert.equal(result?.title, 'My Teen Romantic Comedy SNAFU Climax!');
|
||||
assert.equal(result?.episodes, 12);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
// Searches the bare title only; "Season 3" returns nothing on AniList.
|
||||
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
|
||||
assert.deepEqual(relationLookups, [14813, 20698]);
|
||||
});
|
||||
|
||||
test('season 1 resolves to the anchor without relation lookups', async () => {
|
||||
const { execute, relationLookups } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 1, episode: 5 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 14813);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
assert.equal(result?.via, 'anchor');
|
||||
assert.deepEqual(relationLookups, []);
|
||||
});
|
||||
|
||||
test('strips a season marker already present in the parsed title', async () => {
|
||||
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU Season 2', season: 2, episode: 3 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
|
||||
assert.equal(result?.id, 20698);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
test('falls back to air order when the sequel chain is broken', async () => {
|
||||
const { execute } = createExecutor(OREGAIRU_SEARCH, {});
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 108489);
|
||||
assert.equal(result?.via, 'air-order');
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
test('reports seasonResolved false rather than silently using season 1', async () => {
|
||||
const { execute } = createExecutor([OREGAIRU_SEARCH[0]!], {});
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 14813);
|
||||
assert.equal(result?.seasonResolved, false);
|
||||
assert.equal(result?.via, 'anchor');
|
||||
assert.equal(result?.requestedSeason, 3);
|
||||
});
|
||||
|
||||
test('does not filter the anchor by an episode count the season 1 entry cannot have', async () => {
|
||||
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
// Season 2 episode 13 exceeds nothing, but season 3 episode 13 exceeds Climax!'s 12.
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 13 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 20698);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
test('prefers the TV sequel when a franchise branches into other formats', async () => {
|
||||
const branching: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {
|
||||
1: [
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: {
|
||||
id: 3,
|
||||
episodes: 6,
|
||||
format: 'ONA',
|
||||
seasonYear: 2019,
|
||||
title: { english: 'Spinoff' },
|
||||
},
|
||||
},
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: { id: 2, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show 2' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const { execute } = createExecutor(
|
||||
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2018, title: { english: 'Show' } }],
|
||||
branching,
|
||||
);
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
|
||||
|
||||
assert.equal(result?.id, 2);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
});
|
||||
|
||||
test('air-order fallback refuses when the anchor is not the earliest entry', () => {
|
||||
const anchor: AnilistSeasonMedia = {
|
||||
id: 2,
|
||||
format: 'TV',
|
||||
seasonYear: 2020,
|
||||
title: { english: 'Show Later' },
|
||||
};
|
||||
const picked = pickByAirOrder(anchor, 2, [
|
||||
anchor,
|
||||
{ id: 1, format: 'TV', seasonYear: 2015, title: { english: 'Show Earlier' } },
|
||||
]);
|
||||
|
||||
assert.equal(picked, null);
|
||||
});
|
||||
|
||||
test('returns null when the search yields nothing', async () => {
|
||||
const { execute } = createExecutor([]);
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Nothing', season: 2 }, { execute });
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('refuses a season beyond the sequel-hop cap instead of returning a partial walk', async () => {
|
||||
// A 30-entry sequel chain: hopping the capped number of times would land on the wrong
|
||||
// season and report it resolved, so the walk must decline outright.
|
||||
const chain: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {};
|
||||
for (let id = 1; id < 30; id += 1) {
|
||||
chain[id] = [
|
||||
{
|
||||
relationType: 'SEQUEL',
|
||||
node: {
|
||||
id: id + 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2000 + id,
|
||||
title: { english: `Show ${id + 1}` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
const { execute, relationLookups } = createExecutor(
|
||||
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2000, title: { english: 'Show' } }],
|
||||
chain,
|
||||
);
|
||||
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 25 }, { execute });
|
||||
|
||||
assert.equal(result?.id, 1);
|
||||
assert.equal(result?.seasonResolved, false);
|
||||
assert.equal(result?.via, 'anchor');
|
||||
// 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');
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* AniList has no concept of "season N": sequels are separate media with their own
|
||||
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
|
||||
* nothing and callers silently fall back to the season 1 entry.
|
||||
*
|
||||
* This module resolves a parsed (title, season) pair to the right media by locating a
|
||||
* season 1 anchor and then walking AniList SEQUEL relations, with an air-order fallback
|
||||
* when the relation chain is incomplete. When the seasonal entry cannot be located it
|
||||
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
||||
*/
|
||||
|
||||
export interface AnilistSeasonMediaTitle {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
}
|
||||
|
||||
export interface AnilistSeasonMedia {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
format?: string | null;
|
||||
seasonYear?: number | null;
|
||||
startDate?: { year?: number | null } | null;
|
||||
synonyms?: Array<string | null> | null;
|
||||
coverImage?: { large?: string | null; medium?: string | null } | null;
|
||||
title?: AnilistSeasonMediaTitle | null;
|
||||
}
|
||||
|
||||
export type AnilistQueryExecutor = <T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
export type AnilistSeasonResolutionVia = 'anchor' | 'sequel-chain' | 'air-order';
|
||||
|
||||
export interface AnilistSeasonResolution {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
media: AnilistSeasonMedia;
|
||||
/** False when a season >= 2 was requested but no seasonal entry could be located. */
|
||||
seasonResolved: boolean;
|
||||
requestedSeason: number | null;
|
||||
via: AnilistSeasonResolutionVia;
|
||||
}
|
||||
|
||||
export interface ResolveAnilistSeasonMediaInput {
|
||||
title: string;
|
||||
season?: number | null;
|
||||
episode?: number | null;
|
||||
}
|
||||
|
||||
export interface ResolveAnilistSeasonMediaDeps {
|
||||
execute: AnilistQueryExecutor;
|
||||
logInfo?: (message: string) => void;
|
||||
}
|
||||
|
||||
const MEDIA_FIELDS = `
|
||||
id
|
||||
episodes
|
||||
format
|
||||
seasonYear
|
||||
startDate { year }
|
||||
synonyms
|
||||
coverImage { large medium }
|
||||
title { romaji english native }
|
||||
`;
|
||||
|
||||
export const ANILIST_SEASON_SEARCH_QUERY = `
|
||||
query ($search: String!) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||
${MEDIA_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ANILIST_SEASON_RELATIONS_QUERY = `
|
||||
query ($id: Int!) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id
|
||||
relations {
|
||||
edges {
|
||||
relationType
|
||||
node {
|
||||
type
|
||||
${MEDIA_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface AnilistSeasonSearchResponse {
|
||||
Page?: {
|
||||
media?: AnilistSeasonMedia[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AnilistSeasonRelationsResponse {
|
||||
Media?: {
|
||||
relations?: {
|
||||
edges?: Array<{
|
||||
relationType?: string | null;
|
||||
node?: (AnilistSeasonMedia & { type?: string | null }) | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Formats that can carry a numbered TV season, best first. */
|
||||
const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
||||
|
||||
const MAX_SEQUEL_HOPS = 12;
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops season markers a release name carries but AniList titles never do,
|
||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
||||
*/
|
||||
export function stripSeasonSuffix(title: string): string {
|
||||
return title
|
||||
.replace(/\bseason\s*\d{1,2}\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ')
|
||||
.replace(/\bs\d{1,2}\b/gi, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mediaTitles(media: AnilistSeasonMedia): string[] {
|
||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => normalizeTitle(value));
|
||||
}
|
||||
|
||||
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
||||
return (
|
||||
media.title?.english?.trim() ||
|
||||
media.title?.romaji?.trim() ||
|
||||
media.title?.native?.trim() ||
|
||||
fallback.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function episodeCount(media: AnilistSeasonMedia): number | null {
|
||||
return typeof media.episodes === 'number' && media.episodes > 0 ? media.episodes : null;
|
||||
}
|
||||
|
||||
function airYear(media: AnilistSeasonMedia): number | null {
|
||||
if (typeof media.seasonYear === 'number' && media.seasonYear > 0) {
|
||||
return media.seasonYear;
|
||||
}
|
||||
const startYear = media.startDate?.year;
|
||||
return typeof startYear === 'number' && startYear > 0 ? startYear : null;
|
||||
}
|
||||
|
||||
function isSeasonalFormat(media: AnilistSeasonMedia): boolean {
|
||||
const format = (media.format || '').toUpperCase();
|
||||
return SEASONAL_FORMAT_PRIORITY.includes(format);
|
||||
}
|
||||
|
||||
function formatRank(media: AnilistSeasonMedia): number {
|
||||
const index = SEASONAL_FORMAT_PRIORITY.indexOf((media.format || '').toUpperCase());
|
||||
return index < 0 ? SEASONAL_FORMAT_PRIORITY.length : index;
|
||||
}
|
||||
|
||||
function toResolution(
|
||||
media: AnilistSeasonMedia,
|
||||
fallbackTitle: string,
|
||||
season: number | null,
|
||||
via: AnilistSeasonResolutionVia,
|
||||
seasonResolved: boolean,
|
||||
): AnilistSeasonResolution {
|
||||
return {
|
||||
id: media.id,
|
||||
title: displayTitle(media, fallbackTitle),
|
||||
episodes: episodeCount(media),
|
||||
media,
|
||||
seasonResolved,
|
||||
requestedSeason: season,
|
||||
via,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the franchise anchor (season 1 entry) for a search result set. The anchor is
|
||||
* matched on title alone - season is handled by walking relations from here.
|
||||
*/
|
||||
export function pickAnchorMedia(
|
||||
title: string,
|
||||
media: AnilistSeasonMedia[],
|
||||
options: { episode?: number | null } = {},
|
||||
): AnilistSeasonMedia | null {
|
||||
if (media.length === 0) return null;
|
||||
|
||||
const episode = options.episode;
|
||||
const episodeFiltered =
|
||||
typeof episode === 'number' && episode > 0
|
||||
? media.filter((entry) => {
|
||||
const total = episodeCount(entry);
|
||||
return total === null || total >= episode;
|
||||
})
|
||||
: media;
|
||||
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||
|
||||
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
|
||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
||||
);
|
||||
|
||||
const scored = pool.map((entry, index) => {
|
||||
const candidateTitles = mediaTitles(entry);
|
||||
let score = 0;
|
||||
for (const target of targets) {
|
||||
if (candidateTitles.includes(target)) {
|
||||
score += 120;
|
||||
continue;
|
||||
}
|
||||
if (candidateTitles.some((candidate) => candidate.startsWith(target))) {
|
||||
score += 40;
|
||||
} else if (candidateTitles.some((candidate) => candidate.includes(target))) {
|
||||
score += 25;
|
||||
}
|
||||
if (candidateTitles.some((candidate) => target.includes(candidate))) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
if (isSeasonalFormat(entry)) {
|
||||
score += 30;
|
||||
}
|
||||
return { entry, score, index };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.index !== b.index) return a.index - b.index;
|
||||
return a.entry.id - b.entry.id;
|
||||
});
|
||||
|
||||
return scored[0]?.entry ?? null;
|
||||
}
|
||||
|
||||
async function walkSequelChain(
|
||||
anchor: AnilistSeasonMedia,
|
||||
season: number,
|
||||
deps: ResolveAnilistSeasonMediaDeps,
|
||||
): Promise<AnilistSeasonMedia | null> {
|
||||
// Walking fewer hops than requested would land on the wrong season and report it as
|
||||
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
|
||||
const hops = season - 1;
|
||||
if (hops > MAX_SEQUEL_HOPS) {
|
||||
return null;
|
||||
}
|
||||
const visited = new Set<number>([anchor.id]);
|
||||
let current = anchor;
|
||||
|
||||
for (let hop = 0; hop < hops; hop += 1) {
|
||||
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
|
||||
const response = await deps.execute<AnilistSeasonRelationsResponse>(
|
||||
ANILIST_SEASON_RELATIONS_QUERY,
|
||||
{ id: current.id },
|
||||
);
|
||||
|
||||
const sequels = (response.Media?.relations?.edges ?? [])
|
||||
.map((edge) => edge ?? null)
|
||||
.filter(
|
||||
(
|
||||
edge,
|
||||
): edge is {
|
||||
relationType?: string | null;
|
||||
node: AnilistSeasonMedia & { type?: string | null };
|
||||
} =>
|
||||
Boolean(edge?.node) &&
|
||||
(edge?.relationType || '').toUpperCase() === 'SEQUEL' &&
|
||||
(edge?.node?.type || 'ANIME').toUpperCase() === 'ANIME',
|
||||
)
|
||||
.map((edge) => edge.node)
|
||||
.filter((node) => !visited.has(node.id));
|
||||
|
||||
if (sequels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A franchise can branch (a TV sequel plus an ONA spinoff); prefer the TV line.
|
||||
sequels.sort((a, b) => {
|
||||
const rankDelta = formatRank(a) - formatRank(b);
|
||||
if (rankDelta !== 0) return rankDelta;
|
||||
const yearDelta =
|
||||
(airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (yearDelta !== 0) return yearDelta;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
current = sequels[0]!;
|
||||
visited.add(current.id);
|
||||
}
|
||||
|
||||
return current.id === anchor.id ? null : current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for franchises whose SEQUEL edges are missing or route through a format we
|
||||
* skipped: order the franchise's seasonal entries by air date and index by season.
|
||||
*/
|
||||
export function pickByAirOrder(
|
||||
anchor: AnilistSeasonMedia,
|
||||
season: number,
|
||||
media: AnilistSeasonMedia[],
|
||||
): AnilistSeasonMedia | null {
|
||||
const anchorTitles = mediaTitles(anchor).map((value) => stripSeasonSuffix(value));
|
||||
const anchorBase = anchorTitles.sort((a, b) => a.length - b.length)[0];
|
||||
if (!anchorBase) return null;
|
||||
|
||||
const franchise = media.filter((entry) => {
|
||||
if (!isSeasonalFormat(entry)) return false;
|
||||
if (entry.id === anchor.id) return true;
|
||||
return mediaTitles(entry).some((candidate) =>
|
||||
stripSeasonSuffix(candidate).includes(anchorBase),
|
||||
);
|
||||
});
|
||||
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) ?? 0) - (airYear(b) ?? 0);
|
||||
if (yearDelta !== 0) return yearDelta;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
// The anchor has to be the first entry, otherwise this ordering is not a season list.
|
||||
if (ordered[0]?.id !== anchor.id) return null;
|
||||
|
||||
return ordered[season - 1] ?? null;
|
||||
}
|
||||
|
||||
export async function resolveAnilistSeasonMedia(
|
||||
input: ResolveAnilistSeasonMediaInput,
|
||||
deps: ResolveAnilistSeasonMediaDeps,
|
||||
): Promise<AnilistSeasonResolution | null> {
|
||||
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
|
||||
if (!searchTitle) return null;
|
||||
|
||||
const season =
|
||||
typeof input.season === 'number' && Number.isInteger(input.season) && input.season > 0
|
||||
? input.season
|
||||
: null;
|
||||
|
||||
const response = await deps.execute<AnilistSeasonSearchResponse>(ANILIST_SEASON_SEARCH_QUERY, {
|
||||
search: searchTitle,
|
||||
});
|
||||
const media = (response.Page?.media ?? []).filter(
|
||||
(entry): entry is AnilistSeasonMedia => Boolean(entry) && typeof entry.id === 'number',
|
||||
);
|
||||
if (media.length === 0) return null;
|
||||
|
||||
// Season 1 (or unknown) resolves against the episode count; later seasons must not,
|
||||
// because the anchor is season 1 and may be shorter than the requested episode.
|
||||
const anchor = pickAnchorMedia(searchTitle, media, {
|
||||
episode: season === null || season <= 1 ? input.episode : null,
|
||||
});
|
||||
if (!anchor) return null;
|
||||
|
||||
if (season === null || season <= 1) {
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true);
|
||||
}
|
||||
|
||||
let chainError: unknown = null;
|
||||
let viaChain: AnilistSeasonMedia | null = null;
|
||||
try {
|
||||
viaChain = await walkSequelChain(anchor, season, deps);
|
||||
} catch (error) {
|
||||
chainError = error;
|
||||
}
|
||||
if (viaChain) {
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||
);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
if (viaAirOrder) {
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
||||
);
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent;
|
||||
// surface that so callers retry instead of reporting an unresolvable season.
|
||||
if (chainError) {
|
||||
throw chainError;
|
||||
}
|
||||
|
||||
deps.logInfo?.(
|
||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||
);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false);
|
||||
}
|
||||
+11
-4
@@ -3616,10 +3616,12 @@ const {
|
||||
);
|
||||
},
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
mediaId,
|
||||
logInfo: (message) => logger.info(message),
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
@@ -3655,9 +3657,12 @@ const {
|
||||
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key, title, episode, season) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode, season);
|
||||
enqueueRetry: (key, title, episode, season, mediaId) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode, season, mediaId);
|
||||
},
|
||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||
resolvePinnedAnilistMediaId: ({ mediaPath, mediaTitle, guess }) =>
|
||||
characterDictionaryRuntime.resolvePinnedMediaId({ mediaPath, mediaTitle, guess }),
|
||||
markRetryFailure: (key, message) => {
|
||||
anilistUpdateQueue.markFailure(key, message);
|
||||
},
|
||||
@@ -3665,10 +3670,12 @@ const {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
},
|
||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
mediaId,
|
||||
logInfo: (message) => logger.info(message),
|
||||
}),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
|
||||
@@ -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