mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 20:12:54 -07:00
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:
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user