diff --git a/backlog/tasks/task-140 - Prefer-parser-title-when-guessit-truncates-anime-name-for-character-dictionary-sync.md b/backlog/tasks/task-140 - Prefer-parser-title-when-guessit-truncates-anime-name-for-character-dictionary-sync.md new file mode 100644 index 0000000..e7792c3 --- /dev/null +++ b/backlog/tasks/task-140 - Prefer-parser-title-when-guessit-truncates-anime-name-for-character-dictionary-sync.md @@ -0,0 +1,38 @@ +--- +id: TASK-140 +title: Fix guessit title parsing for character dictionary sync +status: Done +assignee: [] +created_date: '2026-03-09 00:00' +updated_date: '2026-03-09 00:25' +labels: + - dictionary + - anilist + - bug + - guessit +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-updater.ts + - /home/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-updater.test.ts +priority: high +--- + +## Description + + +Fix AniList character dictionary auto-sync for filenames where `guessit` misparses the full path and our title extraction keeps only the first array segment, causing AniList resolution to match the wrong anime and abort merged dictionary refresh. + + +## Acceptance Criteria + +- [x] #1 AniList media guessing passes basename-only targets to `guessit` so parent folder names do not corrupt series title detection. +- [x] #2 Guessit title arrays are combined into one usable title instead of truncating to the first segment. +- [x] #3 Regression coverage includes the Bunny Girl Senpai filename shape that previously resolved to the wrong AniList entry. +- [x] #4 Verification confirms the targeted AniList guessing tests pass. + + +## Implementation Notes + + +Root repro: `guessit` parsed the Bunny Girl Senpai full path as `title: ["Rascal", "Does-not-Dream-of-Bunny-Girl-Senapi"]`, and our `firstString` helper kept only `Rascal`, which resolved to AniList 3490 (`rayca`) and produced zero character results. Fixed by sending basename-only input to `guessit` and joining multi-part guessit title arrays. + diff --git a/changes/task-140.md b/changes/task-140.md new file mode 100644 index 0000000..15b40ba --- /dev/null +++ b/changes/task-140.md @@ -0,0 +1,4 @@ +type: fixed +area: dictionary + +- Fixed AniList media guessing for character dictionary auto-sync by using filename-only `guessit` input and preserving multi-part guessit titles instead of truncating them to the first segment. diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index e699dfa..e42bcff 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -34,6 +34,44 @@ test('guessAnilistMediaInfo falls back to parser when guessit fails', async () = }); }); +test('guessAnilistMediaInfo uses basename for guessit input', async () => { + const mediaPath = + '/truenas/jellyfin/anime/Rascal-Does-not-Dream-of-Bunny-Girl-Senapi/Season-1/Rascal Does Not Dream of Bunny Girl Senpai (2018) - S01E01 - 001 - My Senpai Is a Bunny Girl [Bluray-1080p][10bit][x265][Opus 2.0][JA]-Subs.mkv'; + const seenTargets: string[] = []; + const result = await guessAnilistMediaInfo(mediaPath, null, { + runGuessit: async (target) => { + seenTargets.push(target); + return JSON.stringify({ + title: 'Rascal Does Not Dream of Bunny Girl Senpai', + episode: 1, + }); + }, + }); + assert.deepEqual(seenTargets, [ + 'Rascal Does Not Dream of Bunny Girl Senpai (2018) - S01E01 - 001 - My Senpai Is a Bunny Girl [Bluray-1080p][10bit][x265][Opus 2.0][JA]-Subs.mkv', + ]); + assert.deepEqual(result, { + title: 'Rascal Does Not Dream of Bunny Girl Senpai', + episode: 1, + source: 'guessit', + }); +}); + +test('guessAnilistMediaInfo joins multi-part guessit titles', async () => { + const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null, { + runGuessit: async () => + JSON.stringify({ + title: ['Rascal', 'Does-not-Dream-of-Bunny-Girl-Senpai'], + episode: 1, + }), + }); + assert.deepEqual(result, { + title: 'Rascal Does not Dream of Bunny Girl Senpai', + episode: 1, + source: 'guessit', + }); +}); + test('updateAnilistPostWatchProgress updates progress when behind', 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 f45bb5f..f4d4a0a 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -1,4 +1,5 @@ import * as childProcess from 'child_process'; +import * as path from 'path'; import { parseMediaInfo } from '../../../jimaku/utils'; @@ -90,6 +91,32 @@ function firstString(value: unknown): string | null { return null; } +function normalizeGuessitTitlePart(value: string): string { + return value + .replace(/[._]+/g, ' ') + .replace(/-/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function readGuessitTitle(value: unknown): string | null { + if (typeof value === 'string') { + const normalized = normalizeGuessitTitlePart(value); + return normalized.length > 0 ? normalized : null; + } + if (Array.isArray(value)) { + const parts = value + .filter((item): item is string => typeof item === 'string') + .map((item) => normalizeGuessitTitlePart(item)) + .filter((item) => item.length > 0); + if (parts.length === 0) { + return null; + } + return parts.join(' ').replace(/\s+/g, ' ').trim(); + } + return null; +} + function firstPositiveInteger(value: unknown): number | null { if (typeof value === 'number' && Number.isInteger(value) && value > 0) { return value; @@ -184,12 +211,13 @@ export async function guessAnilistMediaInfo( deps: GuessAnilistMediaInfoDeps = { runGuessit }, ): Promise { const target = mediaPath ?? mediaTitle; + const guessitTarget = mediaPath ? path.basename(mediaPath) : mediaTitle; - if (target && target.trim().length > 0) { + if (guessitTarget && guessitTarget.trim().length > 0) { try { - const stdout = await deps.runGuessit(target); + const stdout = await deps.runGuessit(guessitTarget); const parsed = JSON.parse(stdout) as Record; - const title = firstString(parsed.title); + const title = readGuessitTitle(parsed.title); const episode = firstPositiveInteger(parsed.episode); if (title) { return { title, episode, source: 'guessit' };