From 765df15048a12db3d7f14c662ec7f43147fa499b Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 24 Jul 2026 18:03:49 -0700 Subject: [PATCH] fix(character-dictionary): scope manual overrides to season directory - Match/replace AniList overrides by directory scope instead of exact series key, so later episodes keep the override even when filename guesses differ - Add tests covering differing-guess matches and same-directory override replacement - Update docs-site/character-dictionary.md and add changelog entry --- ...x-character-dictionary-season-overrides.md | 4 ++ docs-site/character-dictionary.md | 2 +- .../manual-selection.test.ts | 67 +++++++++++++++++++ .../manual-selection.ts | 26 ++++++- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 changes/fix-character-dictionary-season-overrides.md diff --git a/changes/fix-character-dictionary-season-overrides.md b/changes/fix-character-dictionary-season-overrides.md new file mode 100644 index 00000000..e07dc1c1 --- /dev/null +++ b/changes/fix-character-dictionary-season-overrides.md @@ -0,0 +1,4 @@ +type: fixed +area: character dictionary + +- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index f1dd14b0..168f4bb4 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -223,7 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}' ``` -Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous 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 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. ## Managing Loaded Entries diff --git a/src/main/character-dictionary-runtime/manual-selection.test.ts b/src/main/character-dictionary-runtime/manual-selection.test.ts index fd9b066d..9cf71ce5 100644 --- a/src/main/character-dictionary-runtime/manual-selection.test.ts +++ b/src/main/character-dictionary-runtime/manual-selection.test.ts @@ -82,6 +82,73 @@ test('manual selection store persists overrides and matches later episodes in th }); }); +test('manual selection store applies a season-directory override when later episode guesses differ', async () => { + const userDataPath = makeTempDir(); + const store = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const directory = '/Volumes/jellyfin/anime/toaru-kagaku-no-railgun/Season-2'; + const selectedKey = buildCharacterDictionarySeriesKey({ + mediaPath: `${directory}/A Certain Scientific Railgun S - 10 - Critical.mkv`, + mediaTitle: 'A Certain Scientific Railgun S - 10 - Critical.mkv', + guess: { + title: 'Critical', + season: null, + episode: 10, + source: 'guessit', + }, + }); + await store.setOverride({ + seriesKey: selectedKey, + mediaId: 16049, + mediaTitle: 'A Certain Scientific Railgun S', + staleMediaIds: [1057], + }); + + const laterEpisodeKey = buildCharacterDictionarySeriesKey({ + mediaPath: `${directory}/A Certain Scientific Railgun S - 16 - Sisters.mkv`, + mediaTitle: 'A Certain Scientific Railgun S - 16 - Sisters.mkv', + guess: { + title: 'Sisters', + season: null, + episode: 16, + source: 'guessit', + }, + }); + + assert.deepEqual(await store.getOverride(laterEpisodeKey), { + seriesKey: selectedKey, + mediaId: 16049, + mediaTitle: 'A Certain Scientific Railgun S', + staleMediaIds: [1057], + }); +}); + +test('manual selection store replaces older guessed keys for the same season directory', async () => { + const userDataPath = makeTempDir(); + const store = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const directoryKey = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2'; + const firstKey = `${directoryKey}--critical`; + const laterKey = `${directoryKey}--sisters`; + await store.setOverride({ + seriesKey: firstKey, + mediaId: 1057, + mediaTitle: 'Ippatsu Kiki Musume', + staleMediaIds: [], + }); + await store.setOverride({ + seriesKey: laterKey, + mediaId: 16049, + mediaTitle: 'A Certain Scientific Railgun S', + staleMediaIds: [1057], + }); + + assert.deepEqual(await store.getOverride(firstKey), { + seriesKey: laterKey, + mediaId: 16049, + mediaTitle: 'A Certain Scientific Railgun S', + staleMediaIds: [1057], + }); +}); + test('manual selection store resolves legacy unscoped override keys', async () => { const userDataPath = makeTempDir(); const overrideDir = path.join(userDataPath, 'character-dictionaries'); diff --git a/src/main/character-dictionary-runtime/manual-selection.ts b/src/main/character-dictionary-runtime/manual-selection.ts index d401eb73..f1321ce5 100644 --- a/src/main/character-dictionary-runtime/manual-selection.ts +++ b/src/main/character-dictionary-runtime/manual-selection.ts @@ -107,6 +107,11 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] { return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)]; } +function getDirectoryScope(seriesKey: string): string | null { + const scopedSeparatorIndex = seriesKey.indexOf('--'); + return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex); +} + export function buildCharacterDictionarySeriesKey(input: { mediaPath: string | null; mediaTitle: string | null; @@ -139,6 +144,20 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa const match = overrides.find((entry) => entry.seriesKey === candidate); if (match) return match; } + const directoryScope = getDirectoryScope(seriesKey); + if (directoryScope) { + const scopedMatches = overrides.filter( + (entry) => getDirectoryScope(entry.seriesKey) === directoryScope, + ); + const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId)); + if (scopedMatches.length > 0 && selectedMediaIds.size === 1) { + const latest = scopedMatches.at(-1)!; + return { + ...latest, + staleMediaIds: dedupeNumbers(scopedMatches.flatMap((entry) => entry.staleMediaIds)), + }; + } + } return null; }, setOverride: async (selection: CharacterDictionaryManualSelection): Promise => { @@ -146,8 +165,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa if (!normalized) { throw new Error('Invalid character dictionary manual selection.'); } - const remaining = readOverrides(filePath).filter( - (entry) => entry.seriesKey !== normalized.seriesKey, + const directoryScope = getDirectoryScope(normalized.seriesKey); + const remaining = readOverrides(filePath).filter((entry) => + directoryScope + ? getDirectoryScope(entry.seriesKey) !== directoryScope + : entry.seriesKey !== normalized.seriesKey, ); writeOverrides(filePath, [...remaining, normalized]); },