mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) || '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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(' ') };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MEDIA_KINDS = ['anime', 'youtube'] as const;
|
||||
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user