feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)

* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
This commit is contained in:
2026-03-24 00:01:24 -07:00
committed by GitHub
parent c17f0a4080
commit 5feed360ca
219 changed files with 12778 additions and 1052 deletions

View File

@@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
});
assert.equal(state.mediaGuessPromise, null);
});
test('ensureAnilistMediaGuess skips youtube playback urls', async () => {
let state: AnilistMediaGuessRuntimeState = {
mediaKey: 'https://www.youtube.com/watch?v=abc123',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
let calls = 0;
const ensureGuess = createEnsureAnilistMediaGuessHandler({
getState: () => state,
setState: (next) => {
state = next;
},
resolveMediaPathForJimaku: (value) => value,
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123',
getCurrentMediaTitle: () => 'Video',
guessAnilistMediaInfo: async () => {
calls += 1;
return { title: 'Show', season: null, episode: 1, source: 'guessit' };
},
});
const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123');
assert.equal(guess, null);
assert.equal(calls, 0);
assert.equal(state.mediaGuess, null);
});

View File

@@ -1,4 +1,5 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { isYoutubeMediaPath } from './youtube-playback';
export type AnilistMediaGuessRuntimeState = {
mediaKey: string | null;
@@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
if (state.mediaKey !== mediaKey) {
return null;
}
if (isYoutubeMediaPath(mediaKey)) {
return null;
}
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
return state.mediaDurationSec;
}
@@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: {
if (state.mediaKey !== mediaKey) {
return null;
}
if (isYoutubeMediaPath(mediaKey)) {
return null;
}
if (state.mediaGuess) {
return state.mediaGuess;
}

View File

@@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => {
assert.equal(getEmptyKey(), null);
});
test('get current anilist media key skips youtube playback urls', () => {
const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ',
});
const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
getCurrentMediaPath: () => 'https://youtu.be/abc123',
});
assert.equal(getYoutubeKey(), null);
assert.equal(getShortYoutubeKey(), null);
});
test('reset anilist media tracking clears duration/guess/probe state', () => {
let mediaKey: string | null = 'old';
let mediaDurationSec: number | null = 123;

View File

@@ -1,11 +1,15 @@
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
import { isYoutubeMediaPath } from './youtube-playback';
export function createGetCurrentAnilistMediaKeyHandler(deps: {
getCurrentMediaPath: () => string | null;
}) {
return (): string | null => {
const mediaPath = deps.getCurrentMediaPath()?.trim();
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) {
return null;
}
return mediaPath;
};
}

View File

@@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
assert.ok(calls.includes('inflight:true'));
assert.ok(calls.includes('inflight:false'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => {
calls.push('guess');
return { title: 'Show', season: null, episode: 1 };
},
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => {
calls.push('process-retry');
return { ok: true, message: 'noop' };
},
refreshAnilistClientSecretState: async () => {
calls.push('refresh-token');
return 'token';
},
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(calls, []);
});

View File

@@ -1,3 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
type AnilistGuess = {
title: string;
episode: number | null;
@@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
if (!mediaKey || !deps.hasMpvClient()) {
return;
}
if (isYoutubeMediaPath(mediaKey)) {
return;
}
if (deps.getTrackedMediaKey() !== mediaKey) {
deps.resetTrackedMedia(mediaKey);
}

View File

@@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => {
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),

View File

@@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,

View File

@@ -63,6 +63,7 @@ test('cli command context factory composes main deps and context handlers', () =
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},

View File

@@ -84,7 +84,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),

View File

@@ -41,6 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -95,6 +96,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
deps.generateCharacterDictionary(targetPath),
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),

View File

@@ -50,6 +50,7 @@ function createDeps() {
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},

View File

@@ -41,6 +41,7 @@ export type CliCommandContextFactoryDeps = {
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -95,6 +96,7 @@ export function createCliCommandContext(
generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,

View File

@@ -33,3 +33,28 @@ test('cli command runtime handler applies precheck and forwards command with con
'cli:initial:ctx',
]);
});
test('cli command runtime handler prepares overlay prerequisites before overlay runtime commands', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
},
createCliCommandContext: () => {
calls.push('context');
return { id: 'ctx' };
},
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
calls.push(`cli:${source}:${context.id}`);
},
});
handler({ settings: true } as never);
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
});

View File

@@ -23,6 +23,12 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);
if (
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args)
) {
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
}
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};

View File

@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -67,6 +68,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {
patchAnkiConnectEnabled: () => {},

View File

@@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => {
assert.equal(tracker, 'unchanged');
});
test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => {
const calls: string[] = [];
const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1';
try {
let tracker: unknown = 'unchanged';
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => {
calls.push('getResolvedConfig');
return makeConfig();
},
getConfiguredDbPath: () => {
calls.push('getConfiguredDbPath');
return '/tmp/subminer.db';
},
createTrackerService: () => {
calls.push('createTrackerService');
return {};
},
setTracker: (nextTracker) => {
tracker = nextTracker;
},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
});
handler();
assert.equal(calls.includes('getResolvedConfig'), false);
assert.equal(calls.includes('getConfiguredDbPath'), false);
assert.equal(calls.includes('createTrackerService'), false);
assert.equal(calls.includes('seedTracker'), false);
assert.equal(tracker, 'unchanged');
assert.ok(
calls.includes(
'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.',
),
);
} finally {
if (originalEnv === undefined) {
delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
} else {
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv;
}
}
});
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
const calls: string[] = [];
const trackerInstance = { kind: 'tracker' };

View File

@@ -23,6 +23,8 @@ type ImmersionTrackingConfig = {
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING';
type ImmersionTrackerServiceParams = {
dbPath: string;
policy: ImmersionTrackerPolicy;
@@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = {
export function createImmersionTrackerStartupHandler(
deps: ImmersionTrackerStartupDeps,
): () => void {
const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1';
return () => {
if (isSessionTrackingDisabled) {
deps.logInfo(
`Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`,
);
return;
}
const config = deps.getResolvedConfig();
if (config.immersionTracking?.enabled === false) {
deps.logInfo('Immersion tracking disabled in config');

View File

@@ -13,6 +13,10 @@ test('initial args handler no-ops without initial args', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {
handled = true;
@@ -36,6 +40,10 @@ test('initial args handler ensures tray in background mode', () => {
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -61,6 +69,10 @@ test('initial args handler auto-connects mpv when needed', () => {
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {
logged = true;
},
@@ -83,6 +95,14 @@ test('initial args handler forwards args to cli handler', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
seenSources.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
seenSources.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
seenSources.push(source);
@@ -93,6 +113,37 @@ test('initial args handler forwards args to cli handler', () => {
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => {
const calls: string[] = [];
const args = { settings: true } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
@@ -106,6 +157,10 @@ test('initial args handler can ensure tray outside background mode when requeste
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -133,6 +188,10 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});

View File

@@ -14,6 +14,10 @@ export function createHandleInitialArgsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => MpvClientLike | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -39,6 +43,13 @@ export function createHandleInitialArgsHandler(deps: {
mpvClient.connect();
}
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
deps.ensureOverlayStartupPrereqs();
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
}
deps.handleCliCommand(initialArgs, 'initial');
};
}

View File

@@ -15,6 +15,10 @@ test('initial args main deps builder maps runtime callbacks and state readers',
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`info:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
})();
@@ -26,9 +30,13 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
assert.equal(deps.isOverlayRuntimeInitialized(), false);
deps.ensureTray();
deps.ensureOverlayStartupPrereqs();
deps.initializeOverlayRuntime();
deps.logInfo('x');
deps.handleCliCommand(args, 'initial');
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
});

View File

@@ -9,6 +9,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => { connected: boolean; connect: () => void } | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -21,6 +25,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),
getMpvClient: () => deps.getMpvClient(),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
logInfo: (message: string) => deps.logInfo(message),
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
});

View File

@@ -16,6 +16,10 @@ test('initial args runtime handler composes main deps and runs initial command f
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -44,6 +48,10 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -67,6 +75,10 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});

View File

@@ -13,6 +13,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
buildMpvCommandDeps: () => ({
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -10,6 +10,7 @@ test('handle mpv command handler forwards command and built deps', () => {
const deps = {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -7,6 +7,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({
triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'),
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.replayCurrentSubtitle();
@@ -34,6 +38,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
assert.deepEqual(calls, [
'subsync',
'palette',
'youtube-picker',
'osd:hello',
'replay',
'next',

View File

@@ -6,6 +6,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
return (): MpvCommandFromIpcRuntimeDeps => ({
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),

View File

@@ -26,7 +26,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push('post-watch');
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),

View File

@@ -116,3 +116,45 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
});
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
deps.setCurrentSubText('sub');
assert.equal(typeof deps.setCurrentSubText, 'function');
});

View File

@@ -12,6 +12,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getModalActive: () => true,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getWindowTracker: () => tracker,
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);

View File

@@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(),

View File

@@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
export function buildWindowsMpvLaunchArgs(
targets: string[],
extraArgs: string[] = [],
): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
}
export function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
extraArgs: string[] = [],
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) {
@@ -51,7 +55,7 @@ export function launchWindowsMpv(
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,896 @@
import os from 'node:os';
import path from 'node:path';
import type {
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type {
YoutubeTrackOption,
YoutubeTrackProbeResult,
} from '../../core/services/youtube/track-probe';
import {
chooseDefaultYoutubeTrackIds,
normalizeYoutubeTrackSelection,
} from '../../core/services/youtube/track-selection';
import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
type YoutubeFlowMode = 'download' | 'generate';
type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
retimeYoutubePrimaryTrack: (input: {
targetUrl: string;
primaryTrack: YoutubeTrackOption;
primaryPath: string;
secondaryTrack: YoutubeTrackOption | null;
secondaryPath: string | null;
}) => Promise<string>;
openPicker: YoutubeFlowOpenPicker;
pauseMpv: () => void;
resumeMpv: () => void;
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
wait: (ms: number) => Promise<void>;
waitForPlaybackWindowReady: () => Promise<void>;
waitForOverlayGeometryReady: () => Promise<void>;
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
reportSubtitleFailure: (message: string) => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
};
type YoutubeFlowSession = {
sessionId: string;
resolve: (request: YoutubePickerResolveRequest) => void;
reject: (error: Error) => void;
};
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
function createSessionId(): string {
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null {
if (!id) return null;
return tracks.find((track) => track.id === id) ?? null;
}
function normalizeOutputPath(value: string): string {
const trimmed = value.trim();
return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs');
}
function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
const frames = ['|', '/', '-', '\\'];
let timer: ReturnType<typeof setInterval> | null = null;
let frame = 0;
const stop = (): void => {
if (!timer) {
return;
}
clearInterval(timer);
timer = null;
};
const setMessage = (message: string): void => {
stop();
frame = 0;
showMpvOsd(message);
timer = setInterval(() => {
showMpvOsd(`${message} ${frames[frame % frames.length]}`);
frame += 1;
}, 180);
};
return {
setMessage,
stop,
};
}
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
deps.resumeMpv();
}
function suppressYoutubeSubtitleState(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
}
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
deps.focusOverlayWindow();
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
async function ensureSubtitleTrackSelection(input: {
deps: YoutubeFlowDeps;
property: 'sid' | 'secondary-sid';
targetId: number;
}): Promise<void> {
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
for (let attempt = 0; attempt < 4; attempt += 1) {
const currentId = parseTrackId(await input.deps.requestMpvProperty(input.property));
if (currentId === input.targetId) {
return;
}
await input.deps.wait(100);
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
}
}
function normalizeTrackListEntry(track: Record<string, unknown>): {
id: number | null;
lang: string;
title: string;
external: boolean;
externalFilename: string | null;
} {
const externalFilenameRaw =
typeof track['external-filename'] === 'string'
? track['external-filename']
: typeof track.external_filename === 'string'
? track.external_filename
: '';
const externalFilename = externalFilenameRaw.trim()
? resolveSubtitleSourcePath(externalFilenameRaw.trim())
: null;
return {
id: parseTrackId(track.id),
lang: String(track.lang || '').trim(),
title: String(track.title || '').trim(),
external: track.external === true,
externalFilename,
};
}
function matchesTitleBasename(title: string, basename: string): boolean {
const normalizedTitle = title.trim();
return normalizedTitle.length > 0 && path.basename(normalizedTitle) === basename;
}
function isLikelyTranslatedYoutubeTrack(entry: { lang: string; title: string }): boolean {
const normalizedTitle = entry.title.trim().toLowerCase();
if (normalizedTitle.includes(' from ')) {
return true;
}
return /-[a-z]{2,}(?:-[a-z0-9]+)?$/i.test(entry.lang.trim());
}
function matchExistingManualYoutubeTrackId(
trackListRaw: unknown,
trackOption: YoutubeTrackOption,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
const expectedLanguages = new Set(
[trackOption.language, trackOption.sourceLanguage]
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
);
const tracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId)
.filter((track) => !isLikelyTranslatedYoutubeTrack(track));
const exactTitleMatch = tracks.find(
(track) =>
expectedTitle.length > 0 &&
track.title.trim().toLowerCase() === expectedTitle &&
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
return exactTitleMatch.id;
}
if (expectedTitle.length === 0) {
const languageOnlyMatch = tracks.find((track) =>
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
return languageOnlyMatch.id;
}
}
return null;
}
function matchExistingYoutubeTrackId(
trackListRaw: unknown,
trackOption: YoutubeTrackOption,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
const expectedLanguages = new Set(
[trackOption.language, trackOption.sourceLanguage]
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
);
const tracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
const exactTitleMatch = tracks.find(
(track) =>
expectedTitle.length > 0 &&
track.title.trim().toLowerCase() === expectedTitle &&
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
return exactTitleMatch.id;
}
if (expectedTitle.length === 0) {
const languageOnlyMatch = tracks.find((track) =>
expectedLanguages.has(track.lang.trim().toLowerCase()),
);
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
return languageOnlyMatch.id;
}
}
return null;
}
function matchExternalTrackId(
trackListRaw: unknown,
filePath: string,
excludeId: number | null = null,
): number | null {
if (!Array.isArray(trackListRaw)) {
return null;
}
const normalizedFilePath = resolveSubtitleSourcePath(filePath);
const basename = path.basename(normalizedFilePath);
const externalTracks = trackListRaw
.filter(
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
)
.filter((track) => track.type === 'sub')
.map(normalizeTrackListEntry)
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
const exactPathMatch = externalTracks.find(
(track) => track.externalFilename === normalizedFilePath,
);
if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) {
return exactPathMatch.id;
}
const basenameMatch = externalTracks.find(
(track) => track.externalFilename && path.basename(track.externalFilename) === basename,
);
if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) {
return basenameMatch.id;
}
const titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename));
if (titleMatch?.id !== null && titleMatch?.id !== undefined) {
return titleMatch.id;
}
return null;
}
async function injectDownloadedSubtitles(
deps: YoutubeFlowDeps,
primarySelection: {
track: YoutubeTrackOption;
existingTrackId: number | null;
injectedPath: string | null;
},
secondaryTrack: YoutubeTrackOption | null,
secondarySelection: {
existingTrackId: number | null;
injectedPath: string | null;
} | null,
): Promise<boolean> {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
if (primarySelection.injectedPath) {
deps.sendMpvCommand([
'sub-add',
primarySelection.injectedPath,
'select',
path.basename(primarySelection.injectedPath),
primarySelection.track.sourceLanguage,
]);
}
if (secondarySelection?.injectedPath && secondaryTrack) {
deps.sendMpvCommand([
'sub-add',
secondarySelection.injectedPath,
'cached',
path.basename(secondarySelection.injectedPath),
secondaryTrack.sourceLanguage,
]);
}
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
let primaryTrackId: number | null = primarySelection.existingTrackId;
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
for (let attempt = 0; attempt < 12; attempt += 1) {
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
await deps.wait(attempt === 0 ? 150 : 100);
trackListRaw = await deps.requestMpvProperty('track-list');
}
if (primaryTrackId === null && primarySelection.injectedPath) {
primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath);
}
if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) {
secondaryTrackId = matchExternalTrackId(
trackListRaw,
secondarySelection.injectedPath,
primaryTrackId,
);
}
if (
primaryTrackId !== null &&
(!secondaryTrack || secondarySelection === null || secondaryTrackId !== null)
) {
break;
}
}
if (primaryTrackId !== null) {
await ensureSubtitleTrackSelection({
deps,
property: 'sid',
targetId: primaryTrackId,
});
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
} else {
deps.warn(
`Unable to bind downloaded primary subtitle track in mpv: ${
primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label
}`,
);
}
if (secondaryTrack && secondarySelection) {
if (secondaryTrackId !== null) {
await ensureSubtitleTrackSelection({
deps,
property: 'secondary-sid',
targetId: secondaryTrackId,
});
} else {
deps.warn(
`Unable to bind downloaded secondary subtitle track in mpv: ${
secondarySelection.injectedPath
? path.basename(secondarySelection.injectedPath)
: secondaryTrack.label
}`,
);
}
}
if (primaryTrackId === null) {
return false;
}
const currentSubText = await deps.requestMpvProperty('sub-text');
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(currentSubText);
}
deps.showMpvOsd(
secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
);
return true;
}
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let activeSession: YoutubeFlowSession | null = null;
const acquireSelectedTracks = async (input: {
targetUrl: string;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
secondaryFailureLabel: string;
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
if (!input.secondaryTrack) {
const primaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
return { primaryPath, secondaryPath: null };
}
try {
const batchResult = await deps.acquireYoutubeSubtitleTracks({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
tracks: [input.primaryTrack, input.secondaryTrack],
});
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
if (primaryPath) {
if (secondaryPath) {
return { primaryPath, secondaryPath };
}
deps.log(
`${
input.secondaryFailureLabel
}: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`,
);
await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS);
try {
const retriedSecondaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.secondaryTrack,
})
).path;
return { primaryPath, secondaryPath: retriedSecondaryPath };
} catch (error) {
deps.warn(
`${input.secondaryFailureLabel}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { primaryPath, secondaryPath: null };
}
}
} catch {
// fall through to primary-only recovery
}
try {
const primaryPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.targetUrl,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
return { primaryPath, secondaryPath: null };
} catch (error) {
throw error;
}
};
const resolveActivePicker = async (
request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> => {
if (!activeSession || activeSession.sessionId !== request.sessionId) {
return { ok: false, message: 'No active YouTube subtitle picker session.' };
}
activeSession.resolve(request);
return { ok: true, message: 'Picker selection accepted.' };
};
const cancelActivePicker = (): boolean => {
if (!activeSession) {
return false;
}
activeSession.resolve({
sessionId: activeSession.sessionId,
action: 'continue-without-subtitles',
primaryTrackId: null,
secondaryTrackId: null,
});
return true;
};
const createPickerSelectionPromise = (sessionId: string): Promise<YoutubePickerResolveRequest> =>
new Promise<YoutubePickerResolveRequest>((resolve, reject) => {
activeSession = { sessionId, resolve, reject };
}).finally(() => {
activeSession = null;
});
const reportPrimarySubtitleFailure = (): void => {
deps.reportSubtitleFailure(
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
);
};
const buildOpenPayload = (
input: {
url: string;
},
probe: YoutubeTrackProbeResult,
): YoutubePickerOpenPayload => {
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
return {
sessionId: createSessionId(),
url: input.url,
tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId,
hasTracks: probe.tracks.length > 0,
};
};
const loadTracksIntoMpv = async (input: {
url: string;
mode: YoutubeFlowMode;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
secondaryFailureLabel: string;
tokenizationWarmupPromise?: Promise<void>;
showDownloadProgress: boolean;
}): Promise<boolean> => {
const osdProgress = input.showDownloadProgress
? createYoutubeFlowOsdProgress(deps.showMpvOsd)
: null;
if (osdProgress) {
osdProgress.setMessage('Downloading subtitles...');
}
try {
let initialTrackListRaw: unknown = null;
let existingPrimaryTrackId: number | null = null;
let existingSecondaryTrackId: number | null = null;
for (let attempt = 0; attempt < 8; attempt += 1) {
if (attempt > 0) {
await deps.wait(attempt === 1 ? 150 : 100);
}
initialTrackListRaw = await deps.requestMpvProperty('track-list');
existingPrimaryTrackId =
input.primaryTrack.kind === 'manual'
? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack)
: null;
existingSecondaryTrackId =
input.secondaryTrack?.kind === 'manual'
? matchExistingManualYoutubeTrackId(
initialTrackListRaw,
input.secondaryTrack,
existingPrimaryTrackId,
)
: null;
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
const secondaryReady =
!input.secondaryTrack ||
input.secondaryTrack.kind !== 'manual' ||
existingSecondaryTrackId !== null;
if (primaryReady && secondaryReady) {
break;
}
}
let primarySidebarPath: string;
let primaryInjectedPath: string | null = null;
let secondaryInjectedPath: string | null = null;
if (existingPrimaryTrackId !== null) {
primarySidebarPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
primaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: primaryInjectedPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: null,
});
primaryInjectedPath = primarySidebarPath;
} else {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir: input.outputDir,
primaryTrack: input.primaryTrack,
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
secondaryFailureLabel: input.secondaryFailureLabel,
});
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
primaryInjectedPath = primarySidebarPath;
secondaryInjectedPath = acquired.secondaryPath;
}
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) {
try {
secondaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.secondaryTrack,
})
).path;
} catch (error) {
const fallbackExistingSecondaryTrackId =
input.secondaryTrack.kind === 'auto'
? matchExistingYoutubeTrackId(
initialTrackListRaw,
input.secondaryTrack,
existingPrimaryTrackId,
)
: null;
if (fallbackExistingSecondaryTrackId !== null) {
existingSecondaryTrackId = fallbackExistingSecondaryTrackId;
} else {
deps.warn(
`${input.secondaryFailureLabel}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
{
track: input.primaryTrack,
existingTrackId: existingPrimaryTrackId,
injectedPath: primaryInjectedPath,
},
input.secondaryTrack,
input.secondaryTrack
? {
existingTrackId: existingSecondaryTrackId,
injectedPath: secondaryInjectedPath,
}
: null,
);
if (!refreshedActiveSubtitle) {
return false;
}
try {
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (input.tokenizationWarmupPromise) {
await input.tokenizationWarmupPromise;
}
await deps.waitForTokenizationReady();
await deps.waitForAnkiReady();
return true;
} finally {
osdProgress?.stop();
}
};
const openManualPicker = async (input: {
url: string;
mode?: YoutubeFlowMode;
}): Promise<void> => {
let probe: YoutubeTrackProbeResult;
try {
probe = await deps.probeYoutubeTracks(input.url);
} catch (error) {
deps.warn(
`Failed to probe YouTube subtitle tracks: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
restoreOverlayInputFocus(deps);
return;
}
const openPayload = buildOpenPayload(input, probe);
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
const pickerSelection = createPickerSelectionPromise(openPayload.sessionId);
void pickerSelection.catch(() => undefined);
let opened = false;
try {
opened = await deps.openPicker(openPayload);
} catch (error) {
activeSession?.reject(error instanceof Error ? error : new Error(String(error)));
deps.warn(
`Unable to open YouTube subtitle picker: ${
error instanceof Error ? error.message : String(error)
}`,
);
restoreOverlayInputFocus(deps);
return;
}
if (!opened) {
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
activeSession = null;
deps.warn('Unable to open YouTube subtitle picker.');
restoreOverlayInputFocus(deps);
return;
}
const request = await pickerSelection;
if (request.action === 'continue-without-subtitles') {
restoreOverlayInputFocus(deps);
return;
}
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
if (!primaryTrack) {
deps.warn('No primary YouTube subtitle track selected.');
restoreOverlayInputFocus(deps);
return;
}
const selected = normalizeYoutubeTrackSelection({
primaryTrackId: primaryTrack.id,
secondaryTrackId: request.secondaryTrackId,
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
try {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode ?? 'download',
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack,
secondaryTrack,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
showDownloadProgress: true,
});
if (!loaded) {
reportPrimarySubtitleFailure();
}
} catch (error) {
deps.warn(
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
restoreOverlayInputFocus(deps);
}
};
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> {
deps.showMpvOsd('Opening YouTube video');
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
deps.warn(
`Failed to warm subtitle tokenization prerequisites: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
deps.pauseMpv();
suppressYoutubeSubtitleState(deps);
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
let probe: YoutubeTrackProbeResult;
try {
probe = await deps.probeYoutubeTracks(input.url);
} catch (error) {
deps.warn(
`Failed to probe YouTube subtitle tracks: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
if (!primaryTrack) {
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
try {
deps.showMpvOsd('Getting subtitles...');
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir,
primaryTrack,
secondaryTrack,
secondaryFailureLabel:
input.mode === 'generate'
? 'Failed to generate secondary YouTube subtitle track'
: 'Failed to download secondary YouTube subtitle track',
tokenizationWarmupPromise,
showDownloadProgress: false,
});
if (!loaded) {
reportPrimarySubtitleFailure();
}
} catch (error) {
deps.warn(
`Failed to ${
input.mode === 'generate' ? 'generate' : 'download'
} primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
}
return {
runYoutubePlaybackFlow,
openManualPicker,
resolveActivePicker,
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),
};
}

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openYoutubeTrackPicker } from './youtube-picker-open';
import type { YoutubePickerOpenPayload } from '../../types';
const payload: YoutubePickerOpenPayload = {
sessionId: 'yt-1',
url: 'https://example.com/watch?v=abc',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
};
test('youtube picker open prefers dedicated modal window on first attempt', async () => {
const sends: Array<{
channel: string;
payload: YoutubePickerOpenPayload;
options: {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
};
}> = [];
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
sends.push({
channel,
payload: nextPayload as YoutubePickerOpenPayload,
options: options as {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
},
});
return true;
},
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(sends, [
{
channel: 'youtube:picker-open',
payload,
options: {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
},
},
]);
});
test('youtube picker open retries on the dedicated modal window after open timeout', async () => {
const preferModalWindowValues: boolean[] = [];
const warns: string[] = [];
let waitCalls = 0;
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (_channel, _payload, options) => {
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
return true;
},
waitForModalOpen: async () => {
waitCalls += 1;
return waitCalls === 2;
},
logWarn: (message) => {
warns.push(message);
},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(preferModalWindowValues, [true, true]);
assert.equal(
warns.includes(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
),
true,
);
});
test('youtube picker open fails when the dedicated modal window cannot be targeted', async () => {
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: () => false,
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, false);
});

View File

@@ -0,0 +1,42 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
export async function openYoutubeTrackPicker(
deps: {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
payload: YoutubePickerOpenPayload,
): Promise<boolean> {
const sendPickerOpen = (): boolean =>
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
preferModalWindow: true,
});
if (!sendPickerOpen()) {
return false;
}
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
return true;
}
deps.logWarn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
);
if (!sendPickerOpen()) {
return false;
}
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
}

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true);
assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true);
assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true);
});
test('isYoutubeMediaPath ignores local files and non-youtube urls', () => {
assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false);
assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false);
assert.equal(isYoutubeMediaPath('https://notyoutube.com/watch?v=abc123'), false);
assert.equal(isYoutubeMediaPath(' '), false);
assert.equal(isYoutubeMediaPath(null), false);
});
test('isYoutubePlaybackActive checks both current media and mpv video paths', () => {
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true);
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
});

View File

@@ -0,0 +1,39 @@
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
const normalized = trimToNull(mediaPath);
if (!normalized) {
return false;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
return false;
}
const host = parsed.hostname.toLowerCase();
return (
matchesYoutubeHost(host, 'youtu.be') ||
matchesYoutubeHost(host, 'youtube.com') ||
matchesYoutubeHost(host, 'youtube-nocookie.com')
);
}
export function isYoutubePlaybackActive(
currentMediaPath: string | null | undefined,
currentVideoPath: string | null | undefined,
): boolean {
return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath);
}

View File

@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createYoutubePrimarySubtitleNotificationRuntime,
type YoutubePrimarySubtitleNotificationTimer,
} from './youtube-primary-subtitle-notification';
function createTimerHarness() {
let nextId = 1;
const timers = new Map<number, () => void>();
return {
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
const id = nextId++;
timers.set(id, fn);
return { id };
},
clear: (timer: YoutubePrimarySubtitleNotificationTimer | null) => {
if (!timer) {
return;
}
if (typeof timer === 'object' && 'id' in timer) {
timers.delete(timer.id);
}
},
runAll: () => {
const pending = [...timers.values()];
timers.clear();
for (const fn of pending) {
fn();
}
},
size: () => timers.size,
};
}
test('notifier reports missing preferred primary subtitle once for youtube media', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(null);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
]);
assert.equal(timers.size(), 1);
timers.runAll();
timers.runAll();
assert.deepEqual(notifications, [
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
]);
});
test('notifier suppresses failure when preferred primary subtitle is selected', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: true },
]);
runtime.handleSubtitleTrackChange(5);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier suppresses failure when selected track is marked active before sid arrives', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(null);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: false, selected: true },
]);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier suppresses failure when any external subtitle track is selected', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 5, lang: '', title: 'auto-ja-orig.ja-orig.vtt', external: true },
]);
runtime.handleSubtitleTrackChange(5);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier resets when media changes away from youtube', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleMediaPathChange('/tmp/video.mkv');
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier ignores empty and null media paths and waits for track list before reporting', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange(null);
runtime.handleMediaPathChange('');
assert.equal(timers.size(), 0);
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(7);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 7, lang: 'ja', title: 'Japanese', external: true },
]);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier suppresses timer while app-owned youtube flow is still settling', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.setAppOwnedFlowInFlight(true);
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
assert.equal(timers.size(), 0);
runtime.setAppOwnedFlowInFlight(false);
assert.equal(timers.size(), 1);
timers.runAll();
assert.deepEqual(notifications, [
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
]);
});

View File

@@ -0,0 +1,185 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
export type YoutubePrimarySubtitleNotificationTimer = ReturnType<typeof setTimeout> | { id: number };
type SubtitleTrackEntry = {
id: number | null;
type: string;
lang: string;
external: boolean;
selected: boolean;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function normalizeTrack(entry: unknown): SubtitleTrackEntry | null {
if (!entry || typeof entry !== 'object') {
return null;
}
const track = entry as Record<string, unknown>;
return {
id: parseTrackId(track.id),
type: String(track.type || '').trim(),
lang: String(track.lang || '').trim(),
external: track.external === true,
selected: track.selected === true,
};
}
export function clearYoutubePrimarySubtitleNotificationTimer(
timer: YoutubePrimarySubtitleNotificationTimer | null,
): void {
if (!timer) {
return;
}
if (typeof timer === 'object' && timer !== null && 'id' in timer) {
clearTimeout((timer as { id: number }).id);
return;
}
clearTimeout(timer);
}
function buildPreferredLanguageSet(values: string[]): Set<string> {
const normalized = values
.map((value) => normalizeYoutubeLangCode(value))
.filter((value) => value.length > 0);
return new Set(normalized);
}
function matchesPreferredLanguage(language: string, preferred: Set<string>): boolean {
if (preferred.size === 0) {
return false;
}
const normalized = normalizeYoutubeLangCode(language);
if (!normalized) {
return false;
}
if (preferred.has(normalized)) {
return true;
}
const base = normalized.split('-')[0] || normalized;
return preferred.has(base);
}
function hasSelectedPrimarySubtitle(
sid: number | null,
trackList: unknown[] | null,
preferredLanguages: Set<string>,
): boolean {
if (!Array.isArray(trackList)) {
return false;
}
const tracks = trackList.map(normalizeTrack);
const activeTrack =
(sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ??
tracks.find((track) => track?.type === 'sub' && track.selected) ??
null;
if (!activeTrack) {
return false;
}
if (activeTrack.external) {
return true;
}
return matchesPreferredLanguage(activeTrack.lang, preferredLanguages);
}
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
getPrimarySubtitleLanguages: () => string[];
notifyFailure: (message: string) => void;
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
delayMs?: number;
}) {
const delayMs = deps.delayMs ?? 5000;
let currentMediaPath: string | null = null;
let currentSid: number | null = null;
let currentTrackList: unknown[] | null = null;
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
let lastReportedMediaPath: string | null = null;
let appOwnedFlowInFlight = false;
const clearPendingTimer = (): void => {
deps.clearSchedule(pendingTimer);
pendingTimer = null;
};
const maybeReportFailure = (): void => {
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
if (lastReportedMediaPath === mediaPath) {
return;
}
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (preferredLanguages.size === 0) {
return;
}
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
return;
}
lastReportedMediaPath = mediaPath;
deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.');
};
const schedulePendingCheck = (): void => {
clearPendingTimer();
if (appOwnedFlowInFlight) {
return;
}
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
pendingTimer = deps.schedule(() => {
pendingTimer = null;
maybeReportFailure();
}, delayMs);
};
return {
handleMediaPathChange: (path: string | null): void => {
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
if (currentMediaPath !== normalizedPath) {
lastReportedMediaPath = null;
}
currentMediaPath = normalizedPath;
currentSid = null;
currentTrackList = null;
schedulePendingCheck();
},
handleSubtitleTrackChange: (sid: number | null): void => {
currentSid = sid;
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
clearPendingTimer();
}
},
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
currentTrackList = trackList;
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
clearPendingTimer();
}
},
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
appOwnedFlowInFlight = inFlight;
if (inFlight) {
clearPendingTimer();
return;
}
schedulePendingCheck();
},
};
}