feat: expand subtitle and media tracking workflows

- Add reference-guided subtitle timing, frame picking, live-action Jimaku search, and YouTube library kinds
- Harden Jellyfin media identity handling and live settings feedback
- Refresh user-facing documentation and changelog fragments
This commit is contained in:
2026-09-20 20:10:03 -07:00
139 changed files with 4511 additions and 389 deletions
+75 -1
View File
@@ -20,6 +20,8 @@ function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
onWait?: (elapsedMs: number, harness: Harness) => void;
isCurrent?: () => boolean;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
@@ -39,10 +41,12 @@ function createHarness(overrides?: {
},
wait: async (ms) => {
clock += ms;
overrides?.onWait?.(clock, harness);
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
isCurrent: overrides?.isCurrent,
});
const harness: Harness = {
@@ -94,6 +98,76 @@ test('times out with a failure when nothing ever starts', async () => {
watch.dispose();
});
test('waits for a slow stream that is still loading after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('vo-configured', true);
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), { ok: true });
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('reports a real stream error after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.emitEndFile({ reason: 'error', fileError: 'HTTP 503' });
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), {
ok: false,
error: 'mpv could not play this stream: HTTP 503',
});
watch.dispose();
});
test('stops waiting if a slow stream leaves mpv idle', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('idle-active', true);
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('stops watching a slow stream when the request is superseded', async () => {
let current = true;
const { watch, harness } = createHarness({
timeoutMs: 300,
isCurrent: () => current,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) current = false;
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});
test('disposing the watcher stops polling a slow stream', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) watch.dispose();
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
assert.equal(harness.listenerCount(), 0);
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
@@ -102,7 +176,7 @@ test('slow property reads eat the budget instead of extending it', async () => {
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
// Property reads and the final idle check all count toward elapsed time.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
+24 -8
View File
@@ -26,10 +26,12 @@ export interface WatchPlaybackOutcomeDeps {
/** One-shot mpv property read; may reject while the file is still loading. */
readProperty: (name: string) => Promise<unknown>;
wait: (ms: number) => Promise<void>;
/** Injectable clock; the timeout is wall-clock, not a probe count. */
/** Injectable clock for the initial confirmation deadline. */
now?: () => number;
timeoutMs?: number;
probeIntervalMs?: number;
/** Stop watching when a newer episode replaces this request or the app closes. */
isCurrent?: () => boolean;
}
export interface PlaybackOutcomeWatch {
@@ -49,6 +51,7 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
let failure: PlaybackOutcome | null = null;
let disposed = false;
const unsubscribe = deps.onEndFile((event) => {
if (event.reason !== 'error') return;
failure = {
@@ -60,11 +63,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
});
async function wait(): Promise<PlaybackOutcome> {
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
// budget rather than stretch it, and a zero probe interval must still end.
// Use elapsed time rather than probe count before checking whether mpv is
// still active. Slow property reads count toward this initial deadline.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
while (!disposed && (deps.isCurrent?.() ?? true)) {
if (failure) return failure;
try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
@@ -72,10 +75,17 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
// The property is unreadable while mpv is between files; keep polling.
}
if (failure) return failure;
// Sleeping past the deadline would only delay the timeout report.
// A deadline without video is not a failure while mpv is still opening
// the stream. Keep waiting for video or a real end-file error in that case.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
if (remaining <= 0) {
try {
if ((await deps.readProperty('idle-active')) !== false) break;
} catch {
break;
}
}
await deps.wait(remaining > 0 ? Math.min(probeIntervalMs, remaining) : probeIntervalMs);
}
return (
failure ?? {
@@ -85,5 +95,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
);
}
return { wait, dispose: unsubscribe };
return {
wait,
dispose: () => {
disposed = true;
unsubscribe();
},
};
}
+18
View File
@@ -1527,3 +1527,21 @@ test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key quer
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});
test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never,
{} as never,
{
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
currentMediaTitle: 'stream?static=true&api_key=test-secret',
currentTimePos: 426,
send: () => true,
} as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
+13 -6
View File
@@ -32,6 +32,7 @@ import {
type MediaTimingReviewRequest,
} from './types/anki';
import { AiConfig } from './types/integrations';
import { sanitizeMediaTitle } from './shared/media-identity';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
@@ -1117,9 +1118,11 @@ export class AnkiIntegration {
return null;
}
const mediaRange = this.getSubtitleMediaRange(context);
const timestamp = context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0;
const timestamp =
context?.screenshotTime ??
(context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0);
if (this.config.media?.imageType === 'avif') {
return this.mediaGenerator.generateAnimatedImage(
@@ -1165,11 +1168,13 @@ export class AnkiIntegration {
}
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const resolvedMediaTitle = sanitizeMediaTitle(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
? resolvedMediaTitle || 'Unknown media'
: sanitizeMediaTitle(videoFilename) || resolvedMediaTitle) ||
sanitizeMediaTitle(fallbackFilename) ||
'Unknown media';
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -1794,6 +1799,8 @@ export class AnkiIntegration {
...request,
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
screenshotEnabled:
this.config.media.generateImage !== false && this.config.media.imageType !== 'avif',
});
}
+20 -3
View File
@@ -290,6 +290,9 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
});
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
let reviewing = false;
const audioRanges: number[][] = [];
const imageTimes: number[] = [];
const audioPaths: string[] = [];
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
@@ -319,6 +322,10 @@ test('CardCreationService uses stream-open-filename for remote media generation'
behavior: {},
ai: false,
}) as AnkiConnectConfig,
reviewMediaTiming: async () =>
reviewing
? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 }
: { action: 'use-original' },
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
@@ -349,16 +356,18 @@ test('CardCreationService uses stream-open-filename for remote media generation'
],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
generateAudio: async (path, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
audioPaths.push(recordMediaPath(path));
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
generateScreenshot: async (path, timestamp) => {
imageTimes.push(timestamp);
imagePaths.push(recordMediaPath(path));
return Buffer.from('image');
},
@@ -406,6 +415,14 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.equal(created, true);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
reviewing = true;
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
await service.markLastCardAsAudioCard();
assert.equal(imageTimes.length, 3);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
});
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+9 -3
View File
@@ -536,6 +536,7 @@ export class CardCreationService {
endTime,
animatedLeadInSeconds,
exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
);
const imageField = this.deps.getConfig().fields?.image;
@@ -796,6 +797,9 @@ export class CardCreationService {
generateImage,
volumeScale,
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
...(timingDecision.action === 'confirm' && timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
@@ -840,6 +844,7 @@ export class CardCreationService {
endTime,
0,
exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
);
const imageField = config.fields?.image;
@@ -922,15 +927,16 @@ export class CardCreationService {
endTime: number,
animatedLeadInSeconds = 0,
exactReviewedRange = false,
screenshotTime?: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
const timestamp = exactReviewedRange
? startTime + (endTime - startTime) / 2
: mpvClient.currentTimePos || 0;
const timestamp =
screenshotTime ??
(exactReviewedRange ? startTime + (endTime - startTime) / 2 : mpvClient.currentTimePos || 0);
if (this.deps.getConfig().media?.imageType === 'avif') {
let imageStart = startTime;
@@ -729,6 +729,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
startTime: 2,
endTime: 7,
text: 'previous-line current-line next-line',
screenshotTime: 8,
});
harness.deps.generateAudio = async (context) => {
audioContexts.push(context);
@@ -745,6 +746,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
assert.equal(audioContexts[0]?.startTime, 2);
assert.equal(audioContexts[0]?.endTime, 7);
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
assert.equal(audioContexts[0]?.screenshotTime, 8);
});
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
@@ -253,6 +253,9 @@ export class NoteUpdateWorkflow {
startTime: timingDecision.startTime,
endTime: timingDecision.endTime,
mediaPaddingSeconds: 0,
...(timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
};
} else if (timingDecision.action === 'skip-media') {
skipMedia = true;
@@ -51,6 +51,41 @@ function createDeps(
return deps;
}
test('queued media keeps a chosen screenshot separate from the reviewed audio range', async () => {
const screenshots: number[] = [];
const audioRanges: number[][] = [];
const deps = createDeps();
deps.client.notesInfo = async () => [{ noteId: 42, fields: { Picture: { value: '' } } }];
deps.mediaGenerator.generateScreenshot = async (_media, time) => {
screenshots.push(time);
return Buffer.from('image');
};
deps.mediaGenerator.generateAudio = async (_media, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
return Buffer.from('audio');
};
const queue = new PendingYoutubeMediaQueue(deps);
assert.equal(
await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'test',
context: {
source: 'overlay',
text: '字幕',
startTime: 1,
endTime: 2,
mediaPaddingSeconds: 0,
screenshotTime: 3.125,
},
}),
true,
);
await queue.handleReady('https://youtu.be/abc123', '/cache/video.mkv');
assert.deepEqual(screenshots, [3.125]);
assert.deepEqual(audioRanges, [[1, 2, 0]]);
});
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({
getCachedMediaPath: async () => {
@@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue {
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
volumeScale,
...(job.context?.screenshotTime !== undefined
? { screenshotTime: job.context.screenshotTime }
: {}),
...(job.context?.mediaPaddingSeconds !== undefined
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
: {}),
@@ -320,6 +323,7 @@ export class PendingYoutubeMediaQueue {
job.endTime,
animatedLeadInSeconds,
job.mediaPaddingSeconds,
job.screenshotTime,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -381,6 +385,7 @@ export class PendingYoutubeMediaQueue {
endTime: number,
animatedLeadInSeconds = 0,
mediaPaddingSeconds?: number,
screenshotTime?: number,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
@@ -399,7 +404,7 @@ export class PendingYoutubeMediaQueue {
);
}
const timestamp = startTime + (endTime - startTime) / 2;
const timestamp = screenshotTime ?? startTime + (endTime - startTime) / 2;
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: config.media?.imageQuality,
@@ -11,6 +11,7 @@ export interface PendingYoutubeMediaUpdate {
generateImage: boolean;
volumeScale?: number;
mediaPaddingSeconds?: number;
screenshotTime?: number;
}
function trimToNonEmptyString(value: unknown): string | null {
+65
View File
@@ -0,0 +1,65 @@
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'anime.autoOpenJimaku',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
export function getConfigHotReloadField(path: string): string | null {
for (const root of HOT_RELOAD_ROOTS) {
if (pathStartsWith(path, root)) {
return root;
}
}
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
if (pathStartsWith(path, hotPath)) {
return hotPath;
}
}
// These consumers read the current config when the next operation starts.
if (
['jimaku', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
pathStartsWith(path, root),
)
) {
return path;
}
return null;
}
+2 -50
View File
@@ -1,9 +1,9 @@
import { getConfigHotReloadField } from '../hot-reload';
import type { ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsCategory,
ConfigSettingsControl,
ConfigSettingsField,
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
@@ -698,54 +698,6 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
return a.configPath.localeCompare(b.configPath);
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
pathStartsWith(path, 'shortcuts') ||
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
path === 'ankiConnect.deck' ||
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.media.normalizeAudio' ||
path === 'ankiConnect.media.mirrorMpvVolume' ||
path === 'ankiConnect.media.reviewTiming' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.isSenren.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') ||
pathStartsWith(path, 'notifications') ||
path === 'anime.autoOpenJimaku' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync') ||
pathStartsWith(path, 'subtitleGeneration')
) {
return 'hot-reload';
}
return 'restart';
}
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
@@ -764,7 +716,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}),
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: restartBehaviorForPath(leaf.path),
restartBehavior: getConfigHotReloadField(leaf.path) ? 'hot-reload' : 'restart',
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
@@ -27,6 +27,83 @@ function createLogger() {
};
}
test('anilist retry queue migrates stream keys and rejects URL-derived searches', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const key = 'https://example.com/Videos/item/stream?api_key=test-secret::2';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [
{
key,
title: 'My Anime',
episode: 2,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
},
],
deadLetter: [],
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::2');
assert.equal(fs.readFileSync(queueFile, 'utf8').includes('test-secret'), false);
queue.enqueue('unsafe', 'stream?api_key=test-secret', 3);
assert.equal(queue.getSnapshot().pending, 1);
queue.markSuccess(key);
assert.equal(queue.getSnapshot().pending, 0);
});
test('anilist retry queue discards empty normalized identities on load and enqueue', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const invalidKeys = [
'',
' ',
'::3',
'stream?api_key=secret::3',
'stream%3Fapi_key%3Dsecret::3',
'https://[invalid::3',
];
const item = {
title: 'My Anime',
episode: 3,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
};
const validKey = 'https://example.com/Videos/item/stream?api_key=secret::3';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [...invalidKeys, validKey].map((key) => ({ ...item, key })),
deadLetter: invalidKeys.map((key) => ({ ...item, key })),
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.deepEqual(queue.getSnapshot(), { pending: 1, ready: 1, deadLetter: 0 });
const persisted = fs.readFileSync(queueFile, 'utf8');
assert.deepEqual(JSON.parse(persisted), {
pending: [{ ...item, key: 'jellyfin://example.com/item/item::3' }],
deadLetter: [],
});
for (const key of invalidKeys) {
queue.enqueue(key, 'My Anime', 3);
queue.markFailure(key, 'invalid');
queue.markSuccess(key);
}
assert.equal(fs.readFileSync(queueFile, 'utf8'), persisted);
queue.markFailure(validKey, 'retry', 10);
assert.equal(queue.nextReady(30_010)?.attemptCount, 1);
queue.markSuccess(validKey);
queue.enqueue(validKey, 'My Anime', 3);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::3');
});
test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
@@ -1,5 +1,13 @@
import * as fs from 'fs';
import { ensureDirForFile } from '../../../shared/fs-utils';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../../shared/media-identity';
function normalizeAnilistRetryKey(key: string): string {
const parts = key.match(/^(.*)::(\d+)$/s);
const identity = toMediaIdentityPath(parts ? (parts[1] ?? '') : key);
if (!identity) return '';
return parts ? `${identity}::${parts[2]}` : identity;
}
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -105,6 +113,9 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
deadLetter = parsedDeadLetter
.filter(
@@ -120,7 +131,11 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
if (JSON.stringify({ pending, deadLetter }) !== JSON.stringify(parsed)) persist();
} catch (error) {
logger.error('Failed to load AniList retry queue.', error);
}
@@ -136,6 +151,9 @@ export function createAnilistUpdateQueue(
season: number | null = null,
mediaId: number | null = null,
): void {
if (!sanitizeMediaTitle(title)) return;
key = normalizeAnilistRetryKey(key);
if (!key) return;
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
@@ -165,6 +183,8 @@ export function createAnilistUpdateQueue(
},
markSuccess(key: string): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const before = pending.length;
pending = pending.filter((item) => item.key !== key);
if (pending.length !== before) {
@@ -173,6 +193,8 @@ export function createAnilistUpdateQueue(
},
markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const item = pending.find((candidate) => candidate.key === key);
if (!item) {
return;
@@ -140,6 +140,62 @@ test('guessAnilistMediaInfo preserves useful guessit alternative title for ambig
});
});
test('guessAnilistMediaInfo uses the display title for authenticated streams', async () => {
const targets: string[] = [];
const result = await guessAnilistMediaInfo(
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'My Anime S02E03',
{
runGuessit: async (target) => {
targets.push(target);
throw new Error('use fallback parser');
},
},
);
assert.deepEqual(targets, ['My Anime S02E03']);
assert.deepEqual(result, {
title: 'My Anime',
season: 2,
episode: 3,
source: 'fallback',
});
});
test('guessAnilistMediaInfo preserves slashes in display titles', async () => {
const title = 'Fate/stay night S01E02';
for (const mediaPath of [null, title, 'https://example.com/stream?api_key=test-secret']) {
const targets: string[] = [];
await guessAnilistMediaInfo(mediaPath, title, {
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
});
assert.deepEqual(targets, [title]);
}
});
test('guessAnilistMediaInfo never parses stream URLs or their query-bearing filenames', async () => {
const unsafeInputs = [
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'stream?static=true&api_key=test-secret&MediaSourceId=item',
'https://user:test-secret@example.com/video.mkv',
];
for (const input of unsafeInputs) {
const targets: string[] = [];
const deps = {
runGuessit: async (target: string) => {
targets.push(target);
return JSON.stringify({ title: target });
},
};
assert.equal(await guessAnilistMediaInfo(input, null, deps), null);
assert.equal(await guessAnilistMediaInfo(null, input, deps), null);
assert.equal(await guessAnilistMediaInfo(input, input, deps), null);
assert.deepEqual(targets, []);
}
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
+5 -4
View File
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
@@ -231,8 +232,9 @@ export async function guessAnilistMediaInfo(
mediaTitle: string | null,
deps: GuessAnilistMediaInfoDeps = { runGuessit },
): Promise<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
const guessitTarget = mediaPath ? path.basename(mediaPath) : mediaTitle;
const target = resolveMediaLookupTarget(mediaPath, mediaTitle);
if (!target) return null;
const guessitTarget = target === sanitizeMediaTitle(mediaTitle) ? target : path.basename(target);
if (guessitTarget && guessitTarget.trim().length > 0) {
try {
@@ -260,8 +262,7 @@ export async function guessAnilistMediaInfo(
}
}
const fallbackTarget = mediaPath ?? mediaTitle;
const parsed = parseMediaInfo(fallbackTarget);
const parsed = parseMediaInfo(target);
if (!parsed.title.trim()) {
return null;
}
@@ -193,6 +193,16 @@ export function createCoverArtFetcher(
return {
async fetchIfMissing(db, videoId, canonicalTitle): Promise<boolean> {
const channel = db
.prepare(
`
SELECT 1 FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.media_kind != 'anime'
`,
)
.get(videoId);
if (channel) return false;
const existing = getCoverArt(db, videoId);
if (existing?.coverBlob) {
return true;
@@ -117,6 +117,27 @@ function createExecutor(
return { execute, searches, relationLookups };
}
test('AniList refuses URL-derived search titles before making a request', async () => {
let requests = 0;
for (const title of [
'https://example.com/stream?api_key=test-secret',
'stream?static=true&api_key=test-secret',
'stream static true api key test secret',
]) {
const result = await resolveAnilistSeasonMedia(
{ title },
{
execute: async () => {
requests += 1;
throw new Error('must not send URL-derived searches');
},
},
);
assert.equal(result, null);
}
assert.equal(requests, 0);
});
test('stripSeasonSuffix drops release-name season markers', () => {
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
+5 -1
View File
@@ -1,3 +1,5 @@
import { sanitizeMediaTitle } from '../../../shared/media-identity';
/**
* AniList has no concept of "season N": sequels are separate media with their own
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
@@ -468,7 +470,9 @@ export async function resolveAnilistSeasonMedia(
input: ResolveAnilistSeasonMediaInput,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonResolution | null> {
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
const safeTitle = sanitizeMediaTitle(input.title);
if (!safeTitle) return null;
const searchTitle = stripSeasonSuffix(safeTitle).trim() || safeTitle;
if (!searchTitle) return null;
const season =
+3 -2
View File
@@ -215,9 +215,10 @@ export function registerAnkiJimakuIpcRuntime(
},
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => {
logger.info(`[jimaku] search-entries query: "${query.query}"`);
const category = query.category ?? 'anime';
logger.info(`[jimaku] search-entries query: "${query.query}" category=${category}`);
const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', {
anime: true,
anime: category === 'anime',
query: query.query,
});
if (!response.ok) return response;
+42 -3
View File
@@ -1,12 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { buildConfigSettingsRegistry, getConfigValueAtPath } from '../../config/settings/registry';
import {
classifyConfigHotReloadDiff,
createConfigHotReloadRuntime,
type ConfigHotReloadRuntimeDeps,
} from './config-hot-reload';
test('every LIVE settings field is classified without a restart warning', () => {
for (const field of buildConfigSettingsRegistry(DEFAULT_CONFIG)) {
if (field.restartBehavior !== 'hot-reload') continue;
const next = deepCloneConfig(DEFAULT_CONFIG);
const segments = field.configPath.split('.');
const leaf = segments.pop();
assert.ok(leaf);
const parent = segments.length ? getConfigValueAtPath(next, segments.join('.')) : next;
assert.ok(parent && typeof parent === 'object', field.configPath);
// The classifier compares structure; validation of field values is tested separately.
Object.defineProperty(parent, leaf, {
value: getConfigValueAtPath(next, field.configPath) === null ? 'changed' : null,
enumerable: true,
});
const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next);
assert.deepEqual(diff.restartRequiredFields, [], field.configPath);
assert.ok(diff.hotReloadFields.length > 0, field.configPath);
}
});
test('live notifications and subtitle generation changes preserve unrelated restart warnings', () => {
const next = deepCloneConfig(DEFAULT_CONFIG);
next.notifications.overlayPosition = 'top';
next.subtitleGeneration.threads += 1;
next.websocket.port += 1;
const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next);
assert.deepEqual(
new Set(diff.hotReloadFields),
new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']),
);
assert.deepEqual(diff.restartRequiredFields, ['websocket.port']);
});
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
@@ -15,7 +50,7 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
assert.deepEqual(diff.restartRequiredFields, ['websocket.port']);
});
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
@@ -101,7 +136,11 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required'
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, []);
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
assert.deepEqual(diff.restartRequiredFields, [
'ankiConnect.url',
'ankiConnect.ai.model',
'stats.serverPort',
]);
});
test('classifyConfigHotReloadDiff treats anime Jimaku auto-open as hot-reloadable', () => {
@@ -124,7 +163,7 @@ test('classifyConfigHotReloadDiff keeps other anime settings restart-required',
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, ['anime.autoOpenJimaku']);
assert.deepEqual(diff.restartRequiredFields, ['anime']);
assert.deepEqual(diff.restartRequiredFields, ['anime.preferredQuality']);
});
test('config hot reload runtime debounces rapid watch events', () => {
+4 -84
View File
@@ -1,3 +1,4 @@
import { getConfigHotReloadField } from '../../config/hot-reload';
import { type ReloadConfigStrictResult } from '../../config';
import type { ConfigValidationWarning } from '../../types';
import type { ResolvedConfig } from '../../types';
@@ -33,10 +34,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] {
if (isEqual(prev, next)) {
return [];
@@ -52,61 +49,6 @@ function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[
);
}
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'anime.autoOpenJimaku',
'jimaku',
'subsync',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
function hotReloadFieldForChangedPath(path: string): string | null {
for (const root of HOT_RELOAD_ROOTS) {
if (pathStartsWith(path, root)) {
return root;
}
}
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
if (pathStartsWith(path, hotPath)) {
return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath;
}
}
return null;
}
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
@@ -114,33 +56,11 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
const changedPaths = collectChangedPaths(prev, next);
for (const path of changedPaths) {
const hotReloadField = hotReloadFieldForChangedPath(path);
const hotReloadField = getConfigHotReloadField(path);
if (hotReloadField) {
hotReloadFieldSet.add(hotReloadField);
}
}
const keys = new Set([
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
...(Object.keys(next) as Array<keyof ResolvedConfig>),
]);
for (const key of keys) {
if (
key === 'subtitleStyle' ||
key === 'keybindings' ||
key === 'shortcuts' ||
key === 'subtitleSidebar'
) {
continue;
}
const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key)));
const hasRestartRequiredChange = changedPathsForKey.some(
(path) => !hotReloadFieldForChangedPath(path),
);
if (hasRestartRequiredChange) {
restartRequiredFields.push(String(key));
} else {
restartRequiredFields.push(path);
}
}
@@ -91,6 +91,21 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
}
});
test('buildDiscordPresenceActivity rejects stream URLs supplied as titles', () => {
for (const mediaTitle of [
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
const activity = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaPath: 'https://example.com/stream?api_key=test-secret',
mediaTitle,
});
assert.equal(activity.details, 'Unknown media');
assert.equal(JSON.stringify(activity).includes('test-secret'), false);
}
});
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
+2 -1
View File
@@ -1,5 +1,6 @@
import type { DiscordPresenceStylePreset } from '../../types/integrations';
import type { ResolvedConfig } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
@@ -140,7 +141,7 @@ export function buildDiscordPresenceActivity(
const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot);
const title = sanitizeText(
snapshot.mediaTitle,
sanitizeMediaTitle(snapshot.mediaTitle),
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details =
@@ -3060,7 +3060,27 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
const titledStreamUrl =
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
tracker.handleMediaTitleUpdate('stream?static=true&api_key=secret-token');
tracker.handleMediaChange(null, null);
// Safety must hold before metadata registration or a startup repair can run.
const liveDb = (tracker as unknown as { db: DatabaseSync }).db;
const persistedRows = liveDb.prepare('SELECT * FROM imm_videos').all();
assert.equal(JSON.stringify(persistedRows).includes('secret-token'), false);
assert.equal(JSON.stringify(persistedRows).includes('/stream'), false);
// Recreate the old on-disk representation to retain coverage of startup repair.
liveDb
.prepare(
'UPDATE imm_videos SET video_key = ?, source_url = ?, canonical_title = ? WHERE source_url = ?',
)
.run(
`remote:${streamUrl}`,
streamUrl,
'stream?static=true&api_key=secret-token',
'jellyfin://jellyfin.local/item/item-9',
);
liveDb
.prepare('UPDATE imm_videos SET video_key = ?, source_url = ? WHERE source_url = ?')
.run(`remote:${titledStreamUrl}`, titledStreamUrl, 'jellyfin://jellyfin.local/item/item-10');
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
displayTitle: 'Frieren S01E09 Aura the Guillotine',
@@ -3162,6 +3182,90 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
}
});
test('startup clears leaked parser metadata on safely titled anime without changing assignments', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
displayTitle: 'My Anime S01E01',
itemTitle: 'Episode 1',
seriesTitle: 'My Anime',
seasonNumber: 1,
episodeNumber: 1,
itemId: 'item',
});
const db = (tracker as unknown as { db: DatabaseSync }).db;
db.prepare('UPDATE imm_anime SET metadata_json = ?').run(
JSON.stringify({
filename: 'stream?api_key=test-secret',
source: 'guessit',
}),
);
const before = db.prepare('SELECT video_id, anime_id FROM imm_videos').all();
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedDb = (tracker as unknown as { db: DatabaseSync }).db;
assert.deepEqual(repairedDb.prepare('SELECT video_id, anime_id FROM imm_videos').all(), before);
assert.deepEqual(
repairedDb.prepare('SELECT canonical_title, metadata_json FROM imm_anime').all(),
[{ canonical_title: 'My Anime Season 1', metadata_json: null }],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin metadata cleanup requires both an API key and a stream marker', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const db = (tracker as unknown as { db: DatabaseSync }).db;
const timestamp = toDbTimestamp(trackerNowMs());
const cases = [
{ filename: 'stream?api_key=secret', leaked: true },
{ filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?api_key=secret', leaked: true },
{ filename: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false },
{ filename: 'stream?quality=high', leaked: false },
{ filename: '/Videos/item', leaked: false },
{ filename: 'MediaSourceId=item', leaked: false },
];
for (const [index, entry] of cases.entries()) {
db.prepare(
`
INSERT INTO imm_anime (
normalized_title_key, canonical_title, metadata_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?)
`,
).run(
`show-${index}`,
`Show ${index}`,
JSON.stringify({ filename: entry.filename }),
timestamp,
timestamp,
);
}
repairJellyfinStreamVideoLinks(db);
assert.deepEqual(
db.prepare('SELECT metadata_json FROM imm_anime ORDER BY anime_id').all(),
cases.map(({ filename, leaked }) => ({
metadata_json: leaked ? null : JSON.stringify({ filename }),
})),
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1,6 +1,7 @@
import path from 'node:path';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../shared/media-identity';
import { MediaGenerator } from '../../media-generator';
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
@@ -395,7 +396,7 @@ function normalizeMetadataInt(value: number | null | undefined): number | null {
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
const normalizedItemId = normalizeText(itemId);
if (!normalizedItemId) {
return mediaPath;
return toMediaIdentityPath(mediaPath);
}
try {
const parsed = new URL(mediaPath);
@@ -1660,11 +1661,11 @@ export class ImmersionTrackerService {
}
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
normalizeText(sanitizeMediaTitle(metadata.displayTitle)) ||
normalizeText(sanitizeMediaTitle(metadata.itemTitle)) ||
deriveCanonicalTitle(statsPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const itemTitle = normalizeText(sanitizeMediaTitle(metadata.itemTitle)) || displayTitle;
const seriesTitle = normalizeText(sanitizeMediaTitle(metadata.seriesTitle));
const libraryTitle = seriesTitle || itemTitle;
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
@@ -1829,8 +1830,8 @@ export class ImmersionTrackerService {
const normalizedPath =
buildMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
.find((alias): alias is string => Boolean(alias)) ?? toMediaIdentityPath(rawPath);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
);
@@ -1888,7 +1889,7 @@ export class ImmersionTrackerService {
handleMediaTitleUpdate(mediaTitle: string | null): void {
if (!this.sessionState) return;
const normalizedTitle = normalizeText(mediaTitle);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
if (!normalizedTitle) return;
this.currentVideoKey = normalizedTitle;
this.updateVideoTitleForActiveSession(normalizedTitle);
@@ -13,7 +13,11 @@ import {
getOrCreateAnimeRecord,
linkVideoToAnimeRecord,
} from '../storage.js';
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
import {
MEDIA_KIND_MISMATCH_MESSAGE,
mergeAnimeRecords,
moveVideoToAnime,
} from '../anime-merge.js';
import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
@@ -940,3 +944,36 @@ test('automatic AniList update onto an entry that already links elsewhere does n
);
});
});
test('merge and move refuse to mix anime entries with YouTube channels', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'some anime', title: 'Some Anime', anilistId: 555 });
insertAnime(db, { animeId: 2, key: 'youtube channel uc123', title: 'Channel' });
db.prepare("UPDATE imm_anime SET media_kind = 'youtube' WHERE anime_id = 2").run();
insertEpisode(db, { videoId: 10, animeId: 1 });
insertEpisode(db, { videoId: 20, animeId: 2 });
assert.throws(() => mergeAnimeRecords(db, 1, [2]), { message: MEDIA_KIND_MISMATCH_MESSAGE });
assert.throws(() => mergeAnimeRecords(db, 2, [1]), { message: MEDIA_KIND_MISMATCH_MESSAGE });
assert.throws(() => moveVideoToAnime(db, 20, 1), { message: MEDIA_KIND_MISMATCH_MESSAGE });
assert.throws(() => moveVideoToAnime(db, 10, 2), { message: MEDIA_KIND_MISMATCH_MESSAGE });
const rows = db
.prepare(
'SELECT anime_id AS animeId, media_kind AS mediaKind FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; mediaKind: string }>;
assert.deepEqual(rows, [
{ animeId: 1, mediaKind: 'anime' },
{ animeId: 2, mediaKind: 'youtube' },
]);
assert.equal(
(
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 20').get() as {
animeId: number;
}
).animeId,
2,
);
});
});
@@ -27,7 +27,7 @@ function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null
.prepare(
`SELECT canonical_title, title_romaji, title_english, title_native
FROM imm_anime
WHERE anime_id = ?`,
WHERE anime_id = ? AND media_kind = 'anime'`,
)
.get(animeId) as AnimeTitleRow | null;
}
@@ -77,6 +77,7 @@ export function shouldRecommendAnilistConflict(
conflictAnimeId: number,
options: AnimeConflictRecommendationOptions,
): boolean {
if (!getAnimeTitles(db, targetAnimeId) || !getAnimeTitles(db, conflictAnimeId)) return false;
if (options.survivor === 'target' || options.matchConfidence === 'manual') return false;
if (
!animeSeasonsAreMergeCompatible(
@@ -99,6 +100,8 @@ export function recordAnimeMergeRecommendation(
secondCandidateAnimeId: number,
anilistId: number,
): void {
if (!getAnimeTitles(db, firstCandidateAnimeId) || !getAnimeTitles(db, secondCandidateAnimeId))
return;
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
const timestamp = toDbTimestamp(nowMs());
@@ -141,6 +144,8 @@ export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecomm
second_anime_id AS secondAnimeId
FROM imm_anime_merge_recommendations
WHERE status = 'pending'
AND first_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime')
AND second_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime')
ORDER BY recommendation_id ASC`,
)
.all() as Array<{
@@ -1,3 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
@@ -5,6 +6,8 @@ import { nowMs } from './time';
/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */
export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
@@ -60,8 +63,11 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
.get(animeId) ?? null) as AnimeMetadataRow | null;
}
function animeExists(db: DatabaseSync, animeId: number): boolean {
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
function readMediaKind(db: DatabaseSync, animeId: number): MediaKind | null {
const row = db
.prepare('SELECT media_kind AS mediaKind FROM imm_anime WHERE anime_id = ?')
.get(animeId) as { mediaKind: MediaKind } | undefined;
return row?.mediaKind ?? null;
}
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
@@ -161,7 +167,8 @@ export function mergeAnimeRecordsInTransaction(
sourceAnimeIds: number[],
): AnimeMergeSummary {
const summary = emptyMergeSummary(targetAnimeId);
if (!animeExists(db, targetAnimeId)) {
const targetKind = readMediaKind(db, targetAnimeId);
if (targetKind === null) {
return summary;
}
@@ -195,8 +202,13 @@ export function mergeAnimeRecordsInTransaction(
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
continue;
if (sourceAnimeId === targetAnimeId) continue;
const sourceKind = readMediaKind(db, sourceAnimeId);
if (sourceKind === null) continue;
// A channel folded into an anime would only be recreated on the next
// watch, because title lookups never cross kinds; refuse instead.
if (sourceKind !== targetKind) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
@@ -257,11 +269,15 @@ export function moveVideoToAnime(
const videoRow = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
if (!videoRow || !animeExists(db, targetAnimeId)) {
const targetKind = readMediaKind(db, targetAnimeId);
if (!videoRow || targetKind === null) {
throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
if (previousAnimeId === targetAnimeId) {
db.prepare(
'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?',
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
episodes_total,
description
FROM imm_anime
WHERE anime_id = ?
WHERE anime_id = ? AND media_kind = 'anime'
`,
)
.get(animeId) as AnimeRow | null;
@@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
SELECT a.anime_id AS animeId
FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL
WHERE a.media_kind = 'anime'
AND v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0
@@ -372,6 +373,11 @@ export function resolveAnimeAnilistConflict(
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
if (!getAnimeRow(db, targetAnimeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
const conflict = db
.prepare(
`
@@ -387,6 +393,11 @@ export function resolveAnimeAnilistConflict(
return emptySummary();
}
if (!getAnimeRow(db, conflict.animeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId);
if (
@@ -257,6 +257,30 @@ function repairLeakedJellyfinVideoParseMetadata(
return updated.changes;
}
function repairLeakedJellyfinAnimeParseMetadata(
db: DatabaseSync,
currentTimestamp: string,
): number {
const updated = db
.prepare(
`
UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE (
metadata_json LIKE '%api_key=%'
OR lower(metadata_json) LIKE '%api key%'
) AND (
lower(metadata_json) LIKE '%stream?%'
OR lower(metadata_json) LIKE '%/stream?%'
OR lower(metadata_json) LIKE '%/videos/%'
OR lower(metadata_json) LIKE '%mediasourceid%'
)
`,
)
.run(currentTimestamp);
return updated.changes;
}
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
const candidates = db
.prepare(
@@ -290,7 +314,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
const currentTimestamp = toDbTimestamp(nowMs());
const repaired =
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp) +
repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
summary.repaired += repaired;
return summary;
}
@@ -422,6 +447,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -147,6 +147,25 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er
assert.equal(hashFallbackMetadata.hashSha256, null);
});
test('stream stats parsing preserves display titles and never persists transport credentials', async () => {
const targets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
'Fate/stay night S01E02',
{
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
},
);
assert.deepEqual(targets, ['Fate/stay night S01E02']);
assert.equal(parsed?.parsedBasename, 'Fate/stay night S01E02');
assert.equal(parsed?.parsedTitle, 'Fate/stay night');
assert.equal(JSON.stringify(parsed).includes('test-secret'), false);
assert.equal(JSON.stringify(parsed).includes('/stream'), false);
});
test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => {
const seenTargets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
@@ -3,6 +3,7 @@ import { spawn as nodeSpawn } from 'node:child_process';
import * as fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -184,6 +185,7 @@ export async function guessAnimeVideoMetadata(
mediaTitle: string | null,
deps: GuessAnimeVideoMetadataDeps = {},
): Promise<ParsedAnimeVideoGuess | null> {
const lookupTarget = resolveMediaLookupTarget(mediaPath, mediaTitle);
const parsed = await guessAnilistMediaInfo(mediaPath, mediaTitle, {
runGuessit: deps.runGuessit ?? runGuessit,
});
@@ -191,7 +193,12 @@ export async function guessAnimeVideoMetadata(
return null;
}
const parsedBasename = mediaPath ? path.basename(mediaPath) : null;
const parsedBasename =
lookupTarget === sanitizeMediaTitle(mediaTitle)
? lookupTarget
: lookupTarget
? path.basename(lookupTarget)
: null;
if (parsed.source === 'guessit') {
return {
parsedBasename,
@@ -207,7 +214,7 @@ export async function guessAnimeVideoMetadata(
};
}
const fallbackInfo = parseMediaInfo(mediaPath ?? mediaTitle);
const fallbackInfo = parseMediaInfo(lookupTarget);
return {
parsedBasename: parsedBasename ?? fallbackInfo.filename ?? null,
parsedTitle: parsed.title,
@@ -33,6 +33,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
SELECT
a.anime_id AS animeId,
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
@@ -63,6 +64,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
SELECT
a.anime_id AS animeId,
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish,
+98 -11
View File
@@ -1,3 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
@@ -24,6 +25,7 @@ export interface TrackerPreparedStatements {
}
export interface AnimeRecordInput {
mediaKind?: MediaKind;
parsedTitle: string;
canonicalTitle: string;
seasonScope?: number | null;
@@ -569,6 +571,8 @@ function ensureSubtitleLineEventIndex(db: DatabaseSync): void {
}
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const mediaKind = input.mediaKind ?? 'anime';
const anilistId = mediaKind === 'anime' ? input.anilistId : null;
const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
const canonicalTitle =
@@ -580,17 +584,23 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
}
const byAnilistId =
input.anilistId !== null
? (db.prepare('SELECT anime_id FROM imm_anime WHERE anilist_id = ?').get(input.anilistId) as {
anilistId !== null
? (db
.prepare("SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'")
.get(anilistId) as {
anime_id: number;
} | null)
: null;
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const byTitleAlias = db
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
.prepare(
`SELECT a.anime_id FROM imm_anime_title_aliases AS alias
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
if (existing?.anime_id) {
// An alias remembers an intentionally merged-away spelling. Reusing it
@@ -601,7 +611,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
UPDATE imm_anime
SET
canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
anilist_id = COALESCE(?, anilist_id),
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -611,7 +621,8 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
`,
).run(
canonicalTitleUpdate,
input.anilistId,
mediaKind,
anilistId,
input.titleRomaji,
input.titleEnglish,
input.titleNative,
@@ -627,6 +638,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
.prepare(
`
INSERT INTO imm_anime(
media_kind,
normalized_title_key,
canonical_title,
anilist_id,
@@ -636,13 +648,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
metadata_json,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
input.mediaKind ?? 'anime',
normalizedTitleKey,
canonicalTitle,
input.anilistId,
anilistId,
input.titleRomaji,
input.titleEnglish,
input.titleNative,
@@ -803,6 +816,7 @@ export function linkYoutubeVideoToAnimeRecord(
}
const animeId = getOrCreateAnimeRecord(db, {
mediaKind: 'youtube',
parsedTitle: identity.parsedTitle,
canonicalTitle: identity.canonicalTitle,
anilistId: null,
@@ -875,6 +889,70 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
}
}
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
// and foreign keys disabled so dependent history and manual assignments survive.
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
sql: string;
};
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
const sequence = db
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
.get() as { seq: number } | null;
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN IMMEDIATE');
db.exec(
schema.sql
.replace(
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
'CREATE TABLE imm_anime_new',
)
.replace(
/normalized_title_key TEXT NOT NULL UNIQUE/i,
'normalized_title_key TEXT NOT NULL',
),
);
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
DROP TABLE imm_anime;
ALTER TABLE imm_anime_new RENAME TO imm_anime;`);
if (sequence) {
db.prepare("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'imm_anime'").run(
sequence.seq,
);
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
} finally {
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
}
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
ON imm_anime(media_kind, normalized_title_key)`);
}
// Older builds can create channel rows with the default anime kind even after
// the schema upgrade. Repair classification on every startup without moving videos.
function classifyYoutubeChannels(db: DatabaseSync): void {
db.exec(`
UPDATE imm_anime
SET media_kind = 'youtube', anilist_id = NULL
WHERE media_kind = 'anime'
AND NOT EXISTS (SELECT 1 FROM imm_anime AS channel
WHERE channel.media_kind = 'youtube'
AND channel.normalized_title_key = imm_anime.normalized_title_key)
AND (
normalized_title_key LIKE 'youtube channel %'
OR CASE WHEN json_valid(metadata_json)
THEN json_extract(metadata_json, '$.source') = 'youtube-channel'
ELSE 0 END
)
`);
}
export function ensureSchema(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version (
@@ -897,6 +975,7 @@ export function ensureSchema(db: DatabaseSync): void {
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
.get() as { schema_version: number } | null;
if (currentVersion?.schema_version === SCHEMA_VERSION) {
classifyYoutubeChannels(db);
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
@@ -908,7 +987,7 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE,
normalized_title_key TEXT NOT NULL,
canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE,
title_romaji TEXT,
@@ -921,6 +1000,12 @@ export function ensureSchema(db: DatabaseSync): void {
LAST_UPDATE_DATE TEXT
);
`);
addColumnIfMissing(
db,
'imm_anime',
'media_kind',
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1464,6 +1549,8 @@ export function ensureSchema(db: DatabaseSync): void {
);
}
migrateAnimeTitleUniqueness(db);
classifyYoutubeChannels(db);
migrateSessionEventTimestampsToText(db);
ensureLexicalDailyRollupTables(db);
+5 -1
View File
@@ -1,4 +1,6 @@
export const SCHEMA_VERSION = 23;
import type { MediaKind } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 25;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -518,6 +520,7 @@ export interface YoutubeVideoMetadata {
}
export interface AnimeLibraryRow {
mediaKind: MediaKind;
animeId: number;
canonicalTitle: string;
anilistId: number | null;
@@ -531,6 +534,7 @@ export interface AnimeLibraryRow {
}
export interface AnimeDetailRow {
mediaKind: MediaKind;
animeId: number;
canonicalTitle: string;
anilistId: number | null;
@@ -0,0 +1,310 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Database, type DatabaseSync } from './sqlite';
import {
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
linkYoutubeVideoToAnimeRecord,
} from './storage';
import { getAnimeDetail, getAnimeLibrary } from './query-library';
import { updateAnimeAnilistInfo } from './query-maintenance';
import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './anime-season-repair';
import {
getAnimeMergeRecommendations,
recordAnimeMergeRecommendation,
} from './anime-merge-recommendations';
import { createCoverArtFetcher } from '../anilist/cover-art-fetcher';
import { SCHEMA_VERSION, SOURCE_TYPE_REMOTE, type YoutubeVideoMetadata } from './types';
const metadata: YoutubeVideoMetadata = {
youtubeVideoId: 'video1',
videoUrl: 'https://www.youtube.com/watch?v=video1',
videoTitle: 'Video title',
videoThumbnailUrl: null,
channelId: 'UC123',
channelName: 'Channel name',
channelUrl: 'https://www.youtube.com/channel/UC123',
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
metadataJson: null,
};
function createVideo(db: DatabaseSync, key: string): number {
return getOrCreateVideoRecord(db, key, {
canonicalTitle: 'Video title',
sourcePath: null,
sourceUrl: metadata.videoUrl,
sourceType: SOURCE_TYPE_REMOTE,
});
}
function createAnime(
db: DatabaseSync,
parsedTitle: string,
metadataJson: string | null = null,
): number {
return getOrCreateAnimeRecord(db, {
parsedTitle,
canonicalTitle: parsedTitle,
metadataJson,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
});
}
test('schema 23 channel migration preserves history and manual assignments and is idempotent', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const ids = [
createAnime(db, 'youtube-channel:UC123'),
createAnime(db, 'youtube-channel-url:https://www.youtube.com/@creator'),
createAnime(db, 'youtube-channel-name:Creator'),
createAnime(db, 'Renamed channel', '{ "source": "youtube-channel" }'),
];
const animeId = createAnime(db, 'Anime title', 'legacy non-JSON metadata');
const videoId = createVideo(db, 'manual');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(animeId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)',
).run(animeId);
const history = getAnimeLibrary(db);
// Reproduce the previous schema, including its lack of a media kind column.
db.exec(
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
);
ensureSchema(db);
ensureSchema(db);
for (const id of ids) {
const row = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(id);
assert.ok(row && typeof row === 'object' && 'media_kind' in row);
assert.equal(row.media_kind, 'youtube');
}
assert.deepEqual(getAnimeLibrary(db), history);
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), animeId);
const version = db
.prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
.get();
assert.ok(version && typeof version === 'object' && 'version' in version);
assert.equal(version.version, SCHEMA_VERSION);
} finally {
db.close();
}
});
test('channel creation and repeated linking expose youtube in library and detail without losing totals', async () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const videoId = createVideo(db, 'first');
const channelId = linkYoutubeVideoToAnimeRecord(db, videoId, metadata);
assert.ok(channelId);
const secondVideoId = createVideo(db, 'second');
assert.equal(linkYoutubeVideoToAnimeRecord(db, secondVideoId, metadata), channelId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)',
).run(channelId);
assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'youtube');
const detail = getAnimeDetail(db, channelId);
assert.equal(detail?.mediaKind, 'youtube');
assert.equal(detail?.episodeCount, 2);
assert.equal(detail?.totalActiveMs, 123456);
assert.equal(detail?.totalCards, 7);
// Even parsed season numbers and a matching anime name must not trigger repairs.
db.prepare('UPDATE imm_videos SET parsed_season = video_id').run();
assert.equal(repairLegacySeasonlessAnimeRows(db).repaired, 0);
const animeId = createAnime(db, 'Channel name');
for (const matchConfidence of ['exact', 'weak', 'manual'] as const) {
assert.equal(
resolveAnimeAnilistConflict(db, channelId, 123, { matchConfidence })
.anilistAssignmentBlocked,
true,
);
}
updateAnimeAnilistInfo(db, videoId, {
anilistId: 123,
titleRomaji: 'Wrong title',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
});
recordAnimeMergeRecommendation(db, channelId, animeId, 123);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
assert.equal(getAnimeDetail(db, channelId)?.anilistId, null);
const fetcher = createCoverArtFetcher(
{
acquire: async () => {
assert.fail('YouTube must not query AniList');
},
recordResponse: () => {},
},
console,
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Channel name'), false);
} finally {
db.close();
}
});
test('startup reclassifies channels created by an older build after the schema upgrade', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
// An old build omits media_kind when creating a channel in the upgraded DB.
const channelId = createAnime(db, 'youtube-channel:UCnew');
db.prepare('UPDATE imm_anime SET anilist_id = 321 WHERE anime_id = ?').run(channelId);
const animeId = createAnime(db, 'Regular anime');
const videoId = createVideo(db, 'older-build');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(channelId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 120000, 5)',
).run(channelId);
assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'anime');
ensureSchema(db);
const channel = getAnimeLibrary(db)[0];
assert.equal(channel?.animeId, channelId);
assert.equal(channel?.mediaKind, 'youtube');
assert.equal(
(
db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as {
anilist_id: number | null;
}
).anilist_id,
null,
);
assert.equal(channel?.totalActiveMs, 120000);
assert.equal(channel?.totalCards, 5);
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId);
const anime = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(animeId);
assert.ok(anime && typeof anime === 'object' && 'media_kind' in anime);
assert.equal(anime.media_kind, 'anime');
} finally {
db.close();
}
});
test('title identity and aliases never cross media kinds', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const animeId = createAnime(db, 'Shared title');
const input = {
mediaKind: 'youtube' as const,
parsedTitle: 'Shared title',
canonicalTitle: 'Shared title',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
};
const channelId = getOrCreateAnimeRecord(db, input);
assert.notEqual(channelId, animeId);
db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = ?').run(channelId);
assert.equal(getOrCreateAnimeRecord(db, input), channelId);
assert.equal(
(
db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as {
anilist_id: number | null;
}
).anilist_id,
null,
);
assert.equal(createAnime(db, 'Shared title'), animeId);
db.prepare(
'INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id) VALUES (?, ?)',
).run('alias title', animeId);
assert.notEqual(getOrCreateAnimeRecord(db, { ...input, parsedTitle: 'Alias title' }), animeId);
assert.throws(() =>
db
.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'duplicate', 'youtube')",
)
.run(),
);
} finally {
db.close();
}
});
test('schema 24 title constraint migration preserves referenced data', () => {
const db = new Database(':memory:');
try {
// Reproduce the original table-level uniqueness constraint.
db.exec(`CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_english TEXT, title_native TEXT,
episodes_total INTEGER, description TEXT, metadata_json TEXT,
CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))
)`);
ensureSchema(db);
const animeId = createAnime(db, 'Shared title');
const videoId = createVideo(db, 'migration-video');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(animeId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123, 4)',
).run(animeId);
// Restore the old constraint while leaving child rows populated.
db.exec(`PRAGMA foreign_keys = OFF;
CREATE TABLE old_anime AS SELECT * FROM imm_anime;
DROP TABLE imm_anime;
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
INSERT INTO imm_anime SELECT * FROM old_anime;
DROP TABLE old_anime;
DELETE FROM imm_schema_version;
INSERT INTO imm_schema_version VALUES (24, 0);
PRAGMA foreign_keys = ON;`);
ensureSchema(db);
ensureSchema(db);
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
assert.equal(
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
1,
);
assert.equal(
(
db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
anime_id: number;
}
).anime_id,
animeId,
);
assert.equal(
(
db
.prepare('SELECT total_active_ms FROM imm_lifetime_anime WHERE anime_id = ?')
.get(animeId) as { total_active_ms: number }
).total_active_ms,
123,
);
db.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'Channel', 'youtube')",
).run();
} finally {
db.close();
}
});
+42 -11
View File
@@ -671,7 +671,34 @@ test('registerIpcHandlers accepts the keep-without-media timing decision', async
assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]);
});
test('registerIpcHandlers validates and forwards combined timing review text', async () => {
test('frame IPC validates timestamps and directions', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const requests: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
getMediaTimingReviewFrame: async (request) => {
requests.push(request);
return { ok: true };
},
}),
registrar,
);
const frame = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewFrame)!;
const valid = { reviewId: 'r', timestamp: 13, direction: 1 };
assert.deepEqual(await frame({}, valid), { ok: true });
for (const invalid of [
null,
{},
{ ...valid, timestamp: NaN },
{ ...valid, timestamp: '13' },
{ ...valid, direction: 2 },
]) {
assert.equal(((await frame({}, invalid)) as { ok: boolean }).ok, false);
}
assert.deepEqual(requests, [valid]);
});
test('registerIpcHandlers validates and forwards timing review text and screenshot selection', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const requests: unknown[] = [];
registerIpcHandlers(
@@ -696,6 +723,7 @@ test('registerIpcHandlers validates and forwards combined timing review text', a
startTime: 10,
endTime: 12,
text: '前の行 対象の行',
screenshotTime: 13,
},
},
),
@@ -709,20 +737,23 @@ test('registerIpcHandlers validates and forwards combined timing review text', a
startTime: 10,
endTime: 12,
text: '前の行 対象の行',
screenshotTime: 13,
},
},
]);
assert.deepEqual(
await handler!(
{},
{
reviewId: 'review-1',
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
},
),
{ ok: false, message: 'Timing review is unavailable.' },
);
for (const invalid of [{ text: ' ' }, { screenshotTime: Infinity }]) {
assert.deepEqual(
await handler!(
{},
{
reviewId: 'review-1',
decision: { action: 'confirm', startTime: 10, endTime: 12, ...invalid },
},
),
{ ok: false, message: 'Timing review is unavailable.' },
);
}
assert.equal(requests.length, 1);
});
+43
View File
@@ -24,6 +24,8 @@ import type {
MediaTimingReviewActionResult,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
@@ -111,6 +113,9 @@ export interface IpcServiceDeps {
previewMediaTimingReview?: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewFrame?: (
request: MediaTimingReviewFrameRequest,
) => Promise<MediaTimingReviewFrameResult>;
getMediaTimingReviewWaveform?: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
@@ -269,6 +274,26 @@ function parseMediaTimingReviewWaveformRequest(
return parseMediaTimingReviewPreviewRequest(payload);
}
function parseMediaTimingReviewFrameRequest(
payload: unknown,
): MediaTimingReviewFrameRequest | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (
typeof record.reviewId !== 'string' ||
!record.reviewId ||
typeof record.timestamp !== 'number' ||
!Number.isFinite(record.timestamp) ||
(record.direction !== undefined && record.direction !== -1 && record.direction !== 1)
)
return null;
return {
reviewId: record.reviewId,
timestamp: record.timestamp,
...(record.direction !== undefined ? { direction: record.direction as -1 | 1 } : {}),
};
}
function parseMediaTimingReviewResolveRequest(
payload: unknown,
): MediaTimingReviewResolveRequest | null {
@@ -291,6 +316,9 @@ function parseMediaTimingReviewResolveRequest(
Number.isFinite(decisionRecord.startTime) &&
typeof decisionRecord.endTime === 'number' &&
Number.isFinite(decisionRecord.endTime) &&
(decisionRecord.screenshotTime === undefined ||
(typeof decisionRecord.screenshotTime === 'number' &&
Number.isFinite(decisionRecord.screenshotTime))) &&
(decisionRecord.text === undefined ||
(typeof decisionRecord.text === 'string' && decisionRecord.text.trim().length > 0))
) {
@@ -300,6 +328,9 @@ function parseMediaTimingReviewResolveRequest(
action: 'confirm',
startTime: decisionRecord.startTime,
endTime: decisionRecord.endTime,
...(decisionRecord.screenshotTime === undefined
? {}
: { screenshotTime: decisionRecord.screenshotTime as number }),
...(decisionRecord.text === undefined ? {} : { text: decisionRecord.text }),
},
};
@@ -365,6 +396,7 @@ export interface IpcDepsRuntimeOptions {
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
getMediaTimingReviewFrame?: IpcServiceDeps['getMediaTimingReviewFrame'];
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
@@ -463,6 +495,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
previewMediaTimingReview: options.previewMediaTimingReview,
getMediaTimingReviewFrame: options.getMediaTimingReviewFrame,
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
resolveMediaTimingReview: options.resolveMediaTimingReview,
@@ -613,6 +646,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getMediaTimingReviewWaveform(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewFrame,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewFrameRequest(payload);
if (!request || !deps.getMediaTimingReviewFrame) {
return { ok: false, message: 'Screenshot preview is unavailable.' };
}
return await deps.getMediaTimingReviewFrame(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
async (_event: unknown, reviewId: unknown) => {
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMediaTimingFrameExtractor, selectMediaTimingFrame } from './media-timing-frame';
test('frame stepping follows decoded timestamps with variable frame durations', () => {
const times = [10, 10.041667, 10.125, 10.166667];
assert.equal(selectMediaTimingFrame(times, 10.05), 10.125);
assert.equal(selectMediaTimingFrame(times, 10.125 - 0.000001, 1), 10.166667);
assert.equal(selectMediaTimingFrame(times, 10.125 - 0.000001, -1), 10.041667);
assert.equal(selectMediaTimingFrame(times, 10, -1), undefined);
assert.equal(selectMediaTimingFrame(times, 10.166667, 1), undefined);
assert.equal(selectMediaTimingFrame([], 10), undefined);
});
function fixture(startTime = 0) {
const calls: Array<{ file: string; args: string[] }> = [];
const extractor = createMediaTimingFrameExtractor(async (file, args) => {
calls.push({ file, args });
if (file === 'ffmpeg') return Buffer.from('image');
if (args.includes('format=start_time'))
return Buffer.from(JSON.stringify({ format: { start_time: String(startTime) } }));
return Buffer.from(
JSON.stringify({
frames: [10, 10.04, 10.12, 10.16].map((time) => ({
best_effort_timestamp_time: String(time + startTime),
})),
}),
);
});
return { calls, extractor };
}
test('frame extraction normalizes nonzero source start times and reuses the frame index', async () => {
const { calls, extractor } = fixture(5);
const first = await extractor.generate({ media: '/movie.mkv', timestamp: 10.05 });
assert.ok(Math.abs(first.timestamp - 10.119999) < 0.000001);
assert.equal(first.dataUrl, 'data:image/jpeg;base64,aW1hZ2U=');
await extractor.generate({ media: '/movie.mkv', timestamp: first.timestamp, direction: 1 });
assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 2);
assert.ok(calls[1]!.args.includes('13.05%17.05'));
extractor.clear();
await extractor.generate({ media: '/movie.mkv', timestamp: 10.05 });
assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 4);
});
test('cached windows keep source timestamps for ffmpeg and use absolute ffprobe intervals', async () => {
const { calls, extractor } = fixture();
await extractor.generate({
media: { path: '/window.mkv', absoluteTimestamps: true },
timestamp: 10.05,
});
assert.ok(calls[1]!.args.includes('8.05%12.05'));
assert.equal(calls[1]!.args.includes('-seek_timestamp'), false);
assert.ok(calls[2]!.args.includes('-seek_timestamp'));
});
test('remote frame reads carry source headers and do not silently reuse a different input', async () => {
const { calls, extractor } = fixture();
const media = {
path: 'https://example.test/video',
inputOptions: { headers: { 'X-Emby-Token': 'test-token' } },
};
await extractor.generate({ media, timestamp: 10.05 });
assert.ok(calls.every((call) => call.args.includes('X-Emby-Token: test-token\r\n')));
await extractor.generate({
media: { ...media, path: 'https://example.test/other' },
timestamp: 10.05,
});
assert.equal(calls.filter((call) => call.file === 'ffprobe').length, 4);
});
+159
View File
@@ -0,0 +1,159 @@
import { execFile } from 'node:child_process';
import { normalizeMediaInput, type MediaInput } from '../../media-input';
export interface MediaTimingFrameOptions {
media: MediaInput;
timestamp: number;
direction?: -1 | 1;
}
const FRAME_EPSILON = 0.00001;
const PROBE_RADIUS_SECONDS = 2;
function run(file: string, args: string[]): Promise<Buffer> {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{ encoding: 'buffer', timeout: 30_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
(error, stdout) => {
// Child-process errors contain the input URL, which may contain authentication tokens.
if (error)
reject(
new Error('Screenshot preview is unavailable. Check FFmpeg and the video source.'),
);
else resolve(stdout);
},
);
});
}
/** Uses decoded timestamps, rather than an assumed FPS, including for variable-rate video. */
export function selectMediaTimingFrame(
times: readonly number[],
timestamp: number,
direction?: -1 | 1,
): number | undefined {
if (direction === -1)
return [...times].reverse().find((time) => time < timestamp - FRAME_EPSILON);
if (direction === 1) return times.find((time) => time > timestamp + FRAME_EPSILON);
return times.find((time) => time >= timestamp - FRAME_EPSILON) ?? times.at(-1);
}
export function createMediaTimingFrameExtractor(execute: typeof run = run) {
let cached: { key: string; start: number; end: number; times: number[] } | null = null;
let source: { key: string; offset: number } | null = null;
let generation = 0;
async function generate(
options: MediaTimingFrameOptions,
): Promise<{ dataUrl: string; timestamp: number }> {
const input = normalizeMediaInput(options.media);
const key = JSON.stringify(options.media);
const currentGeneration = generation;
const absolute = typeof options.media !== 'string' && options.media.absoluteTimestamps;
// -seek_timestamp is an ffmpeg option; ffprobe intervals already use stream timestamps.
const probeInputArgs = normalizeMediaInput({
path: input.path,
...(typeof options.media !== 'string' ? { inputOptions: options.media.inputOptions } : {}),
}).inputArgs;
let offset = source?.key === key ? source.offset : undefined;
if (offset === undefined) {
const metadata = JSON.parse(
(
await execute('ffprobe', [
'-v',
'error',
...probeInputArgs,
'-show_entries',
'format=start_time',
'-of',
'json',
input.path,
])
).toString(),
) as { format?: { start_time?: string } };
const start = Number(metadata.format?.start_time ?? 0);
offset = absolute || !Number.isFinite(start) ? 0 : start;
if (generation === currentGeneration) source = { key, offset };
}
let times: number[];
if (
cached?.key === key &&
options.timestamp > cached.start + 0.5 &&
options.timestamp < cached.end - 0.5
) {
times = cached.times;
} else {
const start = Math.max(0, options.timestamp - PROBE_RADIUS_SECONDS);
const end = options.timestamp + PROBE_RADIUS_SECONDS;
const result = JSON.parse(
(
await execute('ffprobe', [
'-v',
'error',
...probeInputArgs,
'-read_intervals',
`${start + offset}%${end + offset}`,
'-select_streams',
'v:0',
'-show_entries',
'frame=best_effort_timestamp_time',
'-of',
'json',
input.path,
])
).toString(),
) as { frames?: { best_effort_timestamp_time?: string }[] };
times = [
...new Set(
(result.frames ?? [])
.map((frame) => Number(frame.best_effort_timestamp_time) - offset)
.filter((time) => Number.isFinite(time) && time >= 0 && time >= start && time <= end),
),
].sort((a, b) => a - b);
if (generation === currentGeneration) cached = { key, start, end, times };
}
const timestamp = selectMediaTimingFrame(times, options.timestamp, options.direction);
if (timestamp === undefined) throw new Error('No adjacent video frame is available here.');
// Round down by one microsecond so decimal timestamp rounding cannot skip the chosen frame.
const seekTime = Math.max(0, timestamp - 0.000001);
const image = await execute('ffmpeg', [
'-hide_banner',
'-nostdin',
'-loglevel',
'error',
'-ss',
String(seekTime),
...input.inputArgs,
'-i',
input.path,
'-map',
'0:v:0',
'-frames:v',
'1',
'-an',
'-sn',
'-vf',
'scale=w=640:h=360:force_original_aspect_ratio=decrease',
'-c:v',
'mjpeg',
'-q:v',
'3',
'-f',
'image2pipe',
'pipe:1',
]);
if (!image.length) throw new Error('No video frame is available here.');
return { dataUrl: `data:image/jpeg;base64,${image.toString('base64')}`, timestamp: seekTime };
}
return {
generate,
clear: () => {
generation += 1;
cached = null;
source = null;
},
};
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export type MpvMessage = {
event?: string;
@@ -337,8 +338,10 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title: typeof msg.data === 'string' ? msg.data.trim() : null,
title,
});
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
+15
View File
@@ -120,6 +120,21 @@ test('MpvIpcClient emits fullscreen property changes', async () => {
assert.deepEqual(events, [{ fullscreen: true }]);
});
test('MpvIpcClient ignores URL-derived titles without replacing known metadata', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const titles: Array<string | null> = [];
client.on('media-title-change', ({ title }) => titles.push(title));
for (const data of [
'My Anime S01E02',
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
await invokeHandleMessage(client, { event: 'property-change', name: 'media-title', data });
}
assert.equal(client.currentMediaTitle, 'My Anime S01E02');
assert.deepEqual(titles, ['My Anime S01E02']);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
@@ -1,6 +1,9 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import { UNKNOWN_MOVE_TARGET_MESSAGE } from '../immersion-tracker/anime-merge.js';
import {
MEDIA_KIND_MISMATCH_MESSAGE,
UNKNOWN_MOVE_TARGET_MESSAGE,
} from '../immersion-tracker/anime-merge.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildSentenceSearchOptions,
@@ -245,7 +248,15 @@ export function registerStatsLibraryRoutes(
const body = await c.req.json().catch(() => null);
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
if (sourceAnimeIds.length === 0) return c.body(null, 400);
const summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
let summary: Awaited<ReturnType<typeof tracker.mergeAnime>>;
try {
summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
} catch (error) {
if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) {
return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409);
}
throw error;
}
// Nothing folded means the target or every source was already gone, so the
// caller should not be told the merge succeeded.
if (summary.mergedAnimeIds.length === 0) return c.body(null, 404);
@@ -281,6 +292,9 @@ export function registerStatsLibraryRoutes(
if (error instanceof Error && error.message === UNKNOWN_MOVE_TARGET_MESSAGE) {
return c.body(null, 404);
}
if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) {
return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409);
}
throw error;
}
});
+37 -5
View File
@@ -2,6 +2,7 @@ import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver'
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
const ANIME_COPY_COLUMNS = [
'media_kind',
'normalized_title_key',
'canonical_title',
'anilist_id',
@@ -98,11 +99,28 @@ export function mergeAnime(
summary: SyncMergeSummary,
): Map<number, number> {
const map = new Map<number, number>();
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
const byAnilist = local.query(
"SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'",
);
const byTitleKey = local.query(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?',
);
// A pre-classification channel can be repaired, but a genuine anime sharing
// its title must remain a separate entry.
const legacyChannel = local.query(`SELECT anime_id FROM imm_anime
WHERE normalized_title_key = ? AND media_kind = 'anime' AND (
normalized_title_key LIKE 'youtube channel %'
OR CASE WHEN json_valid(metadata_json)
THEN json_extract(metadata_json, '$.source') = 'youtube-channel' ELSE 0 END
)`);
const releaseChannelAnilistId = local.query(
"UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?",
);
const fillMissing = local.query(
`UPDATE imm_anime
SET
media_kind = ?,
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END,
title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
@@ -116,12 +134,24 @@ export function mergeAnime(
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
)) {
const remoteId = Number(row.anime_id);
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
if (row.media_kind === 'anime' && row.anilist_id !== null) {
// AniList identifiers belong to anime, including when an older peer
// incorrectly attached one to a channel.
releaseChannelAnilistId.run(row.anilist_id);
}
const existing = ((row.media_kind === 'anime' && row.anilist_id !== null
? byAnilist.get(row.anilist_id)
: undefined) ??
byTitleKey.get(row.normalized_title_key, row.media_kind) ??
(row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as
| SqlRow
| undefined;
if (existing) {
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
fillMissing.run(
row.media_kind,
row.media_kind,
row.title_romaji,
row.title_english,
row.title_native,
@@ -133,7 +163,9 @@ export function mergeAnime(
}
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) => row[column]);
const values = ANIME_COPY_COLUMNS.map((column) =>
column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column],
);
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1;
}
@@ -98,3 +98,122 @@ for (const legacyOccurrences of [false, true]) {
}
});
}
test('sync preserves the YouTube media kind when adding a channel', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-youtube-'));
try {
const localPath = buildDb(dir, 'local.sqlite', {
word: '猫',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remotePath = buildDb(dir, 'remote.sqlite', {
word: '犬',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remote = new Database(remotePath);
remote.exec("UPDATE imm_anime SET media_kind = 'youtube'");
remote.close();
mergeSnapshotIntoDb(localPath, remotePath);
const local = new Database(localPath);
try {
const row = local
.prepare(
"SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'key-remote.sqlite'",
)
.get();
assert.ok(row && typeof row === 'object' && 'media_kind' in row);
assert.equal(row.media_kind, 'youtube');
} finally {
local.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('sync separates same-title kinds and repairs only identifiable legacy channels', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-kinds-'));
const localPath = buildDb(dir, 'local', {
word: 'local',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remotePath = buildDb(dir, 'remote', {
word: 'remote',
seenMs: BASE_MS,
legacyOccurrences: false,
});
try {
const local = new Database(localPath);
local.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', anilist_id = 42;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, metadata_json, title_english, LAST_UPDATE_DATE)
VALUES (2, 'legacy', 'Local channel', '{"source":"youtube-channel"}', 'Keep local', '9999999999999');
UPDATE imm_anime SET anilist_id = 99 WHERE anime_id = 2;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id)
VALUES (3, 'other shared', 'Channel with bad ID', 'youtube', 77);`);
local.close();
const remote = new Database(remotePath);
remote.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', media_kind = 'youtube', anilist_id = 42;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, title_english, description, LAST_UPDATE_DATE)
VALUES (2, 'legacy', 'Remote channel', 'youtube', 'Remote title', 'Fill missing', '1');
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id)
VALUES (3, 'other shared', 'Real anime', 'anime', 77);`);
remote.close();
mergeSnapshotIntoDb(localPath, remotePath);
mergeSnapshotIntoDb(localPath, remotePath);
const db = new Database(localPath);
try {
const rows = db
.prepare(
'SELECT media_kind FROM imm_anime WHERE normalized_title_key = ? ORDER BY media_kind',
)
.all('shared');
assert.deepEqual(rows, [{ media_kind: 'anime' }, { media_kind: 'youtube' }]);
assert.deepEqual(
db
.prepare(
"SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'other shared' ORDER BY media_kind",
)
.all(),
[{ media_kind: 'anime' }, { media_kind: 'youtube' }],
);
assert.equal(
(
db.prepare('SELECT media_kind FROM imm_anime WHERE anilist_id = 77').get() as {
media_kind: string;
}
).media_kind,
'anime',
);
assert.deepEqual(
db
.prepare(
'SELECT media_kind, anilist_id, title_english, description FROM imm_anime WHERE anime_id = 2',
)
.all()[0],
{
media_kind: 'youtube',
anilist_id: null,
title_english: 'Keep local',
description: 'Fill missing',
},
);
assert.equal(
(
db
.prepare(
"SELECT a.media_kind FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id WHERE v.video_key = 'video-remote'",
)
.get() as { media_kind: string }
).media_kind,
'youtube',
);
} finally {
db.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { detectSubtitleGenerationAcceleration } from './subtitle-generation-acceleration';
async function fixture(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-acceleration-test-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function executable(directory: string, name: string, body: string) {
const file = path.join(directory, name);
await writeFile(file, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return file;
}
const cudaOutput =
'ggml_cuda_init: found 1 CUDA devices:\nwhisper_model_load: invalid model data (bad magic)\n';
test('NVIDIA and CUDA discovery work without downloading or loading a model', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`const fs = require('node:fs');
const model = process.argv[process.argv.indexOf('-m') + 1];
if (fs.readFileSync(model).length !== 4) process.exit(1);
fs.writeFileSync(${JSON.stringify(path.join(directory, 'probe-path'))}, model);
process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' },
);
const model = await readFile(path.join(directory, 'probe-path'), 'utf8');
await assert.rejects(readdir(path.dirname(model)), { code: 'ENOENT' });
}));
test('CPU-only, Vulkan-only, hidden CUDA devices and incomplete probes fall back safely', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
for (const output of [
'usage: --no-gpu disable GPU\ninvalid model data (bad magic)',
'ggml_vulkan: Found 1 Vulkan devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 0 CUDA devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 1 CUDA devices\nCUDA error: driver initialization failed',
]) {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(output)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('missing tools, missing NVIDIA devices and driver errors do not recommend turbo', () =>
fixture(async (directory) => {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'missing', message: 'Not installed' },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
for (const driver of [
null,
'process.exit(0);',
'console.log("NVIDIA GPU"); process.exit(1);',
]) {
if (driver !== null) await executable(directory, 'nvidia-smi', driver);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('a hung Whisper probe times out even if it printed a CUDA device', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); setInterval(() => {}, 1000);`,
);
const started = Date.now();
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
assert.ok(Date.now() - started < 6000);
}));
@@ -0,0 +1,62 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationAcceleration,
SubtitleGenerationToolStatus,
} from '../../shared/subtitle-generation';
function probe(command: string, args: string[], env: NodeJS.ProcessEnv) {
return new Promise<{ code: number; stdout: string; stderr: string } | null>((resolve) => {
execFile(
command,
args,
{ env, timeout: 3000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true },
(error, stdout, stderr) => {
const code = error?.code ?? 0;
if (typeof code !== 'number' || error?.killed || error?.signal) {
resolve(null);
return;
}
resolve({ code, stdout, stderr });
},
);
});
}
/** Require both a working NVIDIA driver and CUDA device discovery in the selected Whisper binary. */
export async function detectSubtitleGenerationAcceleration(
whisper: SubtitleGenerationToolStatus,
env: NodeJS.ProcessEnv = process.env,
): Promise<SubtitleGenerationAcceleration> {
const unavailable: SubtitleGenerationAcceleration = { kind: 'unavailable' };
if (whisper.kind === 'missing') return unavailable;
let directory: string | undefined;
try {
const nvidia = await probe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], env);
const gpuName = nvidia?.stdout.trim().split(/\r?\n/)[0]?.trim();
if (nvidia?.code !== 0 || !gpuName) return unavailable;
directory = await mkdtemp(path.join(tmpdir(), 'subminer-cuda-check-'));
const model = path.join(directory, 'probe.bin');
await writeFile(model, Buffer.alloc(4));
// whisper.cpp discovers backends before checking model magic. This deliberately invalid
// local file stops before allocating a model or decoding audio, even on a fresh install.
const result = await probe(whisper.path, ['-m', model, '-f', model], env);
if (
result &&
result.code !== 0 &&
/ggml_cuda_init:\s+found\s+[1-9]\d*\s+CUDA devices?\b/i.test(result.stderr) &&
/invalid model data \(bad magic\)/i.test(result.stderr)
) {
return { kind: 'nvidia-cuda', gpuName };
}
return unavailable;
} catch {
// Detection is advisory; unsupported builds and driver failures must not block generation.
return unavailable;
} finally {
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
}
}
@@ -2,6 +2,24 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
test('reference starts guide long cuts ahead of VAD without dropping unreferenced audio', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 0, endSeconds: 70 }],
[18],
[19],
[22, 43, 200],
);
assert.deepEqual(chunks, [
{ startSeconds: 0, endSeconds: 22.25 },
{ startSeconds: 21.75, endSeconds: 43.25 },
{ startSeconds: 42.75, endSeconds: 63.25 },
{ startSeconds: 62.75, endSeconds: 70 },
]);
assert.deepEqual(splitSpeechPassages([{ startSeconds: 0, endSeconds: 25 }], [], [], [10, 20]), [
{ startSeconds: 0, endSeconds: 25 },
]);
});
test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 544.418, endSeconds: 581.581 }],
+16 -13
View File
@@ -5,11 +5,12 @@ const CHUNK_CONTEXT_SECONDS = 0.25;
const WHISPER_WINDOW_SECONDS = 30;
const PAUSE_SEARCH_SECONDS = 5;
// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio.
// Prefer subtitle timing hints, then detected speech starts and quiet pauses.
export function splitSpeechPassages(
passages: readonly SpeechPassage[],
pauses: readonly number[] = [],
speechStarts: readonly number[] = [],
referenceStarts: readonly number[] = [],
): SpeechPassage[] {
return passages.flatMap((passage) => {
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
@@ -22,17 +23,19 @@ export function splitSpeechPassages(
if (target < passage.endSeconds) {
// Starting in a long quiet lead-in can make Whisper place the next line
// several seconds early. A nearby VAD start gives the next chunk an anchor.
let nearestSpeechStart: number | undefined;
for (const time of speechStarts) {
if (
time >= target - PAUSE_SEARCH_SECONDS &&
time <= target + PAUSE_SEARCH_SECONDS &&
time < passage.endSeconds &&
(nearestSpeechStart === undefined ||
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
)
nearestSpeechStart = time;
}
const nearestStart = (starts: readonly number[]): number | undefined => {
let nearest: number | undefined;
for (const time of starts) {
if (
time >= target - PAUSE_SEARCH_SECONDS &&
time <= target + PAUSE_SEARCH_SECONDS &&
time < passage.endSeconds &&
(nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target))
)
nearest = time;
}
return nearest;
};
let latestPause: number | undefined;
for (const time of pauses) {
if (
@@ -42,7 +45,7 @@ export function splitSpeechPassages(
)
latestPause = time;
}
end = nearestSpeechStart ?? latestPause ?? end;
end = nearestStart(referenceStarts) ?? nearestStart(speechStarts) ?? latestPause ?? end;
}
chunks.push({
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
@@ -13,6 +13,7 @@ import {
parseSpeechPassages,
speechPassageCues,
SPEECH_PASSAGE_SECONDS,
type SpeechPassage,
} from './subtitle-generation-speech';
import type { SubtitleCue } from './subtitle-cue-parser';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
@@ -21,46 +22,50 @@ import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-
export async function transcribeSubtitleDialogue(input: {
config: SubtitleGenerationConfig;
tools: SubtitleGenerationToolPaths & { vad: string };
tools: SubtitleGenerationToolPaths;
referenceStarts?: readonly number[];
modelPath: string;
wavPath: string;
directory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
await access(vadModelPath, constants.R_OK);
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
const segmentLines: string[] = [];
await runSubtitleGenerationProcess({
command: input.tools.vad,
args: [
'-f',
input.wavPath,
'-vm',
vadModelPath,
'-t',
String(input.config.threads),
'-vt',
'0.3',
'--vad-min-speech-duration-ms',
'100',
'--vad-min-silence-duration-ms',
'500',
'-vp',
'350',
'-vmsd',
String(SPEECH_PASSAGE_SECONDS),
'-np',
],
signal: input.signal,
// Capture structured result lines separately from the bounded process log.
onLine: (line) => {
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
segmentLines.push(line);
},
});
const speech = parseSpeechPassages(segmentLines.join('\n'));
let speech: SpeechPassage[] = [];
if (input.tools.vad !== null) {
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
await access(vadModelPath, constants.R_OK);
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
const segmentLines: string[] = [];
await runSubtitleGenerationProcess({
command: input.tools.vad,
args: [
'-f',
input.wavPath,
'-vm',
vadModelPath,
'-t',
String(input.config.threads),
'-vt',
'0.3',
'--vad-min-speech-duration-ms',
'100',
'--vad-min-silence-duration-ms',
'500',
'-vp',
'350',
'-vmsd',
String(SPEECH_PASSAGE_SECONDS),
'-np',
],
signal: input.signal,
// Capture structured result lines separately from the bounded process log.
onLine: (line) => {
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
segmentLines.push(line);
},
});
speech = parseSpeechPassages(segmentLines.join('\n'));
}
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' });
const audible = await findAudiblePassages({
ffmpegPath: input.tools.ffmpeg,
@@ -82,6 +87,7 @@ export async function transcribeSubtitleDialogue(input: {
detected,
pauses,
speech.map((passage) => passage.startSeconds),
input.referenceStarts,
);
const cues: SubtitleCue[] = [];
for (const [index, passage] of passages.entries()) {
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
loadSubtitleGenerationReference,
readSubtitleGenerationReferences,
subtitleGenerationReferences,
} from './subtitle-generation-reference';
const embedded = { type: 'sub', codec: 'ass', 'ff-index': 2, lang: 'eng' };
test('references exclude signs, songs, forced, bitmap and generated tracks even when selected', () => {
const excluded = [
'Signs & Songs',
'SignsSongs',
'Signs/Songs',
'S&S',
'S+S',
'Forced',
'Karaoke',
'OP',
'ED',
'English lyrics',
'Generated Japanese',
];
assert.deepEqual(
subtitleGenerationReferences([
...excluded.map((title) => ({ ...embedded, title, selected: true })),
{ ...embedded, forced: true },
{ ...embedded, codec: 'hdmv_pgs_subtitle' },
{
...embedded,
title: 'English',
external: true,
'external-filename': '/subs/show.en.signs.ass',
},
{ ...embedded, title: 'English Full' },
]).map((reference) => reference.label),
['English Full'],
);
});
test('references rank English dialogue first and resolve loaded external files against mpv cwd', () => {
const refs = subtitleGenerationReferences(
[
{ ...embedded, title: 'French Full', lang: 'fra', selected: true },
{ ...embedded, title: 'English' },
{ type: 'sub', external: true, 'external-filename': 'subs/show.en.full.srt' },
{ type: 'sub', external: true, 'external-filename': 'https://example.com/en.srt' },
{ type: 'sub', 'ff-index': -1 },
null,
],
'/mpv',
);
assert.deepEqual(
refs.map((ref) => ref.label),
['show.en.full.srt', 'English', 'French Full'],
);
assert.deepEqual(refs[0]?.source, { kind: 'external', path: '/mpv/subs/show.en.full.srt' });
assert.deepEqual(
subtitleGenerationReferences([
{ type: 'sub', external: true, 'external-filename': 'relative.en.srt' },
]),
[],
);
});
test('reference discovery captures primary and secondary subtitle delays', async () => {
const properties: Record<string, unknown> = {
'working-directory': '/mpv',
sid: 1,
'secondary-sid': 2,
'sub-delay': 1.5,
'secondary-sub-delay': -2,
};
const refs = await readSubtitleGenerationReferences(
[
{ ...embedded, id: 1 },
{ ...embedded, id: 2 },
{ ...embedded, id: 3 },
],
async (name) => properties[name],
);
assert.deepEqual(
refs.map((ref) => ref.delaySeconds),
[1.5, -2, 0],
);
});
test('reference extraction retries unreadable tracks, restores audio offset and ignores marked lyrics', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'generation-reference-'));
try {
const ffmpegPath = path.join(directory, 'ffmpeg');
await writeFile(
ffmpegPath,
`#!${process.execPath}
const args = process.argv.slice(2);
if (args[args.indexOf('-map') + 1] === '0:2') process.exit(1);
require('node:fs').writeFileSync(args.at(-1), '1\\n00:00:10,000 --> 00:00:12,000\\nHello\\n\\n2\\n00:00:20,000 --> 00:00:22,000\\n♪ Song ♪\\n\\n3\\n00:00:30,000 --> 00:00:32,000\\nWorld\\n');
`,
{ mode: 0o755 },
);
const refs = subtitleGenerationReferences([{ ...embedded }, { ...embedded, 'ff-index': 3 }]);
const hints = await loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 2.5,
});
assert.deepEqual(hints, [7.5, 27.5]);
assert.deepEqual(
await loadSubtitleGenerationReference({
references: refs.slice(0, 1),
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
}),
[],
);
await assert.rejects(
loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
signal: AbortSignal.abort(),
}),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,169 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import { parseSrtCues } from './subtitle-cue-parser';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
export type SubtitleGenerationReference = {
label: string;
delaySeconds: number;
source: { kind: 'embedded'; streamIndex: number } | { kind: 'external'; path: string };
};
// Check both titles and filenames: releases often tag only one of them.
const EXCLUDED =
/(?:^|[^\p{L}\p{N}])(?:signs?(?:songs?)?|songs?|lyrics?|karaoke|forced|s[\s&+_-]*s|op|ed|opening|ending|generated)(?=$|[^\p{L}\p{N}])||/iu;
const TEXT_CODECS = new Set(['ass', 'ssa', 'subrip', 'srt', 'webvtt', 'mov_text', 'text']);
/** Rank loaded dialogue tracks, preferring English, then explicitly full tracks. */
export function subtitleGenerationReferences(
value: unknown,
workingDirectory?: string,
delays: ReadonlyMap<number, number> = new Map(),
): SubtitleGenerationReference[] {
if (!Array.isArray(value)) return [];
const tracks: unknown[] = value;
return tracks
.flatMap((track) => {
if (typeof track !== 'object' || track === null || !('type' in track) || track.type !== 'sub')
return [];
const title = 'title' in track && typeof track.title === 'string' ? track.title : '';
const filename =
'external-filename' in track && typeof track['external-filename'] === 'string'
? track['external-filename']
: '';
const name = `${title} ${path.basename(filename)}`;
if (('forced' in track && track.forced === true) || EXCLUDED.test(name)) return [];
if ('codec' in track && typeof track.codec === 'string' && !TEXT_CODECS.has(track.codec))
return [];
let source: SubtitleGenerationReference['source'];
if ('external' in track && track.external === true) {
let local = filename;
if (local.startsWith('file://')) {
try {
local = fileURLToPath(local);
} catch {
return [];
}
} else if (/^[a-z][a-z\d+.-]*:\/\//i.test(local)) return [];
if (!local || (!path.isAbsolute(local) && !workingDirectory)) return [];
if (!/\.(?:srt|ass|ssa|vtt)$/i.test(local)) return [];
source = { kind: 'external', path: path.resolve(workingDirectory ?? '.', local) };
} else {
if (
!('ff-index' in track) ||
typeof track['ff-index'] !== 'number' ||
!Number.isSafeInteger(track['ff-index']) ||
track['ff-index'] < 0
)
return [];
source = { kind: 'embedded', streamIndex: track['ff-index'] };
}
const language = 'lang' in track && typeof track.lang === 'string' ? track.lang : '';
const english =
/^(?:en|eng|english)(?:[-_]|$)/i.test(language) ||
/(?:^|[\s.\[(_-])(?:en|eng|english)(?=$|[\s.\])_-])/i.test(name);
const full = /\b(?:full|dialogue|dialog)\b/i.test(name);
const selected = 'selected' in track && track.selected === true;
const preferred = 'default' in track && track.default === true;
return [
{
reference: {
label:
title ||
path.basename(filename) ||
`${language || 'Subtitle'} stream ${source.kind === 'embedded' ? source.streamIndex : ''}`,
source,
delaySeconds:
'id' in track && typeof track.id === 'number' ? (delays.get(track.id) ?? 0) : 0,
},
score:
Number(english) * 100 + Number(full) * 20 + Number(selected) * 2 + Number(preferred),
},
];
})
.sort((a, b) => b.score - a.score)
.map(({ reference }) => reference);
}
/** Read mpv's path base and active subtitle delays while capturing timing references. */
export async function readSubtitleGenerationReferences(
tracks: unknown,
requestProperty: (name: string) => Promise<unknown>,
): Promise<SubtitleGenerationReference[]> {
const [directory, primary, secondary, primaryDelay, secondaryDelay] = await Promise.all(
['working-directory', 'sid', 'secondary-sid', 'sub-delay', 'secondary-sub-delay'].map((name) =>
requestProperty(name).catch(() => null),
),
);
const delays = new Map<number, number>();
for (const [id, delay] of [
[primary, primaryDelay],
[secondary, secondaryDelay],
]) {
if (typeof id === 'number' && typeof delay === 'number' && Number.isFinite(delay))
delays.set(id, delay);
}
return subtitleGenerationReferences(
tracks,
typeof directory === 'string' ? directory : undefined,
delays,
);
}
/** Reference timestamps are hints on the extracted audio timeline, never a coverage mask. */
export async function loadSubtitleGenerationReference(input: {
references: readonly SubtitleGenerationReference[];
mediaPath: string;
ffmpegPath: string;
directory: string;
audioOffset: number;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<number[]> {
for (const [index, reference] of input.references.entries()) {
input.signal?.throwIfAborted();
try {
const output = path.join(input.directory, `reference-${index}.srt`);
const embedded = reference.source.kind === 'embedded';
await runSubtitleGenerationProcess({
command: input.ffmpegPath,
args: [
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
...(embedded ? ['-copyts', '-start_at_zero'] : []),
'-i',
reference.source.kind === 'external' ? reference.source.path : input.mediaPath,
'-map',
reference.source.kind === 'embedded' ? `0:${reference.source.streamIndex}` : '0:s:0',
'-c:s',
'srt',
output,
],
signal: input.signal,
});
const starts = parseSrtCues(await readFile(output, 'utf8'))
.filter((cue) => cue.text.trim() && !/[]/u.test(cue.text) && cue.endTime > cue.startTime)
.map((cue) => cue.startTime + reference.delaySeconds - input.audioOffset)
.filter((time) => Number.isFinite(time) && time >= 0);
if (starts.length === 0) continue;
input.onProgress?.({
stage: 'extract',
message: `Using subtitle timing reference: ${reference.label}`,
});
return [...new Set(starts)].sort((a, b) => a - b);
} catch {
input.signal?.throwIfAborted();
// An optional reference must not prevent transcription. Try the next loaded track.
}
}
if (input.references.length)
input.onProgress?.({
stage: 'extract',
message: 'No readable subtitle timing reference. Using audio timing.',
});
return [];
}
@@ -95,6 +95,52 @@ test('config parser accepts supported models and rejects unsafe threads and wron
assert.deepEqual(warnings, ['whisperPath', 'threads']);
});
test('generation uses reference cuts with and without VAD while preserving media offsets', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const ffmpegPath = await executable(
directory,
'reference-ffmpeg',
`
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(input.callsPath)}, JSON.stringify(args) + '\\n');
if (args.includes('-c:s')) {
fs.writeFileSync(args.at(-1), '1\\n00:00:24,500 --> 00:00:26,500\\nHello\\n\\n2\\n00:00:45,500 --> 00:00:47,500\\nWorld\\n');
} else if (args.at(-1) === '-') {
process.stdout.write('out_time_us=70000000\\nprogress=end\\n');
} else fs.writeFileSync(args.at(-1), 'wav');
`,
);
const vadPath = await executable(
directory,
'reference-vad',
"process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 0.000, end = 7000.000\\n');",
);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'model');
for (const vad of [false, true]) {
await writeFile(input.callsPath, '');
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, ffmpegPath, vadPath, vadModelPath: vad ? vadModelPath : '' },
references: [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
],
});
const calls: string[][] = (await readFile(input.callsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const clips = calls.filter((args) => args.includes('-ss'));
assert.equal(clips[0]?.[clips[0].indexOf('-t') + 1], '22.25');
assert.equal(clips[1]?.[clips[1].indexOf('-ss') + 1], '21.75');
const srt = await readFile(output, 'utf8');
assert.match(srt, /00:00:03,500 --> 00:00:04,500/);
assert.match(srt, /00:00:25,250 --> 00:00:26,250/);
}
}));
test('external model path wins and invalid external models never fall back to download', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
+17 -2
View File
@@ -11,6 +11,10 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
import { formatTimestamp } from './subtitle-generation-srt';
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
import {
loadSubtitleGenerationReference,
type SubtitleGenerationReference,
} from './subtitle-generation-reference';
import {
requireSubtitleGenerationTools,
resolveSubtitleGenerationTools,
@@ -174,6 +178,7 @@ export async function generateJapaneseSubtitles(input: {
modelDirectory: string;
mediaPath: string;
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
@@ -257,11 +262,21 @@ export async function generateJapaneseSubtitles(input: {
percent: 0,
message: 'Generating Japanese subtitles...',
});
const referenceStarts = await loadSubtitleGenerationReference({
references: input.references ?? [],
mediaPath,
ffmpegPath: tools.ffmpeg,
directory: temporaryDirectory,
audioOffset: audio.offset,
onProgress: input.onProgress,
signal: input.signal,
});
let srt: string;
if (tools.vad !== null) {
if (tools.vad !== null || referenceStarts.length > 0) {
srt = await transcribeSubtitleDialogue({
config: input.config,
tools: { ...tools, vad: tools.vad },
tools,
referenceStarts,
modelPath: model.path,
wavPath,
directory: temporaryDirectory,
+11
View File
@@ -477,6 +477,7 @@ import { MediaTimingPreviewSession } from './core/services/media-timing-preview'
import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache';
import { resolveMediaGenerationInput } from './anki-integration/media-source';
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
import { createMediaTimingFrameExtractor } from './core/services/media-timing-frame';
import {
collectMediaTimingContextLines,
createMediaTimingReviewRuntime,
@@ -3006,6 +3007,7 @@ function createOverlayHostedModalOpenDeps(): {
};
}
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () =>
@@ -3014,6 +3016,14 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
clearFrameCache: () => mediaTimingFrameExtractor.clear(),
resolveVideoSource: () =>
resolveMediaGenerationInput(appState.mpvClient, 'video', {
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
remoteCacheMode: shouldRequireYoutubeMediaCacheForCurrentPlayback() ? 'required' : 'optional',
}),
resolveMediaSource: async () => {
const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', {
getCachedMediaPath: (currentVideoPath, kind) =>
@@ -5809,6 +5819,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
},
mainDeps: {
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request),
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
+2
View File
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewFrame?: IpcDepsRuntimeOptions['getMediaTimingReviewFrame'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
@@ -266,6 +267,7 @@ export function createMainIpcRuntimeServiceDeps(
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewFrame: params.getMediaTimingReviewFrame,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
+7 -5
View File
@@ -1,4 +1,5 @@
import { updateCurrentMediaPath } from '../core/services';
import { sanitizeMediaTitle } from '../shared/media-identity';
import type { SubtitlePosition } from '../types';
@@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
const sanitized = sanitizeMediaTitle(mediaTitle);
if (mediaTitle.trim() && !sanitized) return;
deps.setCurrentMediaTitle(sanitized);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
return mediaPath && deps.isRemoteMediaPath(mediaPath)
? sanitizeMediaTitle(deps.getCurrentMediaTitle())
: sanitizeMediaTitle(mediaPath);
},
};
}
@@ -9,6 +9,10 @@ import {
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3),
'jellyfin://example.com/item/item::3',
);
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
@@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('post-watch rejects empty media identities before attempted keys or update side effects', async () => {
for (const mediaKey of [
' ',
'stream?api_key=secret',
'stream%3Fapi_key%3Dsecret',
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
@@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
+5 -2
View File
@@ -1,4 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { toMediaIdentityPath } from '../../shared/media-identity';
type AnilistGuess = {
title: string;
@@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null {
const identity = toMediaIdentityPath(mediaKey);
return identity ? `${identity}::${episode}` : null;
}
export function rememberAnilistAttemptedUpdateKey(
@@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (!attemptKey) return;
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
@@ -278,6 +278,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
onEndFile: deps.onPlaybackEndFile,
readProperty: deps.readMpvProperty,
wait,
isCurrent,
})
: null;
@@ -5,9 +5,91 @@ import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { buildConfigSettingsRegistry } from '../../config/settings/registry';
import type { RawConfig } from '../../types/config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
test('settings saves report live changes and only the sections that actually need restart', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-live-'));
const configPath = path.join(dir, 'config.jsonc');
let rawConfig: RawConfig = {};
let resolvedConfig = resolveConfig(rawConfig).resolved;
const applied: string[][] = [];
const runtime = createConfigSettingsRuntime({
fields: buildConfigSettingsRegistry(DEFAULT_CONFIG),
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const result = resolveConfig(rawConfig);
resolvedConfig = result.resolved;
return { ok: true, config: resolvedConfig, warnings: result.warnings, path: configPath };
},
onHotReloadApplied: (diff) => {
applied.push(diff.hotReloadFields);
},
getSettingsWindow: () => null,
setSettingsWindow: () => {},
createSettingsWindow: () => {
throw new Error('Save must not open a window');
},
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () => {
throw new Error('Save must not query Anki');
},
ipcMain: { handle: () => {} },
ipcChannels: IPC_CHANNELS.request,
});
try {
const live = runtime.savePatch({
operations: [
{ op: 'set', path: 'notifications.overlayPosition', value: 'top' },
{
op: 'set',
path: 'subtitleGeneration.threads',
value: DEFAULT_CONFIG.subtitleGeneration.threads + 1,
},
],
});
assert.equal(live.ok, true);
assert.deepEqual(live.restartRequiredFields, []);
assert.deepEqual(live.restartRequiredSections, []);
assert.deepEqual(
new Set(live.hotReloadFields),
new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']),
);
assert.deepEqual(applied, [live.hotReloadFields]);
const mixed = runtime.savePatch({
operations: [
{ op: 'set', path: 'ankiConnect.deck', value: 'Mining' },
{ op: 'set', path: 'ankiConnect.url', value: 'http://127.0.0.1:9999' },
],
});
assert.equal(mixed.ok, true);
assert.deepEqual(mixed.hotReloadFields, ['ankiConnect.deck']);
assert.deepEqual(mixed.restartRequiredSections, ['AnkiConnect']);
const reset = runtime.savePatch({
operations: [
{ op: 'reset', path: 'notifications.overlayPosition' },
{ op: 'reset', path: 'subtitleGeneration.threads' },
],
});
assert.equal(reset.ok, true);
assert.deepEqual(reset.restartRequiredSections, []);
assert.deepEqual(new Set(reset.hotReloadFields), new Set(live.hotReloadFields));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const runtime = createConfigSettingsRuntime({
+3 -1
View File
@@ -1,3 +1,5 @@
import { toMediaIdentityPath } from '../../shared/media-identity';
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
@@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
@@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'force-media-title', 'Episode 1'],
[
'loadfile',
'https://stream.example/video.m3u8',
@@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => {
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
+2 -1
View File
@@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
// Set mpv's title before loadfile can emit a URL-derived media-title event.
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
createMediaTimingReviewRuntime,
type MediaTimingReviewRuntimeDeps,
} from './media-timing-review';
async function start(
options: Partial<MediaTimingReviewRuntimeDeps> = {},
screenshotEnabled = true,
) {
let opened!: (payload: MediaTimingReviewOpenPayload) => void;
const payloadPromise = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
opened = resolve;
});
const frames: MediaTimingFrameOptions[] = [];
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video.mkv',
send: () => {},
requestProperty: async (name) => (name === 'duration' ? 100 : true),
}),
getCurrentMediaPath: () => '/video.mkv',
getMpvExecutablePath: () => '',
createPreviewSession: () => ({
start: async () => {},
play: async () => {},
stop: async () => {},
onPlaybackEnded: () => {},
dispose: () => {},
}),
generateWaveform: async () => [0, 1],
resolveVideoSource: async () => ({ path: '/video.mkv' }),
generateFrame: async (options) => {
frames.push(options);
return { dataUrl: 'data:image/jpeg;base64,AA==', timestamp: options.timestamp };
},
openModal: async (payload) => {
opened(payload);
return true;
},
showStatus: () => {},
...options,
});
const decision = runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
screenshotEnabled,
});
return { runtime, decision, payload: await payloadPromise, frames };
}
test('review accepts a screenshot outside the audio range and validates media bounds', async () => {
const { runtime, decision, payload, frames } = await start();
for (const timestamp of [NaN, Infinity, -1, 100]) {
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp })).ok, false);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: timestamp },
}).ok,
false,
);
}
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 13 })).ok, true);
assert.equal(frames[0]?.timestamp, 13);
assert.equal(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 11, screenshotTime: 13 },
}).ok,
true,
);
assert.deepEqual(await decision, {
action: 'confirm',
startTime: 10,
endTime: 11,
screenshotTime: 13,
});
});
test('screenshot preview reuses the remote audio window and its absolute timestamps', async () => {
let downloads = 0;
const remote = 'https://example.test/video';
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: remote }),
resolveVideoSource: async () => ({ path: remote }),
acquireMediaWindow: async () => {
downloads += 1;
return {
path: '/window.mkv',
sourcePath: remote,
startTime: 7,
endTime: 15,
audioStreamIndex: null,
media: { path: '/window.mkv', absoluteTimestamps: true },
};
},
});
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 });
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, true);
assert.equal(downloads, 1);
assert.deepEqual(frames[0]?.media, { path: '/window.mkv', absoluteTimestamps: true });
await runtime.dispose();
await decision;
});
test('split video streams never read an audio-only cache as a video source', async () => {
const video = {
path: 'https://example.test/video',
inputOptions: { headers: { 'X-Test': 'value' } },
};
const { runtime, payload, decision, frames } = await start({
resolveMediaSource: async () => ({ path: 'https://example.test/audio' }),
resolveVideoSource: async () => video,
});
await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
assert.deepEqual(frames[0]?.media, video);
await runtime.dispose();
await decision;
});
test('disabled screenshots and missing video inputs do not trigger extraction', async () => {
for (const [enabled, options] of [
[false, {}],
[true, { resolveVideoSource: async () => null }],
] as const) {
const { runtime, payload, decision, frames } = await start(options, enabled);
assert.equal((await runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 })).ok, false);
assert.equal(frames.length, 0);
await runtime.dispose();
await decision;
}
});
test('closing a review invalidates in-flight frame results and clears the frame index', async () => {
let finish!: (value: { dataUrl: string; timestamp: number }) => void;
let clearCount = 0;
const { runtime, payload, decision } = await start({
generateFrame: () =>
new Promise((resolve) => {
finish = resolve;
}),
clearFrameCache: () => {
clearCount += 1;
},
});
const frame = runtime.getFrame({ reviewId: payload.reviewId, timestamp: 11 });
await runtime.dispose();
finish({ dataUrl: 'data:image/jpeg;base64,AA==', timestamp: 11 });
assert.equal((await frame).stale, true);
await decision;
assert.equal(clearCount, 1);
});
+89 -8
View File
@@ -7,10 +7,13 @@ import type {
MediaTimingReviewPreviewRequest,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
isRemoteMediaWindowSourcePath,
type RemoteMediaWindow,
@@ -59,6 +62,8 @@ interface ActiveReview {
mediaPath: string;
/** What the waveform reads when no cached window is available. */
waveformMedia: MediaInput;
videoSource: ReviewMediaSource | null;
frameInFlight: boolean;
audioStreamIndex?: number;
/** Remote source to download windows of; null for local media or without a cache. */
windowSource: RemoteMediaWindowSource | null;
@@ -81,6 +86,11 @@ export interface MediaTimingReviewRuntimeDeps {
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
resolveVideoSource?: () => Promise<ReviewMediaSource | null>;
generateFrame?: (
options: MediaTimingFrameOptions,
) => Promise<{ dataUrl: string; timestamp: number }>;
clearFrameCache?: () => void;
/** Downloads (or reuses) a local window of a remote source covering the range. */
acquireMediaWindow?: (
source: RemoteMediaWindowSource,
@@ -227,6 +237,9 @@ export function buildMediaTimingReviewPayload(
timelineEndTime,
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
maxMediaDuration,
...(request.screenshotEnabled !== undefined
? { screenshotEnabled: request.screenshotEnabled }
: {}),
};
}
@@ -335,13 +348,15 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
]);
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null;
@@ -383,6 +398,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
payload,
mediaPath,
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
videoSource,
frameInFlight: false,
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
windowSource,
window: null,
@@ -527,6 +544,58 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
async function getFrame(
request: MediaTimingReviewFrameRequest,
): Promise<MediaTimingReviewFrameResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) return staleReviewResult();
if (!current.payload.screenshotEnabled || !deps.generateFrame || !current.videoSource) {
return { ok: false, message: 'Screenshot preview is unavailable for this media.' };
}
if (
!Number.isFinite(request.timestamp) ||
request.timestamp < 0 ||
(current.payload.mediaDuration !== undefined &&
request.timestamp >= current.payload.mediaDuration) ||
(request.direction !== undefined && request.direction !== -1 && request.direction !== 1)
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
if (current.frameInFlight)
return { ok: false, message: 'A screenshot preview is already loading.' };
current.frameInFlight = true;
try {
// Reuse the audio window only when it contains this same video source (not split streams).
const range = {
startTime: Math.max(0, Math.min(current.payload.timelineStartTime, request.timestamp - 2)),
endTime: Math.min(
current.payload.mediaDuration ?? Infinity,
Math.max(current.payload.timelineEndTime, request.timestamp + 2),
),
};
const window =
current.windowSource?.path === current.videoSource.path
? await ensureWindow(current, range)
: null;
if (active !== current) return staleReviewResult();
const frame = await deps.generateFrame({
media: window?.media ?? current.videoSource,
timestamp: request.timestamp,
...(request.direction !== undefined ? { direction: request.direction } : {}),
});
if (active !== current) return staleReviewResult();
return { ok: true, ...frame };
} catch {
if (active !== current) return staleReviewResult();
return {
ok: false,
message: 'Screenshot preview unavailable. Try another time or reset to the midpoint.',
};
} finally {
current.frameInFlight = false;
}
}
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
@@ -550,13 +619,23 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return staleReviewResult();
}
if (request.decision.action === 'confirm') {
const { startTime, endTime, text } = request.decision;
const { startTime, endTime, text, screenshotTime } = request.decision;
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
return { ok: false, message: 'The selected timing range is invalid.' };
}
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
return { ok: false, message: 'The combined sentence text is invalid.' };
}
if (
screenshotTime !== undefined &&
(!current.payload.screenshotEnabled ||
!Number.isFinite(screenshotTime) ||
screenshotTime < 0 ||
(current.payload.mediaDuration !== undefined &&
screenshotTime >= current.payload.mediaDuration))
) {
return { ok: false, message: 'The screenshot time is invalid.' };
}
}
current.resolve(request.decision);
return { ok: true };
@@ -566,6 +645,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const current = active;
active = null;
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
@@ -582,6 +662,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
requestReview,
previewRange,
getWaveform,
getFrame,
stopPreview,
resolveReview,
dispose,
@@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
getModelDirectory: () => '/models',
getMpvClient: () => client,
onProgress: () => {},
detectAcceleration: async () => ({ kind: 'unavailable' }),
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
resolveTools: async (config) => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
@@ -71,6 +72,27 @@ test('generation preserves the output without attaching it to a different video'
assert.deepEqual(subject.commands, []);
});
test('generation selects loaded dialogue references and excludes the signs track', async () => {
const subject = fixture({
generate: async (input) => {
assert.deepEqual(input.references, [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
]);
return '/video/generated.srt';
},
});
const request = subject.client.requestProperty;
subject.client.requestProperty = async (name) =>
name === 'track-list'
? [
{ type: 'audio', selected: true, 'ff-index': 3 },
{ type: 'sub', lang: 'eng', title: 'Signs & Songs', 'ff-index': 4 },
{ type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 5 },
]
: request(name);
assert.equal((await subject.runtime.start()).ok, true);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' });
@@ -182,6 +204,40 @@ test('external model paths prevent managed selection, including unreadable overr
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
});
test('CUDA recommendations preserve selected and configured models and follow the Whisper path', async () => {
let whisperPath = '/cuda/whisper-cli';
const checked: string[] = [];
const subject = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, managedModel: 'medium' }),
resolveTools: async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: whisperPath },
vad: null,
}),
detectAcceleration: async (whisper) => {
assert.equal(whisper.kind, 'found');
if (whisper.kind !== 'found') throw new Error('Expected a Whisper executable');
checked.push(whisper.path);
return whisper.path.startsWith('/cuda/')
? { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }
: { kind: 'unavailable' };
},
});
const initial = await subject.runtime.getStatus();
assert.deepEqual(initial.acceleration, { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' });
assert.equal(initial.managedModel, 'medium');
const selected = await subject.runtime.selectModel('small');
assert.equal(selected.managedModel, 'small');
assert.equal(selected.acceleration.kind, 'nvidia-cuda');
assert.deepEqual(checked, ['/cuda/whisper-cli']);
whisperPath = '/cpu/whisper-cli';
const changed = await subject.runtime.getStatus();
assert.equal(changed.acceleration.kind, 'unavailable');
assert.equal(changed.managedModel, 'small');
assert.deepEqual(checked, ['/cuda/whisper-cli', '/cpu/whisper-cli']);
});
test('status reports the speech detector only while dialogue mode is on', async () => {
const { runtime } = fixture({
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
@@ -1,4 +1,6 @@
import path from 'node:path';
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import {
downloadSubtitleGenerationVadModel,
@@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps {
download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools;
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
}
@@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
let accelerationCheck:
| {
path: string;
expires: number;
result: ReturnType<typeof detectSubtitleGenerationAcceleration>;
}
| undefined;
function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig();
return {
@@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig();
const tools = await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config);
const whisperPath = tools.whisper.kind === 'found' ? tools.whisper.path : '';
if (
!accelerationCheck ||
accelerationCheck.path !== whisperPath ||
(!controller && Date.now() >= accelerationCheck.expires)
) {
accelerationCheck = {
path: whisperPath,
expires: Date.now() + 30_000,
result: (deps.detectAcceleration ?? detectSubtitleGenerationAcceleration)(tools.whisper),
};
}
const acceleration = await accelerationCheck.result;
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config,
deps.getModelDirectory(),
@@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
),
},
// Session toggles decide whether the speech detector executable is required.
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config),
tools,
acceleration,
managedModel: config.managedModel,
externalModelPath: config.modelPath.trim() || null,
mediaPath,
@@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list'));
const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
client.requestProperty(name),
);
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
@@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
modelDirectory: deps.getModelDirectory(),
mediaPath,
audioStreamIndex,
references,
onProgress: report,
signal,
});
+6
View File
@@ -73,6 +73,8 @@ import type {
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -512,6 +514,10 @@ const electronAPI: ElectronAPI = {
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
getMediaTimingReviewFrame: (
request: MediaTimingReviewFrameRequest,
): Promise<MediaTimingReviewFrameResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewFrame, request),
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
stopMediaTimingReviewPreview: (reviewId: string): Promise<MediaTimingReviewActionResult> =>
+72 -1
View File
@@ -86,10 +86,30 @@
<button id="jimakuClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="jimaku-tabs" role="tablist">
<button
id="jimakuTabAnime"
class="jimaku-tab active"
type="button"
role="tab"
aria-selected="true"
>
Anime
</button>
<button
id="jimakuTabLiveAction"
class="jimaku-tab"
type="button"
role="tab"
aria-selected="false"
>
Live action
</button>
</div>
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="jimakuTitle" type="text" placeholder="Anime title" />
<input id="jimakuTitle" type="text" placeholder="Title" />
</label>
<label class="jimaku-field">
<span>Season</span>
@@ -281,6 +301,57 @@
</div>
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
<div id="mediaTimingReviewFramePicker" class="media-timing-frame-picker hidden">
<div class="media-timing-frame-image-shell">
<img
id="mediaTimingReviewFrameImage"
class="hidden"
alt="Selected card screenshot"
/>
</div>
<div class="media-timing-frame-controls">
<div class="media-timing-frame-heading">
<label for="mediaTimingReviewFrameSlider">Card screenshot</label>
<output id="mediaTimingReviewFrameTime" for="mediaTimingReviewFrameSlider"
>—</output
>
</div>
<input
id="mediaTimingReviewFrameSlider"
type="range"
min="0"
max="1"
step="0.001"
value="0"
aria-label="Screenshot time"
/>
<div class="media-timing-frame-buttons">
<button
id="mediaTimingReviewFramePrevious"
type="button"
aria-label="Previous video frame"
>
← Frame
</button>
<button
id="mediaTimingReviewFrameNext"
type="button"
aria-label="Next video frame"
>
Frame →
</button>
<button
id="mediaTimingReviewFrameReset"
type="button"
aria-label="Reset screenshot"
>
Reset
</button>
</div>
<p id="mediaTimingReviewFrameStatus" role="status"></p>
</div>
</div>
<div class="media-timing-review-readout" aria-live="polite">
<div>
<span>Starts</span>
+426
View File
@@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => {
classList: jimakuBroadenButtonClassList,
addEventListener: () => {},
},
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
@@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => {
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('switching to the Live action tab re-runs the search with the live action category', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const searchQueries: Array<{ query: string; category?: string }> = [];
const electronAPI = {
jimakuSearchEntries: async (query: { query: string; category?: string }) => {
searchQueries.push(query);
return { ok: true, data: [] };
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const animeTabClassList = createClassList(['active']);
const liveActionTabClassList = createClassList();
const status = { textContent: '', style: { color: '' } };
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '3' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: status,
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(state.jimakuActiveTab, 'liveAction');
assert.equal(liveActionTabClassList.contains('active'), true);
assert.equal(animeTabClassList.contains('active'), false);
assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]);
assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.');
// Same tab again is a no-op: no duplicate request.
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(searchQueries.length, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('a slow reply from a superseded search does not overwrite the newer results', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const pending: Array<(entries: unknown[]) => void> = [];
const electronAPI = {
jimakuSearchEntries: () =>
new Promise((resolve) => {
pending.push((entries) => resolve({ ok: true, data: entries }));
}),
jimakuListFiles: async () => ({ ok: true, data: [] }),
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
// Anime -> Live action -> Anime, all before any reply arrives.
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
jimakuModal.handleJimakuKeydown({
key: 'ArrowLeft',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(pending.length, 2);
// The stale live action reply lands after the newer anime search was issued.
pending[0]!([{ id: 1, name: 'Stale live action entry' }]);
await flushAsyncWork();
assert.equal(state.jimakuEntries.length, 0);
pending[1]!([
{ id: 2, name: 'Anime A' },
{ id: 3, name: 'Anime B' },
]);
await flushAsyncWork();
assert.deepEqual(
state.jimakuEntries.map((entry) => entry.id),
[2, 3],
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('closing the modal discards an in-flight search reply', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let resolveSearch!: (entries: unknown[]) => void;
let listFilesCalls = 0;
const electronAPI = {
jimakuSearchEntries: () =>
new Promise((resolve) => {
resolveSearch = (entries) => resolve({ ok: true, data: entries });
}),
jimakuListFiles: async () => {
listFilesCalls += 1;
return { ok: true, data: [] };
},
notifyOverlayModalClosed: () => {},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
await flushAsyncWork();
jimakuModal.closeJimakuModal();
// A single entry would normally auto-select and fetch its files.
resolveSearch([{ id: 7, name: 'Only entry' }]);
await flushAsyncWork();
assert.equal(state.jimakuEntries.length, 0);
assert.equal(state.currentEntryId, null);
assert.equal(listFilesCalls, 0);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('a slow files reply for a previously selected entry is ignored', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const pending = new Map<number, (files: unknown[]) => void>();
const electronAPI = {
jimakuListFiles: (query: { entryId: number }) =>
new Promise((resolve) => {
pending.set(query.entryId, (files) => resolve({ ok: true, data: files }));
}),
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
state.jimakuEntries = [
{ id: 1, name: 'Entry A' },
{ id: 2, name: 'Entry B' },
];
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: '' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList() },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
// Select entry A, then move to entry B before A's files arrive.
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
jimakuModal.handleJimakuKeydown({
key: 'ArrowDown',
preventDefault: () => {},
} as KeyboardEvent);
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
await flushAsyncWork();
assert.equal(state.currentEntryId, 2);
pending.get(1)!([
{ name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' },
]);
await flushAsyncWork();
assert.equal(state.jimakuFiles.length, 0);
pending.get(2)!([
{ name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' },
{ name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' },
]);
await flushAsyncWork();
assert.deepEqual(
state.jimakuFiles.map((file) => file.name),
['b1.srt', 'b2.srt'],
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('media info arriving after the modal closed does not fill inputs or search', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let resolveMediaInfo!: (info: unknown) => void;
let searchCalls = 0;
const electronAPI = {
getJimakuMediaInfo: () =>
new Promise((resolve) => {
resolveMediaInfo = resolve;
}),
jimakuSearchEntries: async () => {
searchCalls += 1;
return { ok: true, data: [] };
},
notifyOverlayModalClosed: () => {},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
const titleInput = { value: '' };
const status = { textContent: '', style: { color: '' } };
const ctx = {
dom: {
overlay: { classList: createClassList() },
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
jimakuTitleInput: titleInput,
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: status,
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.openJimakuModal();
await flushAsyncWork();
jimakuModal.closeJimakuModal();
resolveMediaInfo({
title: 'Shinzanmono',
season: 1,
episode: 3,
confidence: 'high',
filename: 'Shinzanmono S01E03.mkv',
rawTitle: 'Shinzanmono S01E03',
});
await flushAsyncWork();
assert.equal(titleInput.value, '');
assert.equal(searchCalls, 0);
assert.equal(status.textContent, 'Loading media info...');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
+69 -2
View File
@@ -4,6 +4,7 @@ import type {
JimakuEntry,
JimakuFileEntry,
JimakuMediaInfo,
JimakuSearchCategory,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
@@ -21,7 +22,12 @@ export function createJimakuModal(
: 'rgba(255, 255, 255, 0.8)';
}
// Bumped whenever the lists are reset (new search, tab switch, open, close)
// so any in-flight entries or files reply for the old state is discarded.
let searchGeneration = 0;
function resetJimakuLists(): void {
searchGeneration += 1;
ctx.state.jimakuEntries = [];
ctx.state.jimakuFiles = [];
ctx.state.selectedEntryIndex = 0;
@@ -35,6 +41,33 @@ export function createJimakuModal(
ctx.dom.jimakuBroadenButton.classList.add('hidden');
}
function renderTabs(): void {
const liveActionActive = ctx.state.jimakuActiveTab === 'liveAction';
const active = liveActionActive
? ctx.dom.jimakuTabLiveActionButton
: ctx.dom.jimakuTabAnimeButton;
const inactive = liveActionActive
? ctx.dom.jimakuTabAnimeButton
: ctx.dom.jimakuTabLiveActionButton;
active.classList.add('active');
active.setAttribute('aria-selected', 'true');
inactive.classList.remove('active');
inactive.setAttribute('aria-selected', 'false');
}
// Tabs map to Jimaku's anime / live-action catalogues, so switching re-runs
// the search server-side instead of filtering a shared result list.
function setActiveTab(tab: JimakuSearchCategory): void {
if (ctx.state.jimakuActiveTab === tab) return;
ctx.state.jimakuActiveTab = tab;
renderTabs();
if (getSearchQuery().query) {
void performJimakuSearch();
} else {
resetJimakuLists();
}
}
function formatEntryLabel(entry: JimakuEntry): string {
if (entry.english_name && entry.english_name !== entry.name) {
return `${entry.name} / ${entry.english_name}`;
@@ -133,9 +166,12 @@ export function createJimakuModal(
setJimakuStatus('Searching Jimaku...');
ctx.state.currentEpisodeFilter = episode;
const category = ctx.state.jimakuActiveTab;
const generation = searchGeneration;
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
{ query },
{ query, category },
);
if (generation !== searchGeneration) return;
if (!response.ok) {
const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
@@ -148,7 +184,11 @@ export function createJimakuModal(
ctx.state.selectedEntryIndex = 0;
if (ctx.state.jimakuEntries.length === 0) {
setJimakuStatus('No entries found.');
setJimakuStatus(
category === 'anime'
? 'No anime entries found. Try the Live action tab.'
: 'No live action entries found. Try the Anime tab.',
);
return;
}
@@ -167,12 +207,15 @@ export function createJimakuModal(
ctx.dom.jimakuFilesList.innerHTML = '';
ctx.dom.jimakuFilesSection.classList.add('hidden');
const generation = searchGeneration;
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
{
entryId,
episode,
},
);
// The user may have picked another entry or reset the modal meanwhile.
if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return;
if (!response.ok) {
const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
@@ -262,10 +305,15 @@ export function createJimakuModal(
setJimakuStatus('Loading media info...');
resetJimakuLists();
renderTabs();
// Media info can resolve after the user already closed the modal or
// started their own search; a stale reply must not touch the inputs.
const generation = searchGeneration;
window.electronAPI
.getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => {
if (generation !== searchGeneration) return;
ctx.dom.jimakuTitleInput.value = info.title || '';
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : '';
@@ -280,6 +328,7 @@ export function createJimakuModal(
}
})
.catch(() => {
if (generation !== searchGeneration) return;
setJimakuStatus('Failed to load media info.', true);
});
}
@@ -315,6 +364,18 @@ export function createJimakuModal(
return true;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('anime');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('liveAction');
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (ctx.state.jimakuFiles.length > 0) {
@@ -367,6 +428,12 @@ export function createJimakuModal(
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
closeJimakuModal();
});
ctx.dom.jimakuTabAnimeButton.addEventListener('click', () => {
setActiveTab('anime');
});
ctx.dom.jimakuTabLiveActionButton.addEventListener('click', () => {
setActiveTab('liveAction');
});
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
if (ctx.state.currentEntryId !== null) {
ctx.dom.jimakuBroadenButton.classList.add('hidden');
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setTimeout as tick } from 'node:timers/promises';
import { createMediaTimingFramePicker } from './media-timing-frame-picker';
import type { MediaTimingReviewFrameRequest, MediaTimingReviewFrameResult } from '../../types/anki';
function fixture() {
const requests: Array<{
request: MediaTimingReviewFrameRequest;
resolve: (result: MediaTimingReviewFrameResult) => void;
}> = [];
let stale = false;
const picker = createMediaTimingFramePicker({
debounceMs: 0,
load: (request) => new Promise((resolve) => requests.push({ request, resolve })),
onChange: () => {},
onStale: () => {
stale = true;
picker.close();
},
});
const complete = async (index: number, result: number | MediaTimingReviewFrameResult) => {
requests[index]!.resolve(
typeof result === 'number'
? { ok: true, timestamp: result, dataUrl: `data:image/jpeg;base64,${result}` }
: result,
);
await tick(0);
};
return { picker, requests, complete, isStale: () => stale };
}
test('a chosen frame stays fixed until Reset restores midpoint tracking', async () => {
const { picker, requests, complete } = fixture();
picker.open('r', true, 11);
await tick(5);
await complete(0, 11);
assert.equal(picker.getScreenshotTime(), undefined);
picker.choose(13);
await tick(5);
await complete(1, 13);
picker.updateMidpoint(12);
await tick(5);
assert.equal(picker.getScreenshotTime(), 13);
assert.equal(requests.length, 2);
picker.reset();
await tick(5);
assert.equal(requests[2]?.request.timestamp, 12);
await complete(2, 12);
assert.equal(picker.getScreenshotTime(), undefined);
picker.updateMidpoint(14);
await tick(5);
assert.equal(requests[3]?.request.timestamp, 14);
await complete(3, 14);
picker.close();
});
test('scrubbing keeps only the latest requested frame', async () => {
const { picker, requests, complete } = fixture();
picker.open('r', true, 11);
await tick(5);
picker.choose(12);
picker.choose(13);
picker.choose(14);
await tick(5);
assert.equal(requests.length, 1);
assert.equal(picker.getState().blockConfirm, true);
await complete(0, 11);
await tick(5);
assert.equal(picker.getState().timestamp, undefined);
assert.equal(requests[1]?.request.timestamp, 14);
await complete(1, 14);
assert.equal(picker.getScreenshotTime(), 14);
assert.equal(picker.getState().blockConfirm, false);
picker.close();
});
test('failed manual previews block confirmation; Reset allows the default fallback', async () => {
const { picker, complete } = fixture();
picker.open('r', true, 11);
await tick(5);
await complete(0, { ok: false });
assert.equal(picker.getState().blockConfirm, false);
picker.choose(12);
await tick(5);
await complete(1, { ok: false });
assert.equal(picker.getState().blockConfirm, true);
picker.reset();
assert.equal(picker.getState().blockConfirm, false);
picker.close();
});
test('replacing a review ignores old frames; stale responses close the current review', async () => {
const { picker, requests, complete, isStale } = fixture();
picker.open('old', true, 11);
await tick(5);
picker.close();
picker.open('new', true, 22);
await tick(5);
await complete(0, 11);
await tick(5);
assert.equal(picker.getState().timestamp, undefined);
assert.equal(requests[1]?.request.reviewId, 'new');
await complete(1, { ok: false, stale: true });
assert.equal(isStale(), true);
assert.equal(picker.getState().enabled, false);
});
test('disabled screenshots perform no extraction', async () => {
const { picker, requests } = fixture();
picker.open('r', false, 11);
picker.updateMidpoint(12);
await tick(5);
assert.equal(requests.length, 0);
picker.close();
});
@@ -0,0 +1,129 @@
import type { MediaTimingReviewFrameRequest, MediaTimingReviewFrameResult } from '../../types/anki';
export interface MediaTimingFramePickerState {
enabled: boolean;
manual: boolean;
loading: boolean;
timestamp?: number;
requestedTime?: number;
dataUrl?: string;
message: string;
blockConfirm: boolean;
}
/** Coalesces scrubbing into one active extraction and the latest requested frame. */
export function createMediaTimingFramePicker(options: {
load: (request: MediaTimingReviewFrameRequest) => Promise<MediaTimingReviewFrameResult>;
onChange: (state: MediaTimingFramePickerState) => void;
onStale: () => void;
debounceMs?: number;
}) {
let reviewId: string | null = null;
let midpoint = 0;
let sequence = 0;
let inFlight = false;
let pending: (MediaTimingReviewFrameRequest & { sequence: number }) | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let state: MediaTimingFramePickerState = emptyState();
function emptyState(): MediaTimingFramePickerState {
return { enabled: false, manual: false, loading: false, message: '', blockConfirm: false };
}
function publish(): void {
options.onChange({ ...state });
}
async function drain(): Promise<void> {
if (inFlight || !pending) return;
const request = pending;
pending = null;
inFlight = true;
try {
const result = await options.load(request);
if (reviewId !== request.reviewId || sequence !== request.sequence) return;
if (result.stale) {
options.onStale();
return;
}
if (
!result.ok ||
!Number.isFinite(result.timestamp) ||
!result.dataUrl?.startsWith('data:image/jpeg;base64,')
) {
throw new Error(result.message ?? 'Screenshot preview is unavailable.');
}
state.timestamp = result.timestamp;
state.dataUrl = result.dataUrl;
state.blockConfirm = false;
state.message = state.manual
? 'Selected frame stays fixed when you trim audio.'
: 'Following the audio midpoint.';
} catch (error) {
if (reviewId !== request.reviewId || sequence !== request.sequence) return;
state.message = error instanceof Error ? error.message : 'Screenshot preview is unavailable.';
state.blockConfirm = state.manual;
} finally {
inFlight = false;
if (reviewId === request.reviewId && sequence === request.sequence) {
state.loading = false;
publish();
}
if (timer === null) void drain();
}
}
function request(timestamp: number, direction?: -1 | 1): void {
if (!reviewId || !state.enabled) return;
sequence += 1;
pending = { reviewId, timestamp, ...(direction ? { direction } : {}), sequence };
state.requestedTime = timestamp;
state.loading = true;
state.blockConfirm = state.manual;
state.message = 'Loading screenshot…';
publish();
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void drain();
}, options.debounceMs ?? 120);
}
function close(): void {
sequence += 1;
reviewId = null;
pending = null;
if (timer !== null) clearTimeout(timer);
timer = null;
state = emptyState();
publish();
}
return {
open(id: string, enabled: boolean, time: number) {
close();
reviewId = id;
midpoint = time;
state.enabled = enabled;
publish();
if (enabled) request(time);
},
updateMidpoint(time: number) {
if (midpoint === time) return;
midpoint = time;
if (!state.manual) request(time);
},
choose(time: number, direction?: -1 | 1) {
if (!Number.isFinite(time) || time < 0) return;
state.manual = true;
request(time, direction);
},
reset() {
state.manual = false;
request(midpoint);
},
close,
getState: () => ({ ...state }),
getScreenshotTime: () => (state.manual && !state.blockConfirm ? state.timestamp : undefined),
};
}
+74 -1
View File
@@ -1,3 +1,4 @@
import { createMediaTimingFramePicker } from './media-timing-frame-picker';
import type {
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
@@ -252,6 +253,45 @@ export function createMediaTimingReviewModal(
isModalLayer: ctx.platform.isModalLayer,
});
const previewRequest = createMediaTimingPreviewRequestGuard();
const framePicker = createMediaTimingFramePicker({
load: (request) => window.electronAPI.getMediaTimingReviewFrame(request),
onChange: () => renderFramePicker(),
onStale: () => closeResolvedReview(),
});
function renderFramePicker(): void {
const state = framePicker.getState();
const dom = ctx.dom;
dom.mediaTimingReviewFramePicker.classList.toggle('hidden', !state.enabled);
dom.mediaTimingReviewFramePicker.setAttribute('aria-busy', String(state.loading));
dom.mediaTimingReviewFrameSlider.min = String(timelineStart);
dom.mediaTimingReviewFrameSlider.max = String(Math.max(timelineStart, timelineEnd - 0.001));
const shownTime = state.loading ? state.requestedTime : state.timestamp;
if (shownTime !== undefined) dom.mediaTimingReviewFrameSlider.value = String(shownTime);
dom.mediaTimingReviewFrameTime.textContent =
shownTime === undefined ? '—' : formatMediaTimingTimestamp(shownTime);
dom.mediaTimingReviewFrameSlider.setAttribute(
'aria-valuetext',
dom.mediaTimingReviewFrameTime.textContent,
);
dom.mediaTimingReviewFrameStatus.textContent = state.message;
dom.mediaTimingReviewFrameImage.classList.toggle('hidden', !state.dataUrl);
if (state.dataUrl) dom.mediaTimingReviewFrameImage.src = state.dataUrl;
else dom.mediaTimingReviewFrameImage.removeAttribute('src');
dom.mediaTimingReviewFramePrevious.disabled =
resolveInFlight ||
state.loading ||
state.timestamp === undefined ||
state.timestamp <= timelineStart;
dom.mediaTimingReviewFrameNext.disabled =
resolveInFlight ||
state.loading ||
state.timestamp === undefined ||
state.timestamp >= timelineEnd - 0.001;
dom.mediaTimingReviewFrameSlider.disabled = resolveInFlight;
dom.mediaTimingReviewFrameReset.disabled = resolveInFlight || !state.manual;
dom.mediaTimingReviewConfirm.disabled = resolveInFlight || state.blockConfirm;
}
function setStatus(message: string, isError = false): void {
ctx.dom.mediaTimingReviewStatus.textContent = message;
@@ -308,6 +348,8 @@ export function createMediaTimingReviewModal(
}
function renderSelection(): void {
framePicker.updateMidpoint(selectionStart + (selectionEnd - selectionStart) / 2);
renderFramePicker();
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
const startPercent = ((selectionStart - timelineStart) / span) * 100;
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
@@ -690,6 +732,7 @@ export function createMediaTimingReviewModal(
window.electronAPI.notifyOverlayModalClosed('media-timing-review');
options.syncSettingsModalSubtitleSuppression();
payload = null;
framePicker.close();
if (!options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
@@ -701,6 +744,7 @@ export function createMediaTimingReviewModal(
async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
if (!payload || resolveInFlight) return;
resolveInFlight = true;
renderFramePicker();
stopPreview();
const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
controls.forEach((button) => {
@@ -733,12 +777,14 @@ export function createMediaTimingReviewModal(
}
function confirmSelection(): void {
if (!payload) return;
if (!payload || framePicker.getState().blockConfirm) return;
const screenshotTime = framePicker.getScreenshotTime();
const includesAdjacentLines = previousCount > 0 || nextCount > 0;
void resolveReview({
action: 'confirm',
startTime: selectionStart,
endTime: selectionEnd,
...(screenshotTime !== undefined ? { screenshotTime } : {}),
...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}),
});
}
@@ -818,6 +864,11 @@ export function createMediaTimingReviewModal(
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
setStatus('');
showEditor();
framePicker.open(
nextPayload.reviewId,
nextPayload.screenshotEnabled === true,
selectionStart + (selectionEnd - selectionStart) / 2,
);
renderSelection();
renderSentence();
ctx.state.mediaTimingReviewModalOpen = true;
@@ -892,6 +943,28 @@ export function createMediaTimingReviewModal(
}
function wireDomEvents(): void {
ctx.dom.mediaTimingReviewFrameSlider.addEventListener('input', () => {
if (!resolveInFlight) framePicker.choose(Number(ctx.dom.mediaTimingReviewFrameSlider.value));
});
const stepFrame = (direction: -1 | 1) => {
const state = framePicker.getState();
if (!resolveInFlight && !state.loading && state.timestamp !== undefined)
framePicker.choose(state.timestamp, direction);
};
ctx.dom.mediaTimingReviewFrameSlider.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
event.preventDefault();
stepFrame(-1);
} else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
event.preventDefault();
stepFrame(1);
}
});
ctx.dom.mediaTimingReviewFramePrevious.addEventListener('click', () => stepFrame(-1));
ctx.dom.mediaTimingReviewFrameNext.addEventListener('click', () => stepFrame(1));
ctx.dom.mediaTimingReviewFrameReset.addEventListener('click', () => {
if (!resolveInFlight) framePicker.reset();
});
const track = ctx.dom.mediaTimingReviewSelectionTrack;
track.addEventListener('pointerdown', beginDrag);
track.addEventListener('pointermove', (event) => {
@@ -3,10 +3,25 @@ import test from 'node:test';
import {
describeGenerationModel,
describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools,
describeGenerationVad,
} from './subtitle-generation-view';
test('only confirmed NVIDIA CUDA support recommends turbo', () => {
assert.deepEqual(describeGenerationRecommendation({ kind: 'unavailable' }), {
model: 'small',
text: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
});
assert.deepEqual(
describeGenerationRecommendation({ kind: 'nvidia-cuda', gpuName: 'NVIDIA RTX 5070 Ti' }),
{
model: 'large-v3-turbo',
text: 'NVIDIA CUDA is available with NVIDIA RTX 5070 Ti. large-v3-turbo is recommended.',
},
);
});
test('missing tools block generation and list every install instruction', () => {
const found = { kind: 'found', path: '/usr/bin/tool' } as const;
assert.deepEqual(
@@ -1,11 +1,23 @@
import {
missingSubtitleGenerationTools,
recommendedSubtitleGenerationModel,
type SubtitleGenerationAcceleration,
type SubtitleGenerationModelStatus,
type SubtitleGenerationProgress,
type SubtitleGenerationTools,
} from '../../shared/subtitle-generation';
import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc';
export function describeGenerationRecommendation(acceleration: SubtitleGenerationAcceleration) {
return {
model: recommendedSubtitleGenerationModel(acceleration),
text:
acceleration.kind === 'nvidia-cuda'
? `NVIDIA CUDA is available with ${acceleration.gpuName}. large-v3-turbo is recommended.`
: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
};
}
export function describeGenerationTools(tools: SubtitleGenerationTools) {
const missing = missingSubtitleGenerationTools(tools);
if (missing.length > 0) return { ready: false, text: missing.join(' ') };
+10 -6
View File
@@ -1,7 +1,6 @@
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import {
SUBTITLE_GENERATION_MODELS,
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
formatSubtitleGenerationModelSize,
getSubtitleGenerationModel,
isSubtitleGenerationModelId,
@@ -16,6 +15,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
import {
describeGenerationModel,
describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools,
describeGenerationVad,
} from './subtitle-generation-view';
@@ -70,8 +70,7 @@ export function createSubtitleGenerationModal(
for (const model of SUBTITLE_GENERATION_MODELS) {
const option = document.createElement('option');
option.value = model.id;
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : '';
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
option.textContent = `${model.id} · ${formatSubtitleGenerationModelSize(model.size)}`;
dom.modelSelect.append(option);
}
@@ -103,10 +102,15 @@ export function createSubtitleGenerationModal(
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath);
if (snapshot) {
const recommendation = describeGenerationRecommendation(snapshot.acceleration);
for (const option of dom.modelSelect.options) {
if (!isSubtitleGenerationModelId(option.value)) continue;
const model = getSubtitleGenerationModel(option.value);
const recommended = model.id === recommendation.model ? ' (recommended)' : '';
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
}
dom.modelSelect.value = snapshot.managedModel;
dom.modelDescription.textContent = getSubtitleGenerationModel(
snapshot.managedModel,
).description;
dom.modelDescription.textContent = `${getSubtitleGenerationModel(snapshot.managedModel).description} ${recommendation.text}`;
}
dom.download.classList.toggle('hidden', !model?.download);
dom.download.textContent = snapshot
+3
View File
@@ -7,6 +7,7 @@ import type {
TsukihimeEntry,
TsukihimeSubtitleFile,
JimakuEntry,
JimakuSearchCategory,
JimakuFileEntry,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
@@ -43,6 +44,7 @@ export type RendererState = {
persistedSubtitlePosition: SubtitlePosition;
jimakuModalOpen: boolean;
jimakuActiveTab: JimakuSearchCategory;
jimakuEntries: JimakuEntry[];
jimakuFiles: JimakuFileEntry[];
selectedEntryIndex: number;
@@ -177,6 +179,7 @@ export function createRendererState(): RendererState {
persistedSubtitlePosition: { yPercent: 10 },
jimakuModalOpen: false,
jimakuActiveTab: 'anime',
jimakuEntries: [],
jimakuFiles: [],
selectedEntryIndex: 0,
+87 -2
View File
@@ -819,12 +819,14 @@ body:focus-visible,
grid-template-columns: 1fr 120px auto;
}
.jimaku-tabs,
.tsukihime-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.jimaku-tab,
.tsukihime-tab {
min-width: 0;
min-height: 34px;
@@ -842,6 +844,8 @@ body:focus-visible,
text-overflow: ellipsis;
}
.jimaku-tab:hover,
.jimaku-tab:focus-visible,
.tsukihime-tab:hover,
.tsukihime-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48);
@@ -849,6 +853,7 @@ body:focus-visible,
outline: none;
}
.jimaku-tab.active,
.tsukihime-tab.active {
border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16);
@@ -1397,7 +1402,7 @@ body:focus-visible,
.media-timing-review-content {
width: min(720px, calc(100vw - 32px));
max-height: min(700px, calc(100vh - 32px));
max-height: min(900px, calc(100vh - 32px));
overflow: auto;
gap: 0;
padding: 0;
@@ -1475,6 +1480,86 @@ body:focus-visible,
padding: 14px 20px 18px;
}
.media-timing-frame-picker {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: center;
margin: 0 0 16px;
padding: 12px;
border: 1px solid var(--ctp-surface1);
border-radius: 10px;
background: var(--ctp-mantle);
}
.media-timing-frame-picker.hidden {
display: none;
}
.media-timing-frame-image-shell {
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 6px;
background: var(--ctp-crust);
}
.media-timing-frame-image-shell img {
width: 100%;
height: 100%;
object-fit: contain;
}
.media-timing-frame-picker[aria-busy='true'] img {
opacity: 0.55;
}
.media-timing-frame-heading {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.media-timing-frame-heading output {
color: var(--ctp-teal);
font-variant-numeric: tabular-nums;
}
.media-timing-frame-controls input {
width: 100%;
margin: 12px 0;
accent-color: var(--ctp-teal);
}
.media-timing-frame-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.media-timing-frame-buttons button {
padding: 5px 8px;
border: 1px solid var(--ctp-surface2);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font-size: 11px;
cursor: pointer;
}
.media-timing-frame-buttons button:disabled {
opacity: 0.45;
cursor: default;
}
.media-timing-frame-controls p {
margin: 8px 0 0;
min-height: 2.6em;
color: var(--ctp-subtext0);
font-size: 11px;
}
@media (max-width: 520px) {
.media-timing-frame-picker {
grid-template-columns: 1fr;
}
.media-timing-frame-image-shell {
max-height: 180px;
}
}
.media-timing-review-sentence-header {
display: flex;
align-items: center;
@@ -3032,7 +3117,7 @@ iframe[id^='yomitan-popup'],
}
.subtitle-generation-content {
width: min(580px, 92%);
width: min(860px, 92%);
max-height: 92%;
border-top: 3px solid var(--ctp-green);
}
+30
View File
@@ -21,6 +21,8 @@ export type RendererDom = {
jimakuFilesSection: HTMLDivElement;
jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement;
jimakuTabAnimeButton: HTMLButtonElement;
jimakuTabLiveActionButton: HTMLButtonElement;
tsukihimeModal: HTMLDivElement;
tsukihimeTitleInput: HTMLInputElement;
@@ -45,6 +47,14 @@ export type RendererDom = {
youtubePickerStatus: HTMLDivElement;
youtubePickerTracks: HTMLUListElement;
mediaTimingReviewFramePicker: HTMLDivElement;
mediaTimingReviewFrameImage: HTMLImageElement;
mediaTimingReviewFrameSlider: HTMLInputElement;
mediaTimingReviewFrameTime: HTMLElement;
mediaTimingReviewFrameStatus: HTMLElement;
mediaTimingReviewFramePrevious: HTMLButtonElement;
mediaTimingReviewFrameNext: HTMLButtonElement;
mediaTimingReviewFrameReset: HTMLButtonElement;
mediaTimingReviewModal: HTMLDivElement;
mediaTimingReviewKind: HTMLDivElement;
mediaTimingReviewText: HTMLElement;
@@ -223,6 +233,8 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesSection: getRequiredElement<HTMLDivElement>('jimakuFilesSection'),
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
jimakuTabAnimeButton: getRequiredElement<HTMLButtonElement>('jimakuTabAnime'),
jimakuTabLiveActionButton: getRequiredElement<HTMLButtonElement>('jimakuTabLiveAction'),
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
@@ -251,6 +263,24 @@ export function resolveRendererDom(): RendererDom {
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
mediaTimingReviewFramePicker: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewFramePicker',
),
mediaTimingReviewFrameImage: getRequiredElement<HTMLImageElement>(
'mediaTimingReviewFrameImage',
),
mediaTimingReviewFrameSlider: getRequiredElement<HTMLInputElement>(
'mediaTimingReviewFrameSlider',
),
mediaTimingReviewFrameTime: getRequiredElement<HTMLElement>('mediaTimingReviewFrameTime'),
mediaTimingReviewFrameStatus: getRequiredElement<HTMLElement>('mediaTimingReviewFrameStatus'),
mediaTimingReviewFramePrevious: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewFramePrevious',
),
mediaTimingReviewFrameNext: getRequiredElement<HTMLButtonElement>('mediaTimingReviewFrameNext'),
mediaTimingReviewFrameReset: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewFrameReset',
),
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
+1
View File
@@ -166,6 +166,7 @@ export const IPC_CHANNELS = {
animeBrowserRemoveRepo: 'anime-browser:remove-repo',
mediaTimingReviewPreview: 'media-timing-review:preview',
mediaTimingReviewWaveform: 'media-timing-review:waveform',
mediaTimingReviewFrame: 'media-timing-review:frame',
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
mediaTimingReviewResolve: 'media-timing-review:resolve',
},
+8 -1
View File
@@ -394,7 +394,14 @@ export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRe
export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null {
if (!isObject(value) || typeof value.query !== 'string') return null;
return { query: value.query };
if (
value.category !== undefined &&
value.category !== 'anime' &&
value.category !== 'liveAction'
) {
return null;
}
return { query: value.query, category: value.category };
}
export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null {
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeMediaTitle, toMediaIdentityPath } from './media-identity';
test('media identity separates authenticated transport URLs from titles and stats keys', () => {
const url =
'https://user:password@jellyfin.example/Videos/item-1/stream?api_key=test-secret#token';
assert.equal(sanitizeMediaTitle(url), null);
assert.equal(sanitizeMediaTitle('stream?static=true&api_key=test-secret'), null);
assert.equal(sanitizeMediaTitle('stream static true api key test secret'), null);
assert.equal(sanitizeMediaTitle('stream%3Fapi_key%3Dtest-secret'), null);
assert.equal(sanitizeMediaTitle(' My Anime S01E02 '), 'My Anime S01E02');
assert.equal(toMediaIdentityPath(url), 'jellyfin://jellyfin.example/item/item-1');
assert.equal(
toMediaIdentityPath('https://example.com/base/Videos/item-1/master.m3u8?token=secret'),
'jellyfin://example.com/item/item-1',
);
assert.equal(toMediaIdentityPath('stream?api_key=test-secret'), '');
assert.equal(toMediaIdentityPath('/media/My Anime S01E02.mkv'), '/media/My Anime S01E02.mkv');
});
test('remote stats identities drop credentials while preserving YouTube video identity', () => {
assert.match(
toMediaIdentityPath('https://user:password@example.com/video.mkv?signature=secret#secret'),
/^https:\/\/example\.com\/video\.mkv#query-[a-f0-9]{64}$/,
);
assert.notEqual(
toMediaIdentityPath('https://example.com/video?id=1'),
toMediaIdentityPath('https://example.com/video?id=2'),
);
const identity = toMediaIdentityPath('https://example.com/video?id=1');
assert.equal(toMediaIdentityPath(identity), identity);
assert.equal(
toMediaIdentityPath('https://www.youtube.com/watch?v=video-id&token=secret'),
'https://www.youtube.com/watch?v=video-id',
);
});
+58
View File
@@ -0,0 +1,58 @@
import { createHash } from 'node:crypto';
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//i;
const QUERY_PAIR = /[?&][^=\s&#]+=/;
const CREDENTIAL_LABEL = /\b(?:api[_ -]?key|access[_ -]?token|x[_ -]?emby[_ -]?token)\b/i;
/** mpv can report the URL or its query-bearing basename before metadata arrives. */
export function sanitizeMediaTitle(value: string | null | undefined): string | null {
const title = value?.trim();
if (!title) return null;
let decoded = title;
try {
decoded = decodeURIComponent(title);
} catch {
// Ordinary titles can contain a literal percent sign.
}
if (URL_SCHEME.test(decoded) || QUERY_PAIR.test(decoded) || CREDENTIAL_LABEL.test(decoded)) {
return null;
}
return title;
}
export function resolveMediaLookupTarget(
mediaPath: string | null,
mediaTitle: string | null,
): string | null {
return sanitizeMediaTitle(mediaPath) ?? sanitizeMediaTitle(mediaTitle);
}
/** Persistent identity, never a URL to use for authenticated media retrieval. */
export function toMediaIdentityPath(mediaPath: string): string {
const value = mediaPath.trim();
if (!URL_SCHEME.test(value)) return sanitizeMediaTitle(value) ?? '';
try {
const url = new URL(value);
const jellyfinItem = url.pathname.match(
/\/Videos\/([^/]+)\/(?:stream(?:\.[^/]*)?|master\.m3u8)\/?$/i,
);
if (jellyfinItem) return `jellyfin://${url.host}/item/${jellyfinItem[1]}`;
url.username = '';
url.password = '';
const query = url.search;
const queryFingerprint = /^#query-[a-f0-9]{64}$/.test(url.hash) ? url.hash : '';
const youtubeId = /^(?:www\.|m\.)?youtube\.com$/i.test(url.hostname)
? url.searchParams.get('v')
: null;
url.search = '';
url.hash = queryFingerprint;
if (youtubeId) url.searchParams.set('v', youtubeId);
else if (query) {
// Different query-selected videos must not collapse into one stats entry.
url.hash = `query-${createHash('sha256').update(query).digest('hex')}`;
}
return url.toString();
} catch {
return '';
}
}
+3
View File
@@ -0,0 +1,3 @@
export const MEDIA_KINDS = ['anime', 'youtube'] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
+2
View File
@@ -1,4 +1,5 @@
import type {
SubtitleGenerationAcceleration,
SubtitleGenerationConfig,
SubtitleGenerationModelStatus,
SubtitleGenerationProgress,
@@ -13,6 +14,7 @@ export interface SubtitleGenerationStatus {
model: SubtitleGenerationModelStatus;
vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
tools: SubtitleGenerationTools;
acceleration: SubtitleGenerationAcceleration;
managedModel: SubtitleGenerationConfig['managedModel'];
externalModelPath: string | null;
mediaPath: string | null;
@@ -45,7 +45,7 @@ const MODEL_CATALOG = {
id: 'small',
size: 487601967,
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
description: 'Recommended starting point for accuracy and processing time.',
description: 'Balances accuracy and processing time with modest memory requirements.',
},
'small-q5_1': {
id: 'small-q5_1',
+10
View File
@@ -41,6 +41,16 @@ export type SubtitleGenerationToolStatus =
| { kind: 'found'; path: string }
| { kind: 'missing'; message: string };
export type SubtitleGenerationAcceleration =
| { kind: 'nvidia-cuda'; gpuName: string }
| { kind: 'unavailable' };
export function recommendedSubtitleGenerationModel(acceleration: SubtitleGenerationAcceleration) {
return acceleration.kind === 'nvidia-cuda'
? 'large-v3-turbo'
: RECOMMENDED_SUBTITLE_GENERATION_MODEL;
}
/** Executables generation depends on. `vad` is null unless dialogue mode is on. */
export interface SubtitleGenerationTools {
ffmpeg: SubtitleGenerationToolStatus;
+22 -1
View File
@@ -21,6 +21,8 @@ export interface MediaTimingReviewRequest {
noteId?: number;
audioPadding: number;
maxMediaDuration: number;
/** Still screenshots only; animated images continue to follow the audio range. */
screenshotEnabled?: boolean;
}
/** A subtitle line adjacent to the mined one that the review can pull onto the card. */
@@ -32,7 +34,13 @@ export interface MediaTimingReviewContextLine {
export type MediaTimingReviewDecision =
/** `text` is set when the review combined adjacent lines into the card sentence. */
| { action: 'confirm'; startTime: number; endTime: number; text?: string }
| {
action: 'confirm';
startTime: number;
endTime: number;
text?: string;
screenshotTime?: number;
}
| { action: 'use-original' }
| { action: 'skip-media' }
| { action: 'discard' };
@@ -53,6 +61,19 @@ export interface MediaTimingReviewOpenPayload {
timelineEndTime: number;
mediaDuration?: number;
maxMediaDuration: number;
screenshotEnabled?: boolean;
}
export interface MediaTimingReviewFrameRequest {
reviewId: string;
timestamp: number;
/** Step to the adjacent decoded frame instead of seeking to a time. */
direction?: -1 | 1;
}
export interface MediaTimingReviewFrameResult extends MediaTimingReviewActionResult {
dataUrl?: string;
timestamp?: number;
}
export interface MediaTimingReviewPreviewRequest {
+4
View File
@@ -224,8 +224,12 @@ export interface JimakuMediaInfo {
rawTitle: string;
}
export type JimakuSearchCategory = 'anime' | 'liveAction';
export interface JimakuSearchQuery {
query: string;
// Which Jimaku catalogue to search; defaults to anime when omitted.
category?: JimakuSearchCategory;
}
export interface JimakuEntryFlags {
+5
View File
@@ -8,6 +8,8 @@ import type {
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
MediaTimingReviewFrameResult,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from './anki';
@@ -553,6 +555,9 @@ export interface ElectronAPI {
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewFrame: (
request: MediaTimingReviewFrameRequest,
) => Promise<MediaTimingReviewFrameResult>;
getMediaTimingReviewWaveform: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
+3
View File
@@ -1,3 +1,4 @@
import type { MediaKind } from '../shared/media-kind';
export interface SessionSummary {
sessionId: number;
canonicalTitle: string | null;
@@ -240,6 +241,7 @@ export const EventType = {
export type EventType = (typeof EventType)[keyof typeof EventType];
export interface AnimeLibraryItem {
mediaKind: MediaKind;
animeId: number;
canonicalTitle: string;
anilistId: number | null;
@@ -261,6 +263,7 @@ export interface AnilistEntry {
export interface AnimeDetailData {
detail: {
mediaKind: MediaKind;
animeId: number;
canonicalTitle: string;
anilistId: number | null;
+2
View File
@@ -246,6 +246,8 @@ export interface SubtitleMiningContext {
capturedAtMs?: number;
/** Explicit generator padding. Confirmed timing-review ranges set this to zero. */
mediaPaddingSeconds?: number;
/** Independent still screenshot selected during media timing review. */
screenshotTime?: number;
}
export interface SubtitleHoverTokenPayload {