From 9f85c55d23ef83b99a820ac1427d2172c682567e Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 28 Jul 2026 01:52:01 -0700 Subject: [PATCH] fix(anilist): resolve later seasons via sequel relations, not title gues - Add season-resolver: search base title, walk AniList SEQUEL relations (falling back to air-date ordering) to reach the requested season, since AniList has no numbered-season entries - Stop writing progress to the season 1 entry when the season can't be resolved; skip the update and point at a manual override instead - Scope character dictionary AniList overrides by detected season as well as directory, and let an override pin the media id used for watch-progress updates too - Reuse the season resolver for cover art lookup so per-season art doesn't fall back to season 1 - Don't cache an unresolved season as a match, so the fallback can't stick for later episodes - Update docs and changelog entry for the fix --- changes/fix-anilist-season-resolution.md | 8 + docs-site/anilist-integration.md | 20 +- docs-site/character-dictionary.md | 4 +- .../anilist/anilist-update-queue.test.ts | 47 ++ .../services/anilist/anilist-update-queue.ts | 33 +- .../services/anilist/anilist-updater.test.ts | 140 +++++- src/core/services/anilist/anilist-updater.ts | 219 ++++------ .../anilist/cover-art-fetcher.test.ts | 48 ++- .../services/anilist/cover-art-fetcher.ts | 245 +++-------- .../services/anilist/season-resolver.test.ts | 289 +++++++++++++ src/core/services/anilist/season-resolver.ts | 404 ++++++++++++++++++ src/main.ts | 15 +- src/main/character-dictionary-runtime.ts | 32 +- .../character-dictionary-runtime/fetch.ts | 87 +--- .../manual-selection-snapshot.test.ts | 70 +++ .../manual-selection.test.ts | 128 ++++++ .../manual-selection.ts | 61 ++- .../snapshot-refresh.test.ts | 99 +++++ .../character-dictionary-runtime/types.ts | 3 + .../runtime/anilist-post-watch-main-deps.ts | 21 +- src/main/runtime/anilist-post-watch.test.ts | 152 +++++++ src/main/runtime/anilist-post-watch.ts | 45 +- 22 files changed, 1733 insertions(+), 437 deletions(-) create mode 100644 changes/fix-anilist-season-resolution.md create mode 100644 src/core/services/anilist/season-resolver.test.ts create mode 100644 src/core/services/anilist/season-resolver.ts diff --git a/changes/fix-anilist-season-resolution.md b/changes/fix-anilist-season-resolution.md new file mode 100644 index 00000000..f384dc04 --- /dev/null +++ b/changes/fix-anilist-season-resolution.md @@ -0,0 +1,8 @@ +type: fixed +area: anilist + +- Season 2 and later files now resolve to the right AniList entry. AniList has no numbered seasons (sequels are separate entries titled `Zoku`, `Kan`, `2nd Season`), so the old ` Season N` search returned nothing and silently fell back to the season 1 entry: an Oregairu S03 episode loaded the season 1 character dictionary and pushed watch progress onto season 1. SubMiner now searches the base title and walks AniList `SEQUEL` relations to reach the requested season, ordering the franchise's TV entries by air date when the relation chain is incomplete. +- When the season still cannot be located, SubMiner no longer writes progress to the season 1 entry; it skips the update and points at the manual AniList override instead. The character dictionary logs the same warning and states which entry it fell back to. +- Character dictionary overrides are now scoped by detected season as well as directory, so seasons kept in one flat folder no longer share (or overwrite) each other's override, and an override now also pins the entry used for AniList watch progress: correcting a wrong match once fixes both the dictionary and progress tracking. +- Cover art lookup uses the same season-aware resolution, so per-season art no longer falls back to the season 1 cover. +- An unresolvable season is no longer cached as if it were a normal match, so the fallback cannot silently stick for every later episode of that series. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index e924e61b..3faa7caf 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo The update flow: 1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable. -2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count. +2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches). 3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped. 4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands). @@ -93,15 +93,15 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w } ``` -| Option | Values | Description | -| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | -| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | -| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | -| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | -| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) | -| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) | -| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | -| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | +| Option | Values | Description | +| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | +| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | +| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) | +| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) | +| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | +| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 168f4bb4..c6eca7ea 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -223,7 +223,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}' ``` -SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. +SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. + +An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking. ## Managing Loaded Entries diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index f0d08011..37ba54c8 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -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']); +}); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index 9772c626..6d2f117d 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -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, diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index 7f13f672..3b99e1c4 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -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; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index acc32737..bbb6da8f 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -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,47 @@ 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) { + return { status: 'error', 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 +327,12 @@ export async function updateAnilistPostWatchProgress( query ($mediaId: Int!) { Media(id: $mediaId, type: ANIME) { id + episodes + title { + romaji + english + native + } mediaListEntry { progress status @@ -382,7 +340,7 @@ export async function updateAnilistPostWatchProgress( } } `, - { mediaId: picked.id }, + { mediaId }, options, ); const entryError = firstErrorMessage(entryResponse); @@ -393,21 +351,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 +393,7 @@ export async function updateAnilistPostWatchProgress( } `, { - mediaId: picked.id, + mediaId, progress: episode, status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT', }, @@ -436,7 +407,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}.`, }; } diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 2accdf9c..0c5c8522 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -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(); diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index 270ed477..b55f3c3c 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -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,44 @@ 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) { + logger.warn( + 'cover-art: could not find season %d of "%s", using "%s"', + resolution.requestedSeason, + searchBase, + resolution.title, + ); + } + + const selected = resolution?.media ?? null; if (!selected) { logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase); upsertCoverArt(db, videoId, { diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts new file mode 100644 index 00000000..4a768f3e --- /dev/null +++ b/src/core/services/anilist/season-resolver.test.ts @@ -0,0 +1,289 @@ +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, []); +}); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts new file mode 100644 index 00000000..eab49290 --- /dev/null +++ b/src/core/services/anilist/season-resolver.ts @@ -0,0 +1,404 @@ +/** + * 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; + + const ordered = [...franchise].sort((a, b) => { + const yearDelta = + (airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER); + 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); +} diff --git a/src/main.ts b/src/main.ts index 880a5193..75154828 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index d2d97bb5..cad02577 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -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; + }, }; } diff --git a/src/main/character-dictionary-runtime/fetch.ts b/src/main/character-dictionary-runtime/fetch.ts index c1fd8bbe..fe33c0f0 100644 --- a/src/main/character-dictionary-runtime/fetch.ts +++ b/src/main/character-dictionary-runtime/fetch.ts @@ -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( diff --git a/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts b/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts index 3a213dd7..20bd715d 100644 --- a/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts +++ b/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts @@ -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, + ); +}); diff --git a/src/main/character-dictionary-runtime/manual-selection.test.ts b/src/main/character-dictionary-runtime/manual-selection.test.ts index 1b58e9a7..d365653e 100644 --- a/src/main/character-dictionary-runtime/manual-selection.test.ts +++ b/src/main/character-dictionary-runtime/manual-selection.test.ts @@ -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); +}); diff --git a/src/main/character-dictionary-runtime/manual-selection.ts b/src/main/character-dictionary-runtime/manual-selection.ts index f70e6f16..e5b99f9f 100644 --- a/src/main/character-dictionary-runtime/manual-selection.ts +++ b/src/main/character-dictionary-runtime/manual-selection.ts @@ -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]); diff --git a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts index 9677f21c..47593d3c 100644 --- a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts +++ b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts @@ -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; + } +}); diff --git a/src/main/character-dictionary-runtime/types.ts b/src/main/character-dictionary-runtime/types.ts index 41e581f4..6c668604 100644 --- a/src/main/character-dictionary-runtime/types.ts +++ b/src/main/character-dictionary-runtime/types.ts @@ -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; }; diff --git a/src/main/runtime/anilist-post-watch-main-deps.ts b/src/main/runtime/anilist-post-watch-main-deps.ts index c0edcdd6..6b11ce6b 100644 --- a/src/main/runtime/anilist-post-watch-main-deps.ts +++ b/src/main/runtime/anilist-post-watch-main-deps.ts @@ -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), diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 57ad173e..06826d11 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -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]); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index cbd81e84..932532b6 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -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}`);