refactor(main): fold remaining main-deps builders into their handlers (#308)

* refactor(main): drop remaining pass-through main-deps builders

Jellyfin client-info/setup-window/remote, overlay shortcuts/visibility/
layout/window-factory, config hot-reload, and startup bootstrap factory
builders only forwarded deps. Callers now pass deps to the handlers
directly; createWatchConfigPathHandler moves into config-hot-reload-handlers.

* refactor(main): fold small-logic main-deps builders into their handlers

Tray click, mpv client factory, startup bootstrap exit callbacks, overlay
runtime options, and dictionary roots/runtime builders move their logic
into the owning handler or call site. The two duplicated dictionary-root
builders become one createBuildDictionaryRootsHandler(vendorDirName).

* refactor(main): fold cli command context builders into the factory

createCliCommandContextFactory now adapts appState and main services to
the CLI command context deps directly (socket-path change notification,
texthooker port/websocket URL, browser-open default). The pass-through
cli-command-context-main-deps and cli-command-context-deps layers are
removed; their behavioural tests now run against the factory's context.

* refactor(main): fold subtitle tokenization builder into the mpv composer

The tokenizer deps builder's logic (known-word lookup recording and the
character-dictionary gate on name matching) becomes
buildTokenizerRuntimeOptions in mpv-runtime-composer, tested through the
composer. The MeCab check and dictionary prewarm handlers move to
subtitle-tokenization-warmup.ts, and the unused BuiltMainDeps type is
removed from composer contracts.

* refactor(main): drop redundant tokenizer deps keys and cast

* refactor(main): end the main-deps builder pattern

mpv-main-event-main-deps becomes mpv-main-event-binding-deps
(createMpvMainEventBindingDeps), returning the binding deps directly
instead of a thunk; it owns the subtitle-recording state. The will-quit
cleanup adapter likewise returns its deps directly, and the pass-through
app-lifecycle activate builders are removed.

* docs: drop references to main-deps builders
This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 14:07:07 -07:00
1 parent 2a0e931325
commit 90fc7cf940
63 files changed
+1282 -2480

No files matched your search

@@ -0,0 +1,410 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
import { createMpvMainEventBindingDeps } from './mpv-main-event-binding-deps';
type MainDeps = Parameters<typeof createMpvMainEventBindingDeps>[0];
/**
* Builds the handlers with inert deps. `appStateOverrides` is merged over a blank app
* state; the full `appState` is returned so tests can mutate it and assert on it.
*/
function buildMainDeps(
appStateOverrides: Partial<MainDeps['appState']> = {},
depsOverrides: Partial<MainDeps> = {},
) {
const appState: MainDeps['appState'] = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
...appStateOverrides,
};
const handlers = createMpvMainEventBindingDeps({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
...depsOverrides,
});
return { handlers, appState };
}
test('secondary subtitle changes go to the injected handler instead of the overlay broadcast', () => {
const calls: string[] = [];
const injected = buildMainDeps(
{},
{
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
},
).handlers;
const fallback = buildMainDeps(
{},
{
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
},
).handlers;
injected.broadcastSecondarySubtitle('sec');
fallback.broadcastSecondarySubtitle('sec');
assert.deepEqual(calls, ['secondary:sec', 'broadcast:secondary-subtitle:set:sec']);
});
test('media duration is recorded for both immersion and AniList', () => {
const calls: string[] = [];
const { handlers } = buildMainDeps(
{ immersionTracker: { recordMediaDuration: (sec) => calls.push(`immersion:${sec}`) } },
{ recordAnilistMediaDuration: (sec) => calls.push(`anilist:${sec}`) },
);
handlers.recordMediaDuration(1234);
assert.deepEqual(calls, ['immersion:1234', 'anilist:1234']);
});
test('mpv main event main deps treat managed playback as quit-on-disconnect', () => {
const deps = buildMainDeps({
initialArgs: { managedPlayback: true },
overlayRuntimeInitialized: false,
}).handlers;
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
assert.equal(deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(), true);
});
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
const recorded: number[] = [];
const deps = buildMainDeps({
mpvClient: {
connected: false,
currentTimePos: 42,
requestProperty: async () => {
throw new Error('disconnected');
},
},
immersionTracker: {
recordPlaybackPosition: (time: number) => {
recorded.push(time);
},
},
currentMediaPath: '',
}).handlers;
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
assert.deepEqual(recorded, [42]);
});
test('media and subtitle-track transitions reset live subtitle-line deduplication', () => {
const recordedStarts: number[] = [];
const handlers = buildMainDeps({
immersionTracker: {
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
},
activeParsedSubtitleCues: null,
currentMediaPath: '/video-a.mkv',
}).handlers;
for (let index = 0; index < 8; index += 1) {
handlers.recordImmersionSubtitleLine('待って', index * 0.04, (index + 1) * 0.04);
}
assert.equal(recordedStarts.length, 4);
handlers.updateCurrentMediaPath('/video-b.mkv');
handlers.recordImmersionSubtitleLine('待って', 0.32, 0.36);
assert.equal(recordedStarts.length, 5);
for (let index = 9; index < 16; index += 1) {
handlers.recordImmersionSubtitleLine('待って', index * 0.04, (index + 1) * 0.04);
}
assert.equal(recordedStarts.length, 8);
assert.equal(typeof handlers.onSubtitleTrackChange, 'function');
handlers.onSubtitleTrackChange?.(2);
handlers.recordImmersionSubtitleLine('待って', 0.64, 0.68);
assert.equal(recordedStarts.length, 9);
});
test('subtitle-track transitions ignore stale parsed cues until replacement cues arrive', () => {
const recordedStarts: number[] = [];
const { handlers, appState } = buildMainDeps({
immersionTracker: {
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
},
activeParsedSubtitleCues: [{ startTime: 10, endTime: 14, text: '飛び上がる' }],
currentMediaPath: '/video-a.mkv',
});
handlers.recordImmersionSubtitleLine('飛び上がる', 10, 10.04);
handlers.onSubtitleTrackChange?.(2);
for (let index = 1; index <= 8; index += 1) {
handlers.recordImmersionSubtitleLine('飛び上がる', 10 + index * 0.04, 10 + (index + 1) * 0.04);
}
assert.equal(recordedStarts.length, 5);
appState.activeParsedSubtitleCues = [{ startTime: 20, endTime: 24, text: '飛び上がる' }];
handlers.recordImmersionSubtitleLine('飛び上がる', 20, 20.04);
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('subtitle timing history records lines on the video clock under sub-delay', () => {
const timing: Array<{ text: string; start: number; end: number }> = [];
const { handlers } = buildMainDeps({
mpvClient: { currentSubDelay: 2 },
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
});
handlers.recordSubtitleTiming('食べる', 5, 8);
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
}).handlers;
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Concurrent dialogue during the song is not part of the animation: it must be
// recorded as itself -- without the fragment lines beside it -- and must not cause
// the song line to be recorded again when the animation frames resume.
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
// Every recorded cue is remembered, so each authored line still records exactly once.
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
// A backward seek means the user is rewatching: the timing history (a viewing log)
// records the revisited line again, while immersion stays once-per-media.
handlers.onTimePosUpdate?.(30);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3);
// Jumping back to a brief previous line moves time-pos by less than the general
// seek threshold. It is still a backward seek, so the revisited line records
// again; otherwise multi-line copy would keep treating the later line as current.
handlers.onTimePosUpdate?.(3.9);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Tiny time-pos jitter is not a seek and must not re-record the line.
handlers.onTimePosUpdate?.(3.0);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.equal(timing.length, 5);
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3);
assert.equal(timing.length, 5);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
const { handlers, appState } = buildMainDeps({
mpvClient: { currentTimePos: 2 },
immersionTracker: { recordSubtitleLine: () => {} },
subtitleTimingTracker: { recordSubtitle: () => {} },
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass' as const,
},
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
activeParsedSubtitleSource: 'track-a.ass' as string | null,
currentMediaPath: '/video.mkv',
});
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
// The new track's cues arrive only after an async re-parse; until then, the old
// track's canonical lyric must not replace the new track's live text.
handlers.onSubtitleTrackChange?.(2);
assert.deepEqual(appState.activeParsedSubtitleCues, []);
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
});
test('subtitle recorders drop ASS furigana events the same way the display does', () => {
// Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events
// positioned above their base line, and mpv lists them as their own live lines.
const cues = parseAssCues(
[
'[Script Info]',
'PlayResX: 960',
'PlayResY: 540',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。',
].join('\n'),
);
assert.deepEqual(
cues.map((cue) => cue.text),
[
'こんな短時間で 悪夢 見んなよ。',
'(平)暗記教科は もう',
'とにかく片っ端から覚えるんだよ。',
],
);
const immersion: string[] = [];
const timing: string[] = [];
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 299.7 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: cues,
}).handlers;
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
assert.equal(handlers.resolveSubtitleText?.(liveText), expected);
handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54);
handlers.recordSubtitleTiming(liveText, 299.63, 303.54);
assert.deepEqual(immersion, [expected]);
assert.deepEqual(timing, [expected]);
});
test('a resolved line survives recording while a fragment grid is on screen', () => {
// Fragment stripping drops every line it can trace back to a cue, and returns nothing
// at all when a fragment grid is nearby. Text the parsed cues already resolved is a
// complete line, not raw mpv output, so it must not be fed through that path.
const immersion: string[] = [];
const timing: string[] = [];
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 3.2 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: [
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
{
startTime: 3,
endTime: 6,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
}).handlers;
// The grid fragment beside the lyric keeps canonical substitution from applying, so
// recording falls to the parsed view -- which is where the whole line is recovered.
const liveText = '飛び越え\nMaid';
assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて');
handlers.recordImmersionSubtitleLine(liveText, 3, 6);
handlers.recordSubtitleTiming(liveText, 3, 6);
assert.deepEqual(immersion, ['飛び越えてみたくて']);
assert.deepEqual(timing, ['飛び越えてみたくて']);
// A spacer event left as literal control debris is not a subtitle line. The display
// drops it, so no recorder may keep it either.
assert.equal(handlers.resolveSubtitleText?.('\\'), '');
handlers.recordImmersionSubtitleLine('\\', 3, 6);
handlers.recordSubtitleTiming('\\', 3, 6);
assert.equal(immersion.length, 1);
assert.equal(timing.length, 1);
});