Fix JLPT underline color drift and AniList skipped-threshold sync

- Replace JLPT `text-decoration` underlines with `border-bottom` so Chromium selection/hover cannot repaint them to another annotation's color
- Lock JLPT underline color for combined annotation selectors (known, n+1, frequency) and character hover/selection states
- Trigger AniList post-watch check on every mpv time-position update to catch skipped completion thresholds
- Fall back to filename-parser season/episode when guessit omits them
This commit is contained in:
2026-05-03 20:11:24 -07:00
parent 42576d99b1
commit 12e1e783c9
11 changed files with 267 additions and 42 deletions
@@ -22,6 +22,32 @@ test('guessAnilistMediaInfo uses guessit output when available', async () => {
});
});
test('guessAnilistMediaInfo fills missing guessit episode from filename parser', async () => {
const result = await guessAnilistMediaInfo('/tmp/Guessit Title S01E09.mkv', null, {
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
});
assert.deepEqual(result, {
title: 'Guessit Title',
season: 1,
episode: 9,
source: 'guessit',
});
});
test('guessAnilistMediaInfo parses Little Witch Academia release filename', async () => {
const filename =
'/tmp/Little Witch Academia (2017) - S01E02 - 002 - Papiliodia [Bluray-1080p][10bit][h265][AC3 2.0][JA].mkv';
const result = await guessAnilistMediaInfo(filename, null, {
runGuessit: async () => JSON.stringify({ title: 'Little Witch Academia' }),
});
assert.deepEqual(result, {
title: 'Little Witch Academia',
season: 1,
episode: 2,
source: 'guessit',
});
});
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null, {
runGuessit: async () => {
@@ -54,7 +80,7 @@ test('guessAnilistMediaInfo uses basename for guessit input', async () => {
]);
assert.deepEqual(result, {
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
season: null,
season: 1,
episode: 1,
source: 'guessit',
});
+3 -2
View File
@@ -236,12 +236,13 @@ export async function guessAnilistMediaInfo(
const season = firstPositiveInteger(parsed.season);
const year = firstYear(parsed.year);
if (title) {
const fallback = parseMediaInfo(target);
return {
title: buildGuessitTitle(title, alternativeTitle),
...(alternativeTitle ? { alternativeTitle } : {}),
...(year ? { year } : {}),
season,
episode,
season: season ?? fallback.season,
episode: episode ?? fallback.episode,
source: 'guessit',
};
}
@@ -199,6 +199,10 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('post-watch-error'),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
@@ -212,6 +216,7 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
'time:12.5',
'progress:normal',
'presence',
'post-watch',
'pause:yes',
'progress:force',
'presence',
@@ -105,12 +105,17 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
}) {
return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
};
}
@@ -149,6 +149,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
+65 -25
View File
@@ -794,11 +794,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
@@ -806,11 +803,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .word.word-jlpt-n2 {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
@@ -818,11 +812,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .word.word-jlpt-n3 {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
@@ -830,11 +821,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .word.word-jlpt-n4 {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
@@ -842,11 +830,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .word.word-jlpt-n5 {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n5-color, #8aadf4);
}
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
@@ -997,29 +982,84 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
-webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
}
#subtitleRoot .word.word-jlpt-n1.word-known,
#subtitleRoot .word.word-jlpt-n1.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n1.word-frequency-single,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-2,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-3,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-4,
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-5,
#subtitleRoot .word.word-jlpt-n1:hover,
#subtitleRoot .word.word-jlpt-n1 .c:hover,
#subtitleRoot .word.word-jlpt-n1::selection,
#subtitleRoot .word.word-jlpt-n1 .c::selection {
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
-webkit-text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
}
#subtitleRoot .word.word-jlpt-n2.word-known,
#subtitleRoot .word.word-jlpt-n2.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n2.word-frequency-single,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-2,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-3,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-4,
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-5,
#subtitleRoot .word.word-jlpt-n2:hover,
#subtitleRoot .word.word-jlpt-n2 .c:hover,
#subtitleRoot .word.word-jlpt-n2::selection,
#subtitleRoot .word.word-jlpt-n2 .c::selection {
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
-webkit-text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
}
#subtitleRoot .word.word-jlpt-n3.word-known,
#subtitleRoot .word.word-jlpt-n3.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n3.word-frequency-single,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-2,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-3,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-4,
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-5,
#subtitleRoot .word.word-jlpt-n3:hover,
#subtitleRoot .word.word-jlpt-n3 .c:hover,
#subtitleRoot .word.word-jlpt-n3::selection,
#subtitleRoot .word.word-jlpt-n3 .c::selection {
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
-webkit-text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
}
#subtitleRoot .word.word-jlpt-n4.word-known,
#subtitleRoot .word.word-jlpt-n4.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n4.word-frequency-single,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-2,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-3,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-4,
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-5,
#subtitleRoot .word.word-jlpt-n4:hover,
#subtitleRoot .word.word-jlpt-n4 .c:hover,
#subtitleRoot .word.word-jlpt-n4::selection,
#subtitleRoot .word.word-jlpt-n4 .c::selection {
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
-webkit-text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
}
#subtitleRoot .word.word-jlpt-n5.word-known,
#subtitleRoot .word.word-jlpt-n5.word-n-plus-one,
#subtitleRoot .word.word-jlpt-n5.word-frequency-single,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-1,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-2,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-3,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-4,
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-5,
#subtitleRoot .word.word-jlpt-n5:hover,
#subtitleRoot .word.word-jlpt-n5 .c:hover,
#subtitleRoot .word.word-jlpt-n5::selection,
#subtitleRoot .word.word-jlpt-n5 .c::selection {
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
-webkit-text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
}
#subtitleRoot
+70 -14
View File
@@ -220,8 +220,20 @@ function normalizeCssSelector(selector: string): string {
.trim();
}
function buildJlptUnderlineSelector(level: number): string {
return `#subtitleRoot .word.word-jlpt-n${level}`;
function buildJlptColorSelector(level: number): string {
const higherPriorityClasses = [
'.word-known',
'.word-n-plus-one',
'.word-name-match',
'.word-frequency-single',
'.word-frequency-band-1',
'.word-frequency-band-2',
'.word-frequency-band-3',
'.word-frequency-band-4',
'.word-frequency-band-5',
].join(', ');
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
}
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
@@ -887,20 +899,31 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, buildJlptUnderlineSelector(level));
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
assert.doesNotMatch(block, /-webkit-text-fill-color\s*:/);
assert.match(block, /text-decoration-line:\s*underline;/);
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
// JLPT tagging must never recolor the token text — other annotations own
// text color. JLPT also must not use `text-decoration: underline`,
// because Chromium repaints text-decoration during ::selection and the
// underline would adopt the other annotation's color during a Yomitan
// lookup. The underline is drawn by `border-bottom`, which is unaffected
// by ::selection and stays locked on the JLPT level color regardless of
// popup/selection state.
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
assert.doesNotMatch(plainJlptBlock, /text-decoration-line:\s*underline;/);
assert.match(
block,
new RegExp(`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
plainJlptBlock,
new RegExp(`border-bottom:\\s*2px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`),
`JLPT level must paint a permanent 2px border-bottom in the level color`,
);
// JLPT tagging must communicate level *only* via the underline; it must
// never recolor the token text. Other annotations (known, n+1, frequency,
// name match) are responsible for token text color.
const jlptOnlyColorBlock = extractClassBlock(cssText, buildJlptColorSelector(level));
assert.equal(
jlptOnlyColorBlock,
'',
`word-jlpt-n${level} (without other annotations) must not set text color — JLPT only paints the underline`,
);
assert.doesNotMatch(block, /border-bottom\s*:/);
assert.doesNotMatch(block, /padding-bottom\s*:/);
assert.doesNotMatch(block, /box-decoration-break\s*:/);
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
assert.doesNotMatch(block, /text-shadow\s*:/);
}
for (const selector of [
@@ -1077,6 +1100,39 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
'i',
),
);
for (const annotationClass of [
'word-known',
'word-n-plus-one',
'word-frequency-single',
'word-frequency-band-2',
]) {
const combinedAnnotationBlock = extractClassBlock(
cssText,
`#subtitleRoot .word.word-jlpt-n${level}.${annotationClass}`,
);
assert.match(
combinedAnnotationBlock,
new RegExp(
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
'i',
),
`combined JLPT ${annotationClass} selector should lock underline color`,
);
}
const jlptCharHoverBlock = extractClassBlock(
cssText,
`#subtitleRoot .word.word-jlpt-n${level} .c:hover`,
);
assert.match(
jlptCharHoverBlock,
new RegExp(
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
'i',
),
'JLPT character hover selector should lock underline color',
);
}
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');