feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createAnilistTokenStore, type SafeStorageLike } from './anilist-token-store';
function createTempTokenFile(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-token-'));
return path.join(dir, 'token.json');
}
function createLogger() {
return {
info: (_message: string) => {},
warn: (_message: string) => {},
error: (_message: string) => {},
};
}
function createStorage(encryptionAvailable: boolean): SafeStorageLike {
return {
isEncryptionAvailable: () => encryptionAvailable,
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
decryptString: (value: Buffer) => {
const raw = value.toString('utf-8');
return raw.startsWith('enc:') ? raw.slice(4) : raw;
},
};
}
test('anilist token store saves and loads encrypted token', () => {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
store.saveToken(' demo-token ');
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, 'string');
assert.equal(payload.plaintextToken, undefined);
assert.equal(store.loadToken(), 'demo-token');
});
test('anilist token store falls back to plaintext when encryption unavailable', () => {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
store.saveToken('plain-token');
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
plaintextToken?: string;
};
assert.equal(payload.plaintextToken, 'plain-token');
assert.equal(store.loadToken(), 'plain-token');
});
test('anilist token store migrates legacy plaintext to encrypted', () => {
const filePath = createTempTokenFile();
fs.writeFileSync(
filePath,
JSON.stringify({ plaintextToken: 'legacy-token', updatedAt: Date.now() }),
'utf-8',
);
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
assert.equal(store.loadToken(), 'legacy-token');
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, 'string');
assert.equal(payload.plaintextToken, undefined);
});
test('anilist token store clears persisted token file', () => {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
store.saveToken('to-clear');
assert.equal(fs.existsSync(filePath), true);
store.clearToken();
assert.equal(fs.existsSync(filePath), false);
});

View File

@@ -0,0 +1,108 @@
import * as fs from 'fs';
import * as path from 'path';
import * as electron from 'electron';
interface PersistedTokenPayload {
encryptedToken?: string;
plaintextToken?: string;
updatedAt?: number;
}
export interface AnilistTokenStore {
loadToken: () => string | null;
saveToken: (token: string) => void;
clearToken: () => void;
}
export interface SafeStorageLike {
isEncryptionAvailable: () => boolean;
encryptString: (value: string) => Buffer;
decryptString: (value: Buffer) => string;
}
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedTokenPayload): void {
ensureDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
}
export function createAnilistTokenStore(
filePath: string,
logger: {
info: (message: string) => void;
warn: (message: string, details?: unknown) => void;
error: (message: string, details?: unknown) => void;
},
storage: SafeStorageLike = electron.safeStorage,
): AnilistTokenStore {
return {
loadToken(): string | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw) as PersistedTokenPayload;
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
if (!storage.isEncryptionAvailable()) {
logger.warn('AniList token encryption is not available on this system.');
return null;
}
const decrypted = storage.decryptString(encrypted).trim();
return decrypted.length > 0 ? decrypted : null;
}
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
// Legacy fallback: migrate plaintext token to encrypted storage on load.
const plaintext = parsed.plaintextToken.trim();
this.saveToken(plaintext);
return plaintext;
}
} catch (error) {
logger.error('Failed to read AniList token store.', error);
}
return null;
},
saveToken(token: string): void {
const trimmed = token.trim();
if (trimmed.length === 0) {
this.clearToken();
return;
}
try {
if (!storage.isEncryptionAvailable()) {
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
writePayload(filePath, {
plaintextToken: trimmed,
updatedAt: Date.now(),
});
return;
}
const encrypted = storage.encryptString(trimmed);
writePayload(filePath, {
encryptedToken: encrypted.toString('base64'),
updatedAt: Date.now(),
});
} catch (error) {
logger.error('Failed to persist AniList token.', error);
}
},
clearToken(): void {
if (!fs.existsSync(filePath)) return;
try {
fs.unlinkSync(filePath);
logger.info('Cleared stored AniList token.');
} catch (error) {
logger.error('Failed to clear stored AniList token.', error);
}
},
};
}

View File

@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createAnilistUpdateQueue } from './anilist-update-queue';
function createTempQueueFile(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-queue-'));
return path.join(dir, 'queue.json');
}
function createLogger() {
const info: string[] = [];
const warn: string[] = [];
const error: string[] = [];
return {
info,
warn,
error,
logger: {
info: (message: string) => info.push(message),
warn: (message: string) => warn.push(message),
error: (message: string) => error.push(message),
},
};
}
test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1);
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 0,
ready: 0,
deadLetter: 0,
});
assert.ok(loggerState.info.some((message) => message.includes('Queued AniList retry')));
});
test('anilist update queue applies retry backoff and dead-letter', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
const now = 1_700_000_000_000;
queue.enqueue('k2', 'Backoff Demo', 2);
queue.markFailure('k2', 'fail-1', now);
const firstRetry = queue.nextReady(now);
assert.equal(firstRetry, null);
const pendingPayload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
pending: Array<{ attemptCount: number; nextAttemptAt: number }>;
};
assert.equal(pendingPayload.pending[0]?.attemptCount, 1);
assert.equal(pendingPayload.pending[0]?.nextAttemptAt, now + 30_000);
for (let attempt = 2; attempt <= 8; attempt += 1) {
queue.markFailure('k2', `fail-${attempt}`, now);
}
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 0, ready: 0, deadLetter: 1 });
assert.ok(
loggerState.warn.some((message) =>
message.includes('AniList retry moved to dead-letter queue.'),
),
);
});
test('anilist update queue persists and reloads from disk', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queueA = createAnilistUpdateQueue(queueFile, loggerState.logger);
queueA.enqueue('k3', 'Persist Demo', 3);
const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 1,
ready: 1,
deadLetter: 0,
});
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
});

View File

@@ -0,0 +1,193 @@
import * as fs from 'fs';
import * as path from 'path';
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
const MAX_ATTEMPTS = 8;
const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
episode: number;
createdAt: number;
attemptCount: number;
nextAttemptAt: number;
lastError: string | null;
}
interface AnilistRetryQueuePayload {
pending?: AnilistQueuedUpdate[];
deadLetter?: AnilistQueuedUpdate[];
}
export interface AnilistRetryQueueSnapshot {
pending: number;
ready: number;
deadLetter: number;
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
}
function ensureDir(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed);
}
export function createAnilistUpdateQueue(
filePath: string,
logger: {
info: (message: string) => void;
warn: (message: string, details?: unknown) => void;
error: (message: string, details?: unknown) => void;
},
): AnilistUpdateQueue {
let pending: AnilistQueuedUpdate[] = [];
let deadLetter: AnilistQueuedUpdate[] = [];
const persist = () => {
try {
ensureDir(filePath);
const payload: AnilistRetryQueuePayload = { pending, deadLetter };
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to persist AniList retry queue.', error);
}
};
const load = () => {
if (!fs.existsSync(filePath)) {
return;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw) as AnilistRetryQueuePayload;
const parsedPending = Array.isArray(parsed.pending) ? parsed.pending : [];
const parsedDeadLetter = Array.isArray(parsed.deadLetter) ? parsed.deadLetter : [];
pending = parsedPending
.filter(
(item): item is AnilistQueuedUpdate =>
item &&
typeof item.key === 'string' &&
typeof item.title === 'string' &&
typeof item.episode === 'number' &&
item.episode > 0 &&
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
deadLetter = parsedDeadLetter
.filter(
(item): item is AnilistQueuedUpdate =>
item &&
typeof item.key === 'string' &&
typeof item.title === 'string' &&
typeof item.episode === 'number' &&
item.episode > 0 &&
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
} catch (error) {
logger.error('Failed to load AniList retry queue.', error);
}
};
load();
return {
enqueue(key: string, title: string, episode: number): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
}
if (pending.length >= MAX_ITEMS) {
pending.shift();
}
pending.push({
key,
title,
episode,
createdAt: Date.now(),
attemptCount: 0,
nextAttemptAt: Date.now(),
lastError: null,
});
persist();
logger.info(`Queued AniList retry for "${title}" episode ${episode}.`);
},
nextReady(nowMs: number = Date.now()): AnilistQueuedUpdate | null {
const ready = pending.find((item) => item.nextAttemptAt <= nowMs);
return ready ?? null;
},
markSuccess(key: string): void {
const before = pending.length;
pending = pending.filter((item) => item.key !== key);
if (pending.length !== before) {
persist();
}
},
markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
const item = pending.find((candidate) => candidate.key === key);
if (!item) {
return;
}
item.attemptCount += 1;
item.lastError = reason;
if (item.attemptCount >= MAX_ATTEMPTS) {
pending = pending.filter((candidate) => candidate.key !== key);
if (deadLetter.length >= MAX_ITEMS) {
deadLetter.shift();
}
deadLetter.push({
...item,
nextAttemptAt: nowMs,
});
logger.warn('AniList retry moved to dead-letter queue.', {
key,
reason,
attempts: item.attemptCount,
});
persist();
return;
}
item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount);
persist();
logger.warn('AniList retry scheduled with backoff.', {
key,
attemptCount: item.attemptCount,
nextAttemptAt: item.nextAttemptAt,
reason,
});
},
getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot {
const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length;
return {
pending: pending.length,
ready,
deadLetter: deadLetter.length,
};
},
};
}

View File

@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as childProcess from 'child_process';
import { guessAnilistMediaInfo, updateAnilistPostWatchProgress } from './anilist-updater';
function createJsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
test('guessAnilistMediaInfo uses guessit output when available', async () => {
const originalExecFile = childProcess.execFile;
(
childProcess as unknown as {
execFile: typeof childProcess.execFile;
}
).execFile = ((...args: unknown[]) => {
const callback = args[args.length - 1];
const cb =
typeof callback === 'function'
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
: null;
cb?.(null, JSON.stringify({ title: 'Guessit Title', episode: 7 }), '');
return {} as childProcess.ChildProcess;
}) as typeof childProcess.execFile;
try {
const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null);
assert.deepEqual(result, {
title: 'Guessit Title',
episode: 7,
source: 'guessit',
});
} finally {
(
childProcess as unknown as {
execFile: typeof childProcess.execFile;
}
).execFile = originalExecFile;
}
});
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
const originalExecFile = childProcess.execFile;
(
childProcess as unknown as {
execFile: typeof childProcess.execFile;
}
).execFile = ((...args: unknown[]) => {
const callback = args[args.length - 1];
const cb =
typeof callback === 'function'
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
: null;
cb?.(new Error('guessit not found'), '', '');
return {} as childProcess.ChildProcess;
}) as typeof childProcess.execFile;
try {
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null);
assert.deepEqual(result, {
title: 'My Anime',
episode: 3,
source: 'fallback',
});
} finally {
(
childProcess as unknown as {
execFile: typeof childProcess.execFile;
}
).execFile = originalExecFile;
}
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [
{
id: 11,
episodes: 24,
title: { english: 'Demo Show', romaji: 'Demo Show' },
},
],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: {
id: 11,
mediaListEntry: { progress: 2, status: 'CURRENT' },
},
},
});
}
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
assert.equal(result.status, 'updated');
assert.match(result.message, /episode 3/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 22, episodes: 12, title: { english: 'Skip Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 22, mediaListEntry: { progress: 12, status: 'CURRENT' } },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Skip Show', 10);
assert.equal(result.status, 'skipped');
assert.match(result.message, /already at episode/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
createJsonResponse({
errors: [{ message: 'bad request' }],
})) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Bad', 1);
assert.equal(result.status, 'error');
assert.match(result.message, /search failed/i);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,299 @@
import * as childProcess from 'child_process';
import { parseMediaInfo } from '../../../jimaku/utils';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export interface AnilistMediaGuess {
title: string;
episode: number | null;
source: 'guessit' | 'fallback';
}
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
}
interface AnilistGraphQlError {
message?: string;
}
interface AnilistGraphQlResponse<T> {
data?: T;
errors?: AnilistGraphQlError[];
}
interface AnilistSearchData {
Page?: {
media?: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>;
};
}
interface AnilistMediaEntryData {
Media?: {
id: number;
mediaListEntry?: {
progress?: number | null;
status?: string | null;
} | null;
} | null;
}
interface AnilistSaveEntryData {
SaveMediaListEntry?: {
progress?: number | null;
status?: string | null;
};
}
function runGuessit(target: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.execFile(
'guessit',
[target, '--json'],
{ timeout: 5000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
function firstString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const candidate = firstString(item);
if (candidate) return candidate;
}
}
return null;
}
function firstPositiveInteger(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const candidate = firstPositiveInteger(item);
if (candidate !== null) return candidate;
}
}
return null;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
): Promise<AnilistGraphQlResponse<T>> {
try {
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
return payload;
} catch (error) {
return {
errors: [
{
message: error instanceof Error ? error.message : String(error),
},
],
};
}
}
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | null {
const firstError = response.errors?.find((item) => Boolean(item?.message));
return firstError?.message ?? null;
}
function pickBestSearchResult(
title: string,
episode: number,
media: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): { id: number; title: string } | null {
const filtered = media.filter((item) => {
const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) return null;
const normalizedTarget = normalizeTitle(title);
const exact = candidates.find((item) => {
const titles = [item.title?.romaji, item.title?.english, item.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTarget);
});
const selected = exact ?? candidates[0]!;
const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle };
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
): Promise<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
if (target && target.trim().length > 0) {
try {
const stdout = await runGuessit(target);
const parsed = JSON.parse(stdout) as Record<string, unknown>;
const title = firstString(parsed.title);
const episode = firstPositiveInteger(parsed.episode);
if (title) {
return { title, episode, source: 'guessit' };
}
} catch {
// Ignore guessit failures and fall back to internal parser.
}
}
const fallbackTarget = mediaPath ?? mediaTitle;
const parsed = parseMediaInfo(fallbackTarget);
if (!parsed.title.trim()) {
return null;
}
return {
title: parsed.title.trim(),
episode: parsed.episode,
source: 'fallback',
};
}
export async function updateAnilistPostWatchProgress(
accessToken: string,
title: string,
episode: number,
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{ search: title },
);
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
return {
status: 'error',
message: `AniList search failed: ${searchError}`,
};
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
accessToken,
`
query ($mediaId: Int!) {
Media(id: $mediaId, type: ANIME) {
id
mediaListEntry {
progress
status
}
}
}
`,
{ mediaId: picked.id },
);
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
return {
status: 'error',
message: `AniList entry lookup failed: ${entryError}`,
};
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
};
}
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
accessToken,
`
mutation ($mediaId: Int!, $progress: Int!) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) {
progress
status
}
}
`,
{ mediaId: picked.id, progress: episode },
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
return { status: 'error', message: `AniList update failed: ${saveError}` };
}
return {
status: 'updated',
message: `AniList updated "${picked.title}" to episode ${episode}.`,
};
}

View File

@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerAnkiJimakuIpcHandlers } from './anki-jimaku-ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
function createFakeRegistrar(): {
registrar: {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
};
onHandlers: Map<string, (event: unknown, ...args: unknown[]) => void>;
handleHandlers: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
} {
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>();
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
registrar: {
on: (channel, listener) => {
onHandlers.set(channel, listener);
},
handle: (channel, listener) => {
handleHandlers.set(channel, listener);
},
},
onHandlers,
handleHandlers,
};
}
test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
const { registrar, handleHandlers } = createFakeRegistrar();
let previewCalls = 0;
registerAnkiJimakuIpcHandlers(
{
setAnkiConnectEnabled: () => {},
clearAnkiHistory: () => {},
refreshKnownWords: async () => {},
respondFieldGrouping: () => {},
buildKikuMergePreview: async () => {
previewCalls += 1;
return { ok: true };
},
getJimakuMediaInfo: () => ({
title: 'x',
season: null,
episode: null,
confidence: 'high',
filename: 'x.mkv',
rawTitle: 'x',
}),
searchJimakuEntries: async () => ({ ok: true, data: [] }),
listJimakuFiles: async () => ({ ok: true, data: [] }),
resolveJimakuApiKey: async () => 'token',
getCurrentMediaPath: () => '/tmp/a.mkv',
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
},
registrar,
);
const previewHandler = handleHandlers.get(IPC_CHANNELS.request.kikuBuildMergePreview);
assert.ok(previewHandler);
const invalidPreviewResult = await previewHandler!({}, null);
assert.deepEqual(invalidPreviewResult, {
ok: false,
error: 'Invalid merge preview request payload',
});
await previewHandler!({}, { keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false });
assert.equal(previewCalls, 1);
const searchHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuSearchEntries);
assert.ok(searchHandler);
const invalidSearchResult = await searchHandler!({}, { query: 12 });
assert.deepEqual(invalidSearchResult, {
ok: false,
error: { error: 'Invalid Jimaku search query payload', code: 400 },
});
const filesHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuListFiles);
assert.ok(filesHandler);
const invalidFilesResult = await filesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidFilesResult, {
ok: false,
error: { error: 'Invalid Jimaku files query payload', code: 400 },
});
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuDownloadFile);
assert.ok(downloadHandler);
const invalidDownloadResult = await downloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidDownloadResult, {
ok: false,
error: { error: 'Invalid Jimaku download query payload', code: 400 },
});
});
test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
const { registrar, onHandlers } = createFakeRegistrar();
const fieldGroupingChoices: unknown[] = [];
const enabledStates: boolean[] = [];
registerAnkiJimakuIpcHandlers(
{
setAnkiConnectEnabled: (enabled) => {
enabledStates.push(enabled);
},
clearAnkiHistory: () => {},
refreshKnownWords: async () => {},
respondFieldGrouping: (choice) => {
fieldGroupingChoices.push(choice);
},
buildKikuMergePreview: async () => ({ ok: true }),
getJimakuMediaInfo: () => ({
title: 'x',
season: null,
episode: null,
confidence: 'high',
filename: 'x.mkv',
rawTitle: 'x',
}),
searchJimakuEntries: async () => ({ ok: true, data: [] }),
listJimakuFiles: async () => ({ ok: true, data: [] }),
resolveJimakuApiKey: async () => 'token',
getCurrentMediaPath: () => '/tmp/a.mkv',
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
},
registrar,
);
onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, 'true');
onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, true);
assert.deepEqual(enabledStates, [true]);
onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!({}, null);
onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!(
{},
{
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
},
);
assert.deepEqual(fieldGroupingChoices, [
{
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
},
]);
});

View File

@@ -0,0 +1,185 @@
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '../../logger';
import {
JimakuApiResponse,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
} from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseJimakuDownloadQuery,
parseJimakuFilesQuery,
parseJimakuSearchQuery,
parseKikuFieldGroupingChoice,
parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators';
const logger = createLogger('main:anki-jimaku-ipc');
export interface AnkiJimakuIpcDeps {
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiHistory: () => void;
refreshKnownWords: () => Promise<void> | void;
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
getJimakuMediaInfo: () => JimakuMediaInfo;
searchJimakuEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listJimakuFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
resolveJimakuApiKey: () => Promise<string | null>;
getCurrentMediaPath: () => string | null;
isRemoteMediaPath: (mediaPath: string) => boolean;
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
) => Promise<JimakuDownloadResult>;
onDownloadedSubtitle: (pathToSubtitle: string) => void;
}
interface IpcMainRegistrar {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
}
export function registerAnkiJimakuIpcHandlers(
deps: AnkiJimakuIpcDeps,
ipc: IpcMainRegistrar = ipcMain,
): void {
ipc.on(IPC_CHANNELS.command.setAnkiConnectEnabled, (_event: unknown, enabled: unknown) => {
if (typeof enabled !== 'boolean') return;
deps.setAnkiConnectEnabled(enabled);
});
ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => {
deps.clearAnkiHistory();
});
ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => {
await deps.refreshKnownWords();
});
ipc.on(IPC_CHANNELS.command.kikuFieldGroupingRespond, (_event: unknown, choice: unknown) => {
const parsedChoice = parseKikuFieldGroupingChoice(choice);
if (!parsedChoice) return;
deps.respondFieldGrouping(parsedChoice);
});
ipc.handle(
IPC_CHANNELS.request.kikuBuildMergePreview,
async (_event, request: unknown): Promise<KikuMergePreviewResponse> => {
const parsedRequest = parseKikuMergePreviewRequest(request);
if (!parsedRequest) {
return { ok: false, error: 'Invalid merge preview request payload' };
}
return deps.buildKikuMergePreview(parsedRequest);
},
);
ipc.handle(IPC_CHANNELS.request.jimakuGetMediaInfo, (): JimakuMediaInfo => {
return deps.getJimakuMediaInfo();
});
ipc.handle(
IPC_CHANNELS.request.jimakuSearchEntries,
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuEntry[]>> => {
const parsedQuery = parseJimakuSearchQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Jimaku search query payload', code: 400 } };
}
return deps.searchJimakuEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.jimakuListFiles,
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
const parsedQuery = parseJimakuFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Jimaku files query payload', code: 400 } };
}
return deps.listJimakuFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.jimakuDownloadFile,
async (_event, query: unknown): Promise<JimakuDownloadResult> => {
const parsedQuery = parseJimakuDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: {
error: 'Invalid Jimaku download query payload',
code: 400,
},
};
}
const apiKey = await deps.resolveJimakuApiKey();
if (!apiKey) {
return {
ok: false,
error: {
error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.',
code: 401,
},
};
}
const currentMediaPath = deps.getCurrentMediaPath();
if (!currentMediaPath) {
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name);
if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
}
const ext = path.extname(safeName);
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
let targetPath = path.join(mediaDir, safeName);
if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (jimaku-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[jimaku] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadToFile(parsedQuery.url, targetPath, {
Authorization: apiKey,
'User-Agent': 'SubMiner',
});
if (result.ok) {
logger.info(`[jimaku] download-file saved to ${result.path}`);
deps.onDownloadedSubtitle(result.path);
} else {
logger.error(`[jimaku] download-file failed: ${result.error?.error ?? 'unknown error'}`);
}
return result;
},
);
}

View File

@@ -0,0 +1,255 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnkiJimakuIpcRuntimeOptions, registerAnkiJimakuIpcRuntime } from './anki-jimaku';
interface RuntimeHarness {
options: AnkiJimakuIpcRuntimeOptions;
registered: Record<string, (...args: unknown[]) => unknown>;
state: {
ankiIntegration: unknown;
fieldGroupingResolver: ((choice: unknown) => void) | null;
patches: boolean[];
broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: string[] }>;
};
}
function createHarness(): RuntimeHarness {
const state = {
ankiIntegration: null as unknown,
fieldGroupingResolver: null as ((choice: unknown) => void) | null,
patches: [] as boolean[],
broadcasts: 0,
fetchCalls: [] as Array<{
endpoint: string;
query?: Record<string, unknown>;
}>,
sentCommands: [] as Array<{ command: string[] }>,
};
const options: AnkiJimakuIpcRuntimeOptions = {
patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled);
},
getResolvedConfig: () => ({}),
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => ({
connected: true,
send: (payload) => {
state.sentCommands.push(payload);
},
}),
getAnkiIntegration: () => state.ankiIntegration as never,
setAnkiIntegration: (integration) => {
state.ankiIntegration = integration;
},
getKnownWordCacheStatePath: () => '/tmp/subminer-known-words-cache.json',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
broadcastRuntimeOptionsChanged: () => {
state.broadcasts += 1;
},
getFieldGroupingResolver: () => state.fieldGroupingResolver as never,
setFieldGroupingResolver: (resolver) => {
state.fieldGroupingResolver = resolver as never;
},
parseMediaInfo: () => ({
title: 'video',
confidence: 'high',
rawTitle: 'video',
filename: 'video.mkv',
season: null,
episode: null,
}),
getCurrentMediaPath: () => '/tmp/video.mkv',
jimakuFetchJson: async (endpoint, query) => {
state.fetchCalls.push({
endpoint,
query: query as Record<string, unknown>,
});
return {
ok: true,
data: [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
] as never,
};
},
getJimakuMaxEntryResults: () => 2,
getJimakuLanguagePreference: () => 'ja',
resolveJimakuApiKey: async () => 'token',
isRemoteMediaPath: () => false,
downloadToFile: async (url, destPath) => ({
ok: true,
path: `${destPath}:${url}`,
}),
};
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntime(options, (deps) => {
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
});
return { options, registered, state };
}
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
const { registered } = createHarness();
const expected = [
'setAnkiConnectEnabled',
'clearAnkiHistory',
'refreshKnownWords',
'respondFieldGrouping',
'buildKikuMergePreview',
'getJimakuMediaInfo',
'searchJimakuEntries',
'listJimakuFiles',
'resolveJimakuApiKey',
'getCurrentMediaPath',
'isRemoteMediaPath',
'downloadToFile',
'onDownloadedSubtitle',
];
for (const key of expected) {
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
}
});
test('refreshKnownWords throws when integration is unavailable', async () => {
const { registered } = createHarness();
await assert.rejects(
async () => {
await registered.refreshKnownWords!();
},
{ message: 'AnkiConnect integration not enabled' },
);
});
test('refreshKnownWords delegates to integration', async () => {
const { registered, state } = createHarness();
let refreshed = 0;
state.ankiIntegration = {
refreshKnownWordCache: async () => {
refreshed += 1;
},
};
await registered.refreshKnownWords!();
assert.equal(refreshed, 1);
});
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
const { registered, state } = createHarness();
let destroyed = 0;
state.ankiIntegration = {
destroy: () => {
destroyed += 1;
},
};
registered.setAnkiConnectEnabled!(false);
assert.deepEqual(state.patches, [false]);
assert.equal(destroyed, 1);
assert.equal(state.ankiIntegration, null);
assert.equal(state.broadcasts, 1);
});
test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () => {
const { registered, state, options } = createHarness();
let cleaned = 0;
let resolvedChoice: unknown = null;
state.fieldGroupingResolver = (choice) => {
resolvedChoice = choice;
};
const originalGetTracker = options.getSubtitleTimingTracker;
options.getSubtitleTimingTracker = () =>
({
cleanup: () => {
cleaned += 1;
},
}) as never;
const choice = {
keepNoteId: 10,
deleteNoteId: 11,
deleteDuplicate: true,
cancelled: false,
};
registered.clearAnkiHistory!();
registered.respondFieldGrouping!(choice);
options.getSubtitleTimingTracker = originalGetTracker;
assert.equal(cleaned, 1);
assert.deepEqual(resolvedChoice, choice);
assert.equal(state.fieldGroupingResolver, null);
});
test('buildKikuMergePreview returns guard error when integration is missing', async () => {
const { registered } = createHarness();
const result = await registered.buildKikuMergePreview!({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
});
assert.deepEqual(result, {
ok: false,
error: 'AnkiConnect integration not enabled',
});
});
test('buildKikuMergePreview delegates to integration when available', async () => {
const { registered, state } = createHarness();
const calls: unknown[] = [];
state.ankiIntegration = {
buildFieldGroupingPreview: async (
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
) => {
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
return { ok: true };
},
};
const result = await registered.buildKikuMergePreview!({
keepNoteId: 3,
deleteNoteId: 4,
deleteDuplicate: true,
});
assert.deepEqual(calls, [[3, 4, true]]);
assert.deepEqual(result, { ok: true });
});
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchJimakuEntries!({ query: 'test' });
assert.deepEqual(state.fetchCalls, [
{
endpoint: '/api/entries/search',
query: { anime: true, query: 'test' },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
});

View File

@@ -0,0 +1,185 @@
import { AnkiIntegration } from '../../anki-integration';
import {
AnkiConnectConfig,
JimakuApiResponse,
JimakuEntry,
JimakuFileEntry,
JimakuLanguagePreference,
JimakuMediaInfo,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger';
export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => void;
interface MpvClientLike {
connected: boolean;
send: (payload: { command: string[] }) => void;
}
interface RuntimeOptionsManagerLike {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
}
interface SubtitleTimingTrackerLike {
cleanup: () => void;
}
export interface AnkiJimakuIpcRuntimeOptions {
patchAnkiConnectEnabled: (enabled: boolean) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
broadcastRuntimeOptionsChanged: () => void;
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
getCurrentMediaPath: () => string | null;
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>;
isRemoteMediaPath: (mediaPath: string) => boolean;
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
) => Promise<
| { ok: true; path: string }
| {
ok: false;
error: { error: string; code?: number; retryAfter?: number };
}
>;
}
const logger = createLogger('main:anki-jimaku');
export function registerAnkiJimakuIpcRuntime(
options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
): void {
registerHandlers({
setAnkiConnectEnabled: (enabled) => {
options.patchAnkiConnectEnabled(enabled);
const config = options.getResolvedConfig();
const subtitleTimingTracker = options.getSubtitleTimingTracker();
const mpvClient = options.getMpvClient();
const ankiIntegration = options.getAnkiIntegration();
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
const runtimeOptionsManager = options.getRuntimeOptionsManager();
const effectiveAnkiConfig = runtimeOptionsManager
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect)
: config.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig as never,
subtitleTimingTracker as never,
mpvClient as never,
(text: string) => {
if (mpvClient) {
mpvClient.send({
command: ['show-text', text, '3000'],
});
}
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(),
);
integration.start();
options.setAnkiIntegration(integration);
logger.info('AnkiConnect integration enabled');
} else if (!enabled && ankiIntegration) {
ankiIntegration.destroy();
options.setAnkiIntegration(null);
logger.info('AnkiConnect integration disabled');
}
options.broadcastRuntimeOptionsChanged();
},
clearAnkiHistory: () => {
const subtitleTimingTracker = options.getSubtitleTimingTracker();
if (subtitleTimingTracker) {
subtitleTimingTracker.cleanup();
logger.info('AnkiConnect subtitle timing history cleared');
}
},
refreshKnownWords: async () => {
const integration = options.getAnkiIntegration();
if (!integration) {
throw new Error('AnkiConnect integration not enabled');
}
await integration.refreshKnownWordCache();
},
respondFieldGrouping: (choice) => {
const resolver = options.getFieldGroupingResolver();
if (resolver) {
resolver(choice);
options.setFieldGroupingResolver(null);
}
},
buildKikuMergePreview: async (request) => {
const integration = options.getAnkiIntegration();
if (!integration) {
return { ok: false, error: 'AnkiConnect integration not enabled' };
}
return integration.buildFieldGroupingPreview(
request.keepNoteId,
request.deleteNoteId,
request.deleteDuplicate,
);
},
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => {
logger.info(`[jimaku] search-entries query: "${query.query}"`);
const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', {
anime: true,
query: query.query,
});
if (!response.ok) return response;
const maxResults = options.getJimakuMaxEntryResults();
logger.info(
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
);
return { ok: true, data: response.data.slice(0, maxResults) };
},
listJimakuFiles: async (query) => {
logger.info(`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? 'all'}`);
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
`/api/entries/${query.entryId}/files`,
{
episode: query.episode ?? undefined,
},
);
if (!response.ok) return response;
const sorted = sortJimakuFiles(response.data, options.getJimakuLanguagePreference());
logger.info(`[jimaku] list-files returned ${sorted.length} files`);
return { ok: true, data: sorted };
},
resolveJimakuApiKey: () => options.resolveJimakuApiKey(),
getCurrentMediaPath: () => options.getCurrentMediaPath(),
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
onDownloadedSubtitle: (pathToSubtitle) => {
const mpvClient = options.getMpvClient();
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
}
},
});
}

View File

@@ -0,0 +1,139 @@
import { CliArgs, CliCommandSource } from '../../cli/args';
import { createLogger } from '../../logger';
const logger = createLogger('main:app-lifecycle');
export interface AppLifecycleServiceDeps {
shouldStartApp: (args: CliArgs) => boolean;
parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean;
quitApp: () => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
interface AppLike {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
}
export interface AppLifecycleDepsRuntimeOptions {
app: AppLike;
platform: NodeJS.Platform;
shouldStartApp: (args: CliArgs) => boolean;
parseArgs: (argv: string[]) => CliArgs;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
export function createAppLifecycleDepsRuntime(
options: AppLifecycleDepsRuntimeOptions,
): AppLifecycleServiceDeps {
return {
shouldStartApp: options.shouldStartApp,
parseArgs: options.parseArgs,
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
onSecondInstance: (handler) => {
options.app.on('second-instance', handler as (...args: unknown[]) => void);
},
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
whenReady: (handler) => {
options.app
.whenReady()
.then(handler)
.catch((error) => {
logger.error('App ready handler failed:', error);
});
},
onWindowAllClosed: (handler) => {
options.app.on('window-all-closed', handler as (...args: unknown[]) => void);
},
onWillQuit: (handler) => {
options.app.on('will-quit', handler as (...args: unknown[]) => void);
},
onActivate: (handler) => {
options.app.on('activate', handler as (...args: unknown[]) => void);
},
isDarwinPlatform: () => options.platform === 'darwin',
onReady: options.onReady,
onWillQuitCleanup: options.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: options.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: options.shouldQuitOnWindowAllClosed,
};
}
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
const gotTheLock = deps.requestSingleInstanceLock();
if (!gotTheLock) {
deps.quitApp();
return;
}
deps.onSecondInstance((_event, argv) => {
try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
});
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
deps.printHelp();
deps.quitApp();
return;
}
if (!deps.shouldStartApp(initialArgs)) {
if (initialArgs.stop && !initialArgs.start) {
deps.quitApp();
} else {
deps.logNoRunningInstance();
deps.quitApp();
}
return;
}
deps.whenReady(async () => {
await deps.onReady();
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
deps.onWillQuitCleanup();
});
deps.onActivate(() => {
if (deps.shouldRestoreWindowsOnActivate()) {
deps.restoreWindowsOnActivate();
}
});
}

View File

@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
const calls: string[] = [];
const deps: AppReadyRuntimeDeps = {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
}),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push('logConfigWarning'),
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 9001,
hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`),
log: (message) => calls.push(`log:${message}`),
createMecabTokenizerAndCheck: async () => {
calls.push('createMecabTokenizerAndCheck');
},
createSubtitleTimingTracker: () => calls.push('createSubtitleTimingTracker'),
createImmersionTracker: () => calls.push('createImmersionTracker'),
startJellyfinRemoteSession: async () => {
calls.push('startJellyfinRemoteSession');
},
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarmSubtitleDictionaries');
},
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
logDebug: (message) => calls.push(`debug:${message}`),
now: () => 1000,
...overrides,
};
return { deps, calls };
}
test('runAppReadyRuntime starts websocket in auto mode when plugin missing', async () => {
const { deps, calls } = makeDeps({
hasMpvWebsocketPlugin: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined,
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(
calls.includes('initializeOverlayRuntime') ||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
);
});
test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
const { deps, calls } = makeDeps({
createImmersionTracker: undefined,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.'));
});
test('runAppReadyRuntime logs and continues when createImmersionTracker throws', async () => {
const { deps, calls } = makeDeps({
createImmersionTracker: () => {
calls.push('createImmersionTracker');
throw new Error('immersion init failed');
},
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(
calls.includes('log:Runtime ready: createImmersionTracker failed: immersion init failed'),
);
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('handleInitialArgs'));
});
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
});
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
logging: { level: 'warn' },
}),
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('setLogLevel:warn:config'));
});
test('runAppReadyRuntime does not await background warmups', async () => {
const calls: string[] = [];
let releaseWarmup: (() => void) | undefined;
const warmupGate = new Promise<void>((resolve) => {
releaseWarmup = resolve;
});
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
void warmupGate.then(() => {
calls.push('warmupDone');
});
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
});
await runAppReadyRuntime(deps);
assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
assert.equal(calls.includes('warmupDone'), false);
assert.ok(releaseWarmup);
releaseWarmup();
});
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
const capturedErrors: string[][] = [];
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
ankiConnect: {
enabled: true,
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: ' ',
miscInfo: 'MiscInfo',
translation: '',
},
},
}),
onCriticalConfigErrors: (errors) => {
capturedErrors.push(errors);
},
});
await runAppReadyRuntime(deps);
assert.equal(capturedErrors.length, 1);
assert.deepEqual(capturedErrors[0], [
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
]);
assert.ok(calls.includes('reloadConfig'));
assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('startBackgroundWarmups'), false);
});
test('runAppReadyRuntime aggregates multiple critical anki mapping errors', async () => {
const capturedErrors: string[][] = [];
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
ankiConnect: {
enabled: true,
fields: {
audio: ' ',
image: '',
sentence: '\t',
miscInfo: ' ',
translation: '',
},
},
}),
onCriticalConfigErrors: (errors) => {
capturedErrors.push(errors);
},
});
await runAppReadyRuntime(deps);
const firstErrorSet = capturedErrors[0]!;
assert.equal(capturedErrors.length, 1);
assert.equal(firstErrorSet.length, 5);
assert.ok(
firstErrorSet.includes(
'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
firstErrorSet.includes(
'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
firstErrorSet.includes(
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
firstErrorSet.includes(
'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
firstErrorSet.includes(
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
),
);
assert.equal(calls.includes('loadSubtitlePosition'), false);
});

View File

@@ -0,0 +1,469 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
refreshKnownWords: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
debug: false,
...overrides,
};
}
function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
const calls: string[] = [];
let mpvSocketPath = '/tmp/subminer.sock';
let texthookerPort = 5174;
const osd: string[] = [];
const deps: CliCommandServiceDeps = {
getMpvSocketPath: () => mpvSocketPath,
setMpvSocketPath: (socketPath) => {
mpvSocketPath = socketPath;
calls.push(`setMpvSocketPath:${socketPath}`);
},
setMpvClientSocketPath: (socketPath) => {
calls.push(`setMpvClientSocketPath:${socketPath}`);
},
hasMpvClient: () => true,
connectMpvClient: () => {
calls.push('connectMpvClient');
},
isTexthookerRunning: () => false,
setTexthookerPort: (port) => {
texthookerPort = port;
calls.push(`setTexthookerPort:${port}`);
},
getTexthookerPort: () => texthookerPort,
shouldOpenTexthookerBrowser: () => true,
ensureTexthookerRunning: (port) => {
calls.push(`ensureTexthookerRunning:${port}`);
},
openTexthookerInBrowser: (url) => {
calls.push(`openTexthookerInBrowser:${url}`);
},
stopApp: () => {
calls.push('stopApp');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('initializeOverlayRuntime');
},
toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay');
},
toggleInvisibleOverlay: () => {
calls.push('toggleInvisibleOverlay');
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`setInvisibleOverlayVisible:${visible}`);
},
copyCurrentSubtitle: () => {
calls.push('copyCurrentSubtitle');
},
startPendingMultiCopy: (timeoutMs) => {
calls.push(`startPendingMultiCopy:${timeoutMs}`);
},
mineSentenceCard: async () => {
calls.push('mineSentenceCard');
},
startPendingMineSentenceMultiple: (timeoutMs) => {
calls.push(`startPendingMineSentenceMultiple:${timeoutMs}`);
},
updateLastCardFromClipboard: async () => {
calls.push('updateLastCardFromClipboard');
},
refreshKnownWords: async () => {
calls.push('refreshKnownWords');
},
cycleSecondarySubMode: () => {
calls.push('cycleSecondarySubMode');
},
triggerFieldGrouping: async () => {
calls.push('triggerFieldGrouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('triggerSubsyncFromConfig');
},
markLastCardAsAudioCard: async () => {
calls.push('markLastCardAsAudioCard');
},
openRuntimeOptionsPalette: () => {
calls.push('openRuntimeOptionsPalette');
},
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'stored',
tokenMessage: null,
tokenResolvedAt: 1,
tokenErrorAt: null,
queuePending: 2,
queueReady: 1,
queueDeadLetter: 0,
queueLastAttemptAt: 2,
queueLastError: null,
}),
clearAnilistToken: () => {
calls.push('clearAnilistToken');
},
openAnilistSetup: () => {
calls.push('openAnilistSetup');
},
openJellyfinSetup: () => {
calls.push('openJellyfinSetup');
},
getAnilistQueueStatus: () => ({
pending: 2,
ready: 1,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
retryAnilistQueue: async () => {
calls.push('retryAnilistQueue');
return { ok: true, message: 'AniList retry processed.' };
},
runJellyfinCommand: async () => {
calls.push('runJellyfinCommand');
},
printHelp: () => {
calls.push('printHelp');
},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 2500,
showMpvOsd: (text) => {
osd.push(text);
},
log: (message) => {
calls.push(`log:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
error: (message) => {
calls.push(`error:${message}`);
},
...overrides,
};
return { deps, calls, osd };
}
test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
});
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.ok(calls.includes('log:Ignoring --start because SubMiner is already running.'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
false,
);
});
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.equal(
calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'),
false,
);
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
true,
);
});
test('handleCliCommand runs texthooker flow with browser open', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ texthooker: true });
handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});
test('handleCliCommand reports async mine errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
mineSentenceCard: async () => {
throw new Error('boom');
},
});
handleCliCommand(makeArgs({ mineSentence: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:mineSentenceCard failed:')));
assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
});
test('handleCliCommand applies socket path and connects on start', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('connectMpvClient'));
});
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
});
handleCliCommand(makeArgs({ texthookerPort: 9999, texthooker: true }), 'initial', deps);
assert.ok(
calls.includes(
'warn:Ignoring --port override because the texthooker server is already running.',
),
);
assert.equal(
calls.some((value) => value === 'setTexthookerPort:9999'),
false,
);
});
test('handleCliCommand prints help and stops app when no window exists', () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(makeArgs({ help: true }), 'initial', deps);
assert.ok(calls.includes('printHelp'));
assert.ok(calls.includes('stopApp'));
});
test('handleCliCommand reports async trigger-subsync errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
triggerSubsyncFromConfig: async () => {
throw new Error('subsync boom');
},
});
handleCliCommand(makeArgs({ triggerSubsync: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:triggerSubsyncFromConfig failed:')));
assert.ok(osd.some((value) => value.includes('Subsync failed: subsync boom')));
});
test('handleCliCommand stops app for --stop command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ stop: true }), 'initial', deps);
assert.ok(calls.includes('log:Stopping SubMiner...'));
assert.ok(calls.includes('stopApp'));
});
test('handleCliCommand still runs non-start actions on second-instance', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ start: true, toggleVisibleOverlay: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
true,
);
});
test('handleCliCommand connects MPV for toggle on second-instance', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
true,
);
});
test('handleCliCommand handles visibility and utility command dispatches', () => {
const cases: Array<{
args: Partial<CliArgs>;
expected: string;
}> = [
{
args: { toggleInvisibleOverlay: true },
expected: 'toggleInvisibleOverlay',
},
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
},
{
args: { hideVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:false',
},
{
args: { showInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:true',
},
{
args: { hideInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:false',
},
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
{
args: { copySubtitleMultiple: true },
expected: 'startPendingMultiCopy:2500',
},
{
args: { mineSentenceMultiple: true },
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
},
{ args: { anilistLogout: true }, expected: 'clearAnilistToken' },
{ args: { anilistSetup: true }, expected: 'openAnilistSetup' },
{ args: { jellyfin: true }, expected: 'openJellyfinSetup' },
];
for (const entry of cases) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(entry.args), 'initial', deps);
assert.ok(
calls.includes(entry.expected),
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
);
}
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
});
test('handleCliCommand runs AniList retry command', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('retryAnilistQueue'));
assert.ok(calls.includes('log:AniList retry processed.'));
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },
{ copySubtitle: true },
{ toggleVisibleOverlay: true },
];
for (const args of nonJellyfinArgs) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(args), 'initial', deps);
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
assert.equal(
runJellyfinCallCount,
0,
`Unexpected Jellyfin dispatch for args ${JSON.stringify(args)}`,
);
}
});
test('handleCliCommand runs jellyfin command dispatcher', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
assert.equal(runJellyfinCallCount, 2);
});
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
runJellyfinCommand: async () => {
throw new Error('server offline');
},
});
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:runJellyfinCommand failed:')));
assert.ok(osd.some((value) => value.includes('Jellyfin command failed: server offline')));
});
test('handleCliCommand runs refresh-known-words command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
assert.ok(calls.includes('refreshKnownWords'));
});
test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
refreshKnownWords: async () => {
throw new Error('refresh boom');
},
});
handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith('error:refreshKnownWords failed:')));
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
});

View File

@@ -0,0 +1,458 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
export interface CliCommandServiceDeps {
getMpvSocketPath: () => string;
setMpvSocketPath: (socketPath: string) => void;
setMpvClientSocketPath: (socketPath: string) => void;
hasMpvClient: () => boolean;
connectMpvClient: () => void;
isTexthookerRunning: () => boolean;
setTexthookerPort: (port: number) => void;
getTexthookerPort: () => number;
shouldOpenTexthookerBrowser: () => boolean;
ensureTexthookerRunning: (port: number) => void;
openTexthookerInBrowser: (url: string) => void;
stopApp: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWords: () => Promise<void>;
cycleSecondarySubMode: () => void;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
getAnilistStatus: () => {
tokenStatus: 'not_checked' | 'resolved' | 'error';
tokenSource: 'none' | 'literal' | 'stored';
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
queuePending: number;
queueReady: number;
queueDeadLetter: number;
queueLastAttemptAt: number | null;
queueLastError: string | null;
};
clearAnilistToken: () => void;
openAnilistSetup: () => void;
openJellyfinSetup: () => void;
getAnilistQueueStatus: () => {
pending: number;
ready: number;
deadLetter: number;
lastAttemptAt: number | null;
lastError: string | null;
};
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void;
log: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
interface MpvClientLike {
setSocketPath: (socketPath: string) => void;
connect: () => void;
}
interface TexthookerServiceLike {
isRunning: () => boolean;
start: (port: number) => void;
}
interface MpvCliRuntime {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getClient: () => MpvClientLike | null;
showOsd: (text: string) => void;
}
interface TexthookerCliRuntime {
service: TexthookerServiceLike;
getPort: () => number;
setPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void;
}
interface OverlayCliRuntime {
isInitialized: () => boolean;
initialize: () => void;
toggleVisible: () => void;
toggleInvisible: () => void;
setVisible: (visible: boolean) => void;
setInvisible: (visible: boolean) => void;
}
interface MiningCliRuntime {
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWords: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
}
interface UiCliRuntime {
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
}
interface AnilistCliRuntime {
getStatus: CliCommandServiceDeps['getAnilistStatus'];
clearToken: CliCommandServiceDeps['clearAnilistToken'];
openSetup: CliCommandServiceDeps['openAnilistSetup'];
getQueueStatus: CliCommandServiceDeps['getAnilistQueueStatus'];
retryQueueNow: CliCommandServiceDeps['retryAnilistQueue'];
}
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
}
export interface CliCommandDepsRuntimeOptions {
mpv: MpvCliRuntime;
texthooker: TexthookerCliRuntime;
overlay: OverlayCliRuntime;
mining: MiningCliRuntime;
anilist: AnilistCliRuntime;
jellyfin: {
openSetup: () => void;
runCommand: (args: CliArgs) => Promise<void>;
};
ui: UiCliRuntime;
app: AppCliRuntime;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
export function createCliCommandDepsRuntime(
options: CliCommandDepsRuntimeOptions,
): CliCommandServiceDeps {
return {
getMpvSocketPath: options.mpv.getSocketPath,
setMpvSocketPath: options.mpv.setSocketPath,
setMpvClientSocketPath: (socketPath) => {
const client = options.mpv.getClient();
if (!client) return;
client.setSocketPath(socketPath);
},
hasMpvClient: () => Boolean(options.mpv.getClient()),
connectMpvClient: () => {
const client = options.mpv.getClient();
if (!client) return;
client.connect();
},
isTexthookerRunning: () => options.texthooker.service.isRunning(),
setTexthookerPort: options.texthooker.setPort,
getTexthookerPort: options.texthooker.getPort,
shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser,
ensureTexthookerRunning: (port) => {
if (!options.texthooker.service.isRunning()) {
options.texthooker.service.start(port);
}
},
openTexthookerInBrowser: options.texthooker.openInBrowser,
stopApp: options.app.stop,
isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible,
toggleInvisibleOverlay: options.overlay.toggleInvisible,
openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openYomitanSettings();
}, delayMs);
},
setVisibleOverlayVisible: options.overlay.setVisible,
setInvisibleOverlayVisible: options.overlay.setInvisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
mineSentenceCard: options.mining.mineSentenceCard,
startPendingMineSentenceMultiple: options.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard,
refreshKnownWords: options.mining.refreshKnownWords,
cycleSecondarySubMode: options.ui.cycleSecondarySubMode,
triggerFieldGrouping: options.mining.triggerFieldGrouping,
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup,
openJellyfinSetup: options.jellyfin.openSetup,
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
runJellyfinCommand: options.jellyfin.runCommand,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
log: options.log,
warn: options.warn,
error: options.error,
};
}
function formatTimestamp(value: number | null): string {
if (!value) return 'never';
return new Date(value).toISOString();
}
function runAsyncWithOsd(
task: () => Promise<void>,
deps: CliCommandServiceDeps,
logLabel: string,
osdLabel: string,
): void {
task().catch((err) => {
deps.error(`${logLabel} failed:`, err);
deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`);
});
}
export function handleCliCommand(
args: CliArgs,
source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps,
): void {
const hasNonStartAction =
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly =
source === 'second-instance' &&
args.start &&
!hasNonStartAction &&
deps.isOverlayRuntimeInitialized();
if (ignoreStartOnly) {
deps.log('Ignoring --start because SubMiner is already running.');
return;
}
const shouldStart =
args.start ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
if (args.socketPath !== undefined) {
deps.setMpvSocketPath(args.socketPath);
deps.setMpvClientSocketPath(args.socketPath);
}
if (args.texthookerPort !== undefined) {
if (deps.isTexthookerRunning()) {
deps.warn('Ignoring --port override because the texthooker server is already running.');
} else {
deps.setTexthookerPort(args.texthookerPort);
}
}
if (args.stop) {
deps.log('Stopping SubMiner...');
deps.stopApp();
return;
}
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
if (shouldStart && deps.hasMpvClient()) {
const socketPath = deps.getMpvSocketPath();
deps.setMpvClientSocketPath(socketPath);
deps.connectMpvClient();
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
}
if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.toggleInvisibleOverlay) {
deps.toggleInvisibleOverlay();
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
deps.setVisibleOverlayVisible(false);
} else if (args.showInvisibleOverlay) {
deps.setInvisibleOverlayVisible(true);
} else if (args.hideInvisibleOverlay) {
deps.setInvisibleOverlayVisible(false);
} else if (args.copySubtitle) {
deps.copyCurrentSubtitle();
} else if (args.copySubtitleMultiple) {
deps.startPendingMultiCopy(deps.getMultiCopyTimeoutMs());
} else if (args.mineSentence) {
runAsyncWithOsd(
() => deps.mineSentenceCard(),
deps,
'mineSentenceCard',
'Mine sentence failed',
);
} else if (args.mineSentenceMultiple) {
deps.startPendingMineSentenceMultiple(deps.getMultiCopyTimeoutMs());
} else if (args.updateLastCardFromClipboard) {
runAsyncWithOsd(
() => deps.updateLastCardFromClipboard(),
deps,
'updateLastCardFromClipboard',
'Update failed',
);
} else if (args.refreshKnownWords) {
runAsyncWithOsd(
() => deps.refreshKnownWords(),
deps,
'refreshKnownWords',
'Refresh known words failed',
);
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
runAsyncWithOsd(
() => deps.triggerFieldGrouping(),
deps,
'triggerFieldGrouping',
'Field grouping failed',
);
} else if (args.triggerSubsync) {
runAsyncWithOsd(
() => deps.triggerSubsyncFromConfig(),
deps,
'triggerSubsyncFromConfig',
'Subsync failed',
);
} else if (args.markAudioCard) {
runAsyncWithOsd(
() => deps.markLastCardAsAudioCard(),
deps,
'markLastCardAsAudioCard',
'Audio card failed',
);
} else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette();
} else if (args.anilistStatus) {
const status = deps.getAnilistStatus();
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);
if (status.tokenMessage) {
deps.log(`AniList token message: ${status.tokenMessage}`);
}
deps.log(
`AniList token timestamps: resolved=${formatTimestamp(status.tokenResolvedAt)}, error=${formatTimestamp(status.tokenErrorAt)}`,
);
deps.log(
`AniList queue: pending=${status.queuePending}, ready=${status.queueReady}, deadLetter=${status.queueDeadLetter}`,
);
deps.log(`AniList queue timestamps: lastAttempt=${formatTimestamp(status.queueLastAttemptAt)}`);
if (status.queueLastError) {
deps.warn(`AniList queue last error: ${status.queueLastError}`);
}
} else if (args.anilistLogout) {
deps.clearAnilistToken();
deps.log('Cleared stored AniList token.');
} else if (args.anilistSetup) {
deps.openAnilistSetup();
deps.log('Opened AniList setup flow.');
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log('Opened Jellyfin setup flow.');
} else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus();
deps.log(
`AniList queue before retry: pending=${queueStatus.pending}, ready=${queueStatus.ready}, deadLetter=${queueStatus.deadLetter}`,
);
runAsyncWithOsd(
async () => {
const result = await deps.retryAnilistQueue();
if (result.ok) deps.log(result.message);
else deps.warn(result.message);
},
deps,
'retryAnilistQueue',
'AniList retry failed',
);
} else if (
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce
) {
runAsyncWithOsd(
() => deps.runJellyfinCommand(args),
deps,
'runJellyfinCommand',
'Jellyfin command failed',
);
} else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort);
if (deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
}
deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`);
} else if (args.help) {
deps.printHelp();
if (!deps.hasMainWindow()) deps.stopApp();
}
}

View File

@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
classifyConfigHotReloadDiff,
createConfigHotReloadRuntime,
type ConfigHotReloadRuntimeDeps,
} from './config-hot-reload';
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
next.websocket.port = prev.websocket.port + 1;
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
});
test('config hot reload runtime debounces rapid watch events', () => {
let watchedChangeCallback: (() => void) | null = null;
const pendingTimers = new Map<number, () => void>();
let nextTimerId = 1;
let reloadCalls = 0;
const deps: ConfigHotReloadRuntimeDeps = {
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => {
reloadCalls += 1;
return {
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [],
path: '/tmp/config.jsonc',
};
},
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
const id = nextTimerId;
nextTimerId += 1;
pendingTimers.set(id, callback);
return id as unknown as NodeJS.Timeout;
},
clearTimeout: (timeout) => {
pendingTimers.delete(timeout as unknown as number);
},
debounceMs: 25,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
onValidationWarnings: () => {},
};
const runtime = createConfigHotReloadRuntime(deps);
runtime.start();
assert.equal(reloadCalls, 1);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
trigger();
trigger();
assert.equal(pendingTimers.size, 1);
for (const callback of pendingTimers.values()) {
callback();
}
assert.equal(reloadCalls, 2);
});
test('config hot reload runtime reports invalid config and skips apply', () => {
const invalidMessages: string[] = [];
let watchedChangeCallback: (() => void) | null = null;
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: false,
error: 'Invalid JSON',
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {
throw new Error('Hot reload should not apply for invalid config.');
},
onRestartRequired: () => {
throw new Error('Restart warning should not trigger for invalid config.');
},
onInvalidConfig: (message) => {
invalidMessages.push(message);
},
onValidationWarnings: () => {
throw new Error('Validation warnings should not trigger for invalid config.');
},
});
runtime.start();
assert.equal(watchedChangeCallback, null);
assert.equal(invalidMessages.length, 1);
});
test('config hot reload runtime reports validation warnings from reload', () => {
let watchedChangeCallback: (() => void) | null = null;
const warningCalls: Array<{ path: string; count: number }> = [];
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
],
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
onValidationWarnings: (path, warnings) => {
warningCalls.push({ path, count: warnings.length });
},
});
runtime.start();
assert.equal(warningCalls.length, 0);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]);
});

View File

@@ -0,0 +1,165 @@
import { type ReloadConfigStrictResult } from '../../config';
import type { ConfigValidationWarning } from '../../types';
import type { ResolvedConfig } from '../../types';
export interface ConfigHotReloadDiff {
hotReloadFields: string[];
restartRequiredFields: string[];
}
export interface ConfigHotReloadRuntimeDeps {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void };
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs?: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
}
export interface ConfigHotReloadRuntime {
start: () => void;
stop: () => void;
}
function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
hotReloadFields.push('subtitleStyle');
}
if (!isEqual(prev.keybindings, next.keybindings)) {
hotReloadFields.push('keybindings');
}
if (!isEqual(prev.shortcuts, next.shortcuts)) {
hotReloadFields.push('shortcuts');
}
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
hotReloadFields.push('secondarySub.defaultMode');
}
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
hotReloadFields.push('ankiConnect.ai');
}
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') {
continue;
}
if (key === 'secondarySub') {
const normalizedPrev = {
...prev.secondarySub,
defaultMode: next.secondarySub.defaultMode,
};
if (!isEqual(normalizedPrev, next.secondarySub)) {
restartRequiredFields.push('secondarySub');
}
continue;
}
if (key === 'ankiConnect') {
const normalizedPrev = {
...prev.ankiConnect,
ai: next.ankiConnect.ai,
};
if (!isEqual(normalizedPrev, next.ankiConnect)) {
restartRequiredFields.push('ankiConnect');
}
continue;
}
if (!isEqual(prev[key], next[key])) {
restartRequiredFields.push(String(key));
}
}
return { hotReloadFields, restartRequiredFields };
}
export function createConfigHotReloadRuntime(
deps: ConfigHotReloadRuntimeDeps,
): ConfigHotReloadRuntime {
let watcher: { close: () => void } | null = null;
let timer: NodeJS.Timeout | null = null;
let watchedPath: string | null = null;
const debounceMs = deps.debounceMs ?? 250;
const reloadWithDiff = () => {
const prev = deps.getCurrentConfig();
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config reload failed: ${result.error}`);
return;
}
if (watchedPath !== result.path) {
watchPath(result.path);
}
if (result.warnings.length > 0) {
deps.onValidationWarnings(result.path, result.warnings);
}
const diff = classifyDiff(prev, result.config);
if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied(diff, result.config);
}
if (diff.restartRequiredFields.length > 0) {
deps.onRestartRequired(diff.restartRequiredFields);
}
};
const scheduleReload = () => {
if (timer) {
deps.clearTimeout(timer);
}
timer = deps.setTimeout(() => {
timer = null;
reloadWithDiff();
}, debounceMs);
};
const watchPath = (configPath: string) => {
watcher?.close();
watcher = deps.watchConfigPath(configPath, scheduleReload);
watchedPath = configPath;
};
return {
start: () => {
if (watcher) {
return;
}
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`);
return;
}
watchPath(result.path);
},
stop: () => {
if (timer) {
deps.clearTimeout(timer);
timer = null;
}
watcher?.close();
watcher = null;
watchedPath = null;
},
};
}
export { classifyDiff as classifyConfigHotReloadDiff };

View File

@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildDiscordPresenceActivity,
createDiscordPresenceService,
type DiscordActivityPayload,
type DiscordPresenceSnapshot,
} from './discord-presence';
const baseConfig = {
enabled: true,
updateIntervalMs: 10_000,
debounceMs: 200,
} as const;
const baseSnapshot: DiscordPresenceSnapshot = {
mediaTitle: 'Sousou no Frieren E01',
mediaPath: '/media/Frieren/E01.mkv',
subtitleText: '旅立ち',
currentTimeSec: 95,
mediaDurationSec: 1450,
paused: false,
connected: true,
sessionStartedAtMs: 1_700_000_000_000,
};
test('buildDiscordPresenceActivity maps polished payload fields', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
assert.equal(payload.largeImageKey, 'subminer-logo');
assert.equal(payload.smallImageKey, 'study');
assert.equal(payload.buttons, undefined);
assert.equal(payload.startTimestamp, 1_700_000_000);
});
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.state, 'Idle');
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
});
test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
let nowMs = 100_000;
const service = createDiscordPresenceService({
config: baseConfig,
createClient: () => ({
login: async () => {},
setActivity: async (activity) => {
sent.push(activity);
},
clearActivity: async () => {},
destroy: () => {},
}),
now: () => nowMs,
setTimeoutFn: (callback) => {
const id = ++timerId;
timers.set(id, callback);
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimeoutFn: (id) => {
timers.delete(id as unknown as number);
},
});
await service.start();
service.publish(baseSnapshot);
timers.get(1)?.();
await Promise.resolve();
assert.equal(sent.length, 1);
service.publish(baseSnapshot);
timers.get(2)?.();
await Promise.resolve();
assert.equal(sent.length, 1);
nowMs += 10_001;
service.publish({ ...baseSnapshot, paused: true, currentTimeSec: 100 });
timers.get(3)?.();
await Promise.resolve();
assert.equal(sent.length, 2);
assert.equal(sent[1]?.state, 'Paused 01:40 / 24:10');
});
test('service handles login failure and stop without throwing', async () => {
let destroyed = false;
const service = createDiscordPresenceService({
config: baseConfig,
createClient: () => ({
login: async () => {
throw new Error('discord not running');
},
setActivity: async () => {},
clearActivity: async () => {},
destroy: () => {
destroyed = true;
},
}),
});
await assert.doesNotReject(async () => service.start());
await assert.doesNotReject(async () => service.stop());
assert.equal(destroyed, false);
});

View File

@@ -0,0 +1,223 @@
import type { ResolvedConfig } from '../../types';
export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
mediaPath: string | null;
subtitleText: string;
currentTimeSec?: number | null;
mediaDurationSec?: number | null;
paused: boolean | null;
connected: boolean;
sessionStartedAtMs: number;
}
type DiscordPresenceConfig = ResolvedConfig['discordPresence'];
export interface DiscordActivityPayload {
details: string;
state: string;
startTimestamp: number;
largeImageKey?: string;
largeImageText?: string;
smallImageKey?: string;
smallImageText?: string;
buttons?: Array<{ label: string; url: string }>;
}
type DiscordClient = {
login: () => Promise<void>;
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
clearActivity: () => Promise<void>;
destroy: () => void;
};
type TimeoutLike = ReturnType<typeof setTimeout>;
const DISCORD_PRESENCE_STYLE = {
fallbackDetails: 'Mining and crafting (Anki cards)',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
} as const;
function trimField(value: string, maxLength = 128): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.max(0, maxLength - 1))}`;
}
function sanitizeText(value: string | null | undefined, fallback: string): string {
const text = value?.trim();
if (!text) return fallback;
return text;
}
function basename(filePath: string | null): string {
if (!filePath) return '';
const parts = filePath.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused';
return 'Playing';
}
function formatClock(totalSeconds: number | null | undefined): string {
if (!Number.isFinite(totalSeconds) || (totalSeconds ?? -1) < 0) return '--:--';
const rounded = Math.floor(totalSeconds as number);
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const seconds = rounded % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
export function buildDiscordPresenceActivity(
_config: DiscordPresenceConfig,
snapshot: DiscordPresenceSnapshot,
): DiscordActivityPayload {
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const details =
snapshot.connected && snapshot.mediaPath
? trimField(title)
: DISCORD_PRESENCE_STYLE.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state =
snapshot.connected && snapshot.mediaPath
? trimField(`${status} ${timeline}`)
: trimField(status);
const activity: DiscordActivityPayload = {
details,
state,
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
};
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
}
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
}
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
}
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
}
if (
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
) {
activity.buttons = [
{
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
},
];
}
return activity;
}
export function createDiscordPresenceService(deps: {
config: DiscordPresenceConfig;
createClient: () => DiscordClient;
now?: () => number;
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
clearTimeoutFn?: (timer: TimeoutLike) => void;
logDebug?: (message: string, meta?: unknown) => void;
}) {
const now = deps.now ?? (() => Date.now());
const setTimeoutFn = deps.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutFn = deps.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
const logDebug = deps.logDebug ?? (() => {});
let client: DiscordClient | null = null;
let pendingSnapshot: DiscordPresenceSnapshot | null = null;
let debounceTimer: TimeoutLike | null = null;
let intervalTimer: TimeoutLike | null = null;
let lastActivityKey = '';
let lastSentAtMs = 0;
async function flush(): Promise<void> {
if (!client || !pendingSnapshot) return;
const elapsed = now() - lastSentAtMs;
if (elapsed < deps.config.updateIntervalMs) {
const delay = Math.max(0, deps.config.updateIntervalMs - elapsed);
if (intervalTimer) clearTimeoutFn(intervalTimer);
intervalTimer = setTimeoutFn(() => {
void flush();
}, delay);
return;
}
const payload = buildDiscordPresenceActivity(deps.config, pendingSnapshot);
const activityKey = JSON.stringify(payload);
if (activityKey === lastActivityKey) return;
try {
await client.setActivity(payload);
lastSentAtMs = now();
lastActivityKey = activityKey;
} catch (error) {
logDebug('[discord-presence] failed to set activity', error);
}
}
function scheduleFlush(snapshot: DiscordPresenceSnapshot): void {
pendingSnapshot = snapshot;
if (debounceTimer) {
clearTimeoutFn(debounceTimer);
}
debounceTimer = setTimeoutFn(() => {
debounceTimer = null;
void flush();
}, deps.config.debounceMs);
}
return {
async start(): Promise<void> {
if (!deps.config.enabled) return;
try {
client = deps.createClient();
await client.login();
} catch (error) {
client = null;
logDebug('[discord-presence] login failed', error);
}
},
publish(snapshot: DiscordPresenceSnapshot): void {
if (!client) return;
scheduleFlush(snapshot);
},
async stop(): Promise<void> {
if (debounceTimer) {
clearTimeoutFn(debounceTimer);
debounceTimer = null;
}
if (intervalTimer) {
clearTimeoutFn(intervalTimer);
intervalTimer = null;
}
pendingSnapshot = null;
lastActivityKey = '';
lastSentAtMs = 0;
if (!client) return;
try {
await client.clearActivity();
} catch (error) {
logDebug('[discord-presence] clear activity failed', error);
}
client.destroy();
client = null;
},
};
}

View File

@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice } from '../../types';
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
const sent: unknown[][] = [];
let visible = false;
const restore = new Set<'runtime-options' | 'subsync'>();
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: {
isLoading: () => false,
send: (...args: unknown[]) => {
sent.push(args);
},
},
}),
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (next) => {
visible = next;
},
setInvisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => restore,
});
const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(ok, true);
assert.equal(visible, true);
assert.equal(restore.has('runtime-options'), true);
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = next;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
});
const callback = runtime.createFieldGroupingCallback();
const result = await callback({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
assert.equal(result.cancelled, true);
assert.equal(result.keepNoteId, 0);
assert.equal(result.deleteNoteId, 0);
});
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
let resolver: unknown = null;
let visible = false;
const visibilityTransitions: boolean[] = [];
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (nextVisible) => {
visible = nextVisible;
visibilityTransitions.push(nextVisible);
},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = nextResolver;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
sendToVisibleOverlay: () => true,
});
const callback = runtime.createFieldGroupingCallback();
const pendingChoice = callback({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
assert.equal(visible, true);
assert.ok(resolver);
if (typeof resolver !== 'function') {
throw new Error('expected field grouping resolver to be assigned');
}
(resolver as (choice: KikuFieldGroupingChoice) => void)({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
await pendingChoice;
assert.equal(visible, false);
assert.deepEqual(visibilityTransitions, [true, false]);
});

View File

@@ -0,0 +1,81 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
interface WindowLike {
isDestroyed: () => boolean;
webContents: {
send: (channel: string, payload?: unknown) => void;
};
}
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
}
export function createFieldGroupingOverlayRuntime<T extends string>(
options: FieldGroupingOverlayRuntimeOptions<T>,
): {
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
} {
const sendToVisibleOverlay = (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
): boolean => {
if (options.sendToVisibleOverlay) {
const wasVisible = options.getVisibleOverlayVisible();
const sent = options.sendToVisibleOverlay(channel, payload, runtimeOptions);
if (sent && !wasVisible && !options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(true);
}
return sent;
}
return sendToVisibleOverlayRuntime({
mainWindow: options.getMainWindow() as never,
visibleOverlayVisible: options.getVisibleOverlayVisible(),
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
channel,
payload,
restoreOnModalClose: runtimeOptions?.restoreOnModalClose,
restoreVisibleOverlayOnModalClose: options.getRestoreVisibleOverlayOnModalClose(),
});
};
const createFieldGroupingCallback = (): ((
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => {
return createFieldGroupingCallbackRuntime({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
sendToVisibleOverlay,
});
};
return {
sendToVisibleOverlay,
createFieldGroupingCallback,
};
}

View File

@@ -0,0 +1,66 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
if (options.getResolver()) {
resolve({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
const previousVisibleOverlay = options.getVisibleOverlayVisible();
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
let settled = false;
const finish = (choice: KikuFieldGroupingChoice): void => {
if (settled) return;
settled = true;
if (options.getResolver() === finish) {
options.setResolver(null);
}
resolve(choice);
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
}
};
options.setResolver(finish);
if (!options.sendRequestToVisibleOverlay(data)) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
});
};
}

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createFrequencyDictionaryLookup } from './frequency-dictionary';
test('createFrequencyDictionaryLookup logs parse errors and returns no-op for invalid dictionaries', async () => {
const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
fs.writeFileSync(bankPath, '{ invalid json');
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: (message) => {
logs.push(message);
},
});
const rank = lookup('猫');
assert.equal(rank, null);
assert.equal(
logs.some(
(entry) =>
entry.includes('Failed to parse frequency dictionary file as JSON') &&
entry.includes('term_meta_bank_1.json'),
),
true,
);
});
test('createFrequencyDictionaryLookup continues with no-op lookup when search path is missing', async () => {
const logs: string[] = [];
const missingPath = path.join(os.tmpdir(), 'subminer-frequency-dict-missing-dir');
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [missingPath],
log: (message) => {
logs.push(message);
},
});
assert.equal(lookup('猫'), null);
assert.equal(
logs.some((entry) => entry.includes(`Frequency dictionary not found.`)),
true,
);
});
test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a single summary', async () => {
const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
fs.writeFileSync(
bankPath,
JSON.stringify([
['猫', 1, { frequency: { displayValue: 100 } }],
['猫', 2, { frequency: { displayValue: 120 } }],
['猫', 3, { frequency: { displayValue: 110 } }],
]),
);
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: (message) => {
logs.push(message);
},
});
assert.equal(lookup('猫'), 100);
assert.equal(
logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries')).length,
1,
);
assert.equal(
logs.some((entry) => entry.includes('Frequency dictionary duplicate term')),
false,
);
});

View File

@@ -0,0 +1,195 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface FrequencyDictionaryLookupOptions {
searchPaths: string[];
log: (message: string) => void;
}
interface FrequencyDictionaryEntry {
rank: number;
term: string;
}
const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/;
const NOOP_LOOKUP = (): null => null;
function normalizeFrequencyTerm(value: string): string {
return value.trim().toLowerCase();
}
function extractFrequencyDisplayValue(meta: unknown): number | null {
if (!meta || typeof meta !== 'object') return null;
const frequency = (meta as { frequency?: unknown }).frequency;
if (!frequency || typeof frequency !== 'object') return null;
const displayValue = (frequency as { displayValue?: unknown }).displayValue;
if (typeof displayValue === 'number') {
if (!Number.isFinite(displayValue) || displayValue <= 0) return null;
return Math.floor(displayValue);
}
if (typeof displayValue === 'string') {
const normalized = displayValue.trim().replace(/,/g, '');
const parsed = Number.parseInt(normalized, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
return null;
}
function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null {
if (!Array.isArray(entry) || entry.length < 3) {
return null;
}
const [term, _id, meta] = entry as [unknown, unknown, unknown];
if (typeof term !== 'string') {
return null;
}
const frequency = extractFrequencyDisplayValue(meta);
if (frequency === null) return null;
const normalizedTerm = normalizeFrequencyTerm(term);
if (!normalizedTerm) return null;
return {
term: normalizedTerm,
rank: frequency,
};
}
function addEntriesToMap(
rawEntries: unknown,
terms: Map<string, number>,
): { duplicateCount: number } {
if (!Array.isArray(rawEntries)) {
return { duplicateCount: 0 };
}
let duplicateCount = 0;
for (const rawEntry of rawEntries) {
const entry = asFrequencyDictionaryEntry(rawEntry);
if (!entry) {
continue;
}
const currentRank = terms.get(entry.term);
if (currentRank === undefined || entry.rank < currentRank) {
terms.set(entry.term, entry.rank);
continue;
}
duplicateCount += 1;
}
return { duplicateCount };
}
function collectDictionaryFromPath(
dictionaryPath: string,
log: (message: string) => void,
): Map<string, number> {
const terms = new Map<string, number>();
let fileNames: string[];
try {
fileNames = fs.readdirSync(dictionaryPath);
} catch (error) {
log(`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(error)}`);
return terms;
}
const bankFiles = fileNames.filter((name) => FREQUENCY_BANK_FILE_GLOB.test(name)).sort();
if (bankFiles.length === 0) {
return terms;
}
for (const bankFile of bankFiles) {
const bankPath = path.join(dictionaryPath, bankFile);
let rawText: string;
try {
rawText = fs.readFileSync(bankPath, 'utf-8');
} catch {
log(`Failed to read frequency dictionary file ${bankPath}`);
continue;
}
let rawEntries: unknown;
try {
rawEntries = JSON.parse(rawText) as unknown;
} catch {
log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`);
continue;
}
const beforeSize = terms.size;
const { duplicateCount } = addEntriesToMap(rawEntries, terms);
if (duplicateCount > 0) {
log(
`Frequency dictionary ignored ${duplicateCount} duplicate term entr${
duplicateCount === 1 ? 'y' : 'ies'
} in ${bankPath} (kept strongest rank per term).`,
);
}
if (terms.size === beforeSize) {
log(`Frequency dictionary file contained no extractable entries: ${bankPath}`);
}
}
return terms;
}
export async function createFrequencyDictionaryLookup(
options: FrequencyDictionaryLookupOptions,
): Promise<(term: string) => number | null> {
const attemptedPaths: string[] = [];
let foundDictionaryPathCount = 0;
for (const dictionaryPath of options.searchPaths) {
attemptedPaths.push(dictionaryPath);
let isDirectory = false;
try {
if (!fs.existsSync(dictionaryPath)) {
continue;
}
isDirectory = fs.statSync(dictionaryPath).isDirectory();
} catch (error) {
options.log(
`Failed to inspect frequency dictionary path ${dictionaryPath}: ${String(error)}`,
);
continue;
}
if (!isDirectory) {
continue;
}
foundDictionaryPathCount += 1;
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
if (terms.size > 0) {
options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
return (term: string): number | null => {
const normalized = normalizeFrequencyTerm(term);
if (!normalized) return null;
return terms.get(normalized) ?? null;
};
}
options.log(
`Frequency dictionary directory exists but contains no readable term_meta_bank_*.json files: ${dictionaryPath}`,
);
}
options.log(
`Frequency dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(', ')}`,
);
if (foundDictionaryPathCount > 0) {
options.log(
'Frequency dictionary directories found, but no usable term_meta_bank_*.json files were loaded.',
);
}
return NOOP_LOOKUP;
}

View File

@@ -0,0 +1,560 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite';
import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
import {
deriveCanonicalTitle,
normalizeText,
resolveBoundedInt,
} from './immersion-tracker/reducer';
import type { QueuedWrite } from './immersion-tracker/types';
type ImmersionTrackerService = import('./immersion-tracker-service').ImmersionTrackerService;
type ImmersionTrackerServiceCtor =
typeof import('./immersion-tracker-service').ImmersionTrackerService;
type DatabaseSyncCtor = typeof NodeDatabaseSync;
const DatabaseSync: DatabaseSyncCtor | null = (() => {
try {
return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null;
} catch {
return null;
}
})();
const testIfSqlite = DatabaseSync ? test : test.skip;
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
if (trackerCtor) return trackerCtor;
const mod = await import('./immersion-tracker-service');
trackerCtor = mod.ImmersionTrackerService;
return trackerCtor;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-immersion-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('seam: resolveBoundedInt keeps fallback for invalid values', () => {
assert.equal(resolveBoundedInt(undefined, 25, 1, 100), 25);
assert.equal(resolveBoundedInt(0, 25, 1, 100), 25);
assert.equal(resolveBoundedInt(101, 25, 1, 100), 25);
assert.equal(resolveBoundedInt(44.8, 25, 1, 100), 44);
});
test('seam: reducer title normalization covers local and remote paths', () => {
assert.equal(normalizeText(' hello\n world '), 'hello world');
assert.equal(deriveCanonicalTitle('/tmp/Episode 01.mkv'), 'Episode 01');
assert.equal(
deriveCanonicalTitle('https://cdn.example.com/show/%E7%AC%AC1%E8%A9%B1.mp4'),
'\u7b2c1\u8a71',
);
});
test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () => {
const queue: QueuedWrite[] = [
{ kind: 'event', sessionId: 1, eventType: 1, sampleMs: 1000 },
{ kind: 'event', sessionId: 1, eventType: 2, sampleMs: 1001 },
];
const incoming: QueuedWrite = { kind: 'event', sessionId: 1, eventType: 3, sampleMs: 1002 };
const result = enqueueWrite(queue, incoming, 2);
assert.equal(result.dropped, 1);
assert.equal(queue.length, 2);
assert.equal(queue[0]!.eventType, 2);
assert.equal(queue[1]!.eventType, 3);
});
test('seam: toMonthKey uses UTC calendar month', () => {
assert.equal(toMonthKey(Date.UTC(2026, 0, 31, 23, 59, 59, 999)), 202601);
assert.equal(toMonthKey(Date.UTC(2026, 1, 1, 0, 0, 0, 0)), 202602);
});
testIfSqlite('startSession generates UUID-like session identifiers', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/episode.mkv', 'Episode');
const privateApi = tracker as unknown as {
flushTelemetry: (force?: boolean) => void;
flushNow: () => void;
};
privateApi.flushTelemetry(true);
privateApi.flushNow();
const db = new DatabaseSync!(dbPath);
const row = db.prepare('SELECT session_uuid FROM imm_sessions LIMIT 1').get() as {
session_uuid: string;
} | null;
db.close();
assert.equal(typeof row?.session_uuid, 'string');
assert.equal(row?.session_uuid?.startsWith('session-'), false);
assert.ok(/^[0-9a-fA-F-]{36}$/.test(row?.session_uuid || ''));
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite('destroy finalizes active session and persists final telemetry', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/episode-2.mkv', 'Episode 2');
tracker.recordSubtitleLine('Hello immersion', 0, 1);
tracker.destroy();
const db = new DatabaseSync!(dbPath);
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
ended_at_ms: number | null;
} | null;
const telemetryCountRow = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
.get() as { total: number };
db.close();
assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0);
assert.ok(Number(telemetryCountRow.total) >= 2);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite('persists and retrieves minimum immersion tracking fields', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/episode-3.mkv', 'Episode 3');
tracker.recordSubtitleLine('alpha beta', 0, 1.2);
tracker.recordCardsMined(2);
tracker.recordLookup(true);
tracker.recordPlaybackPosition(12.5);
const privateApi = tracker as unknown as {
flushTelemetry: (force?: boolean) => void;
flushNow: () => void;
};
privateApi.flushTelemetry(true);
privateApi.flushNow();
const summaries = await tracker.getSessionSummaries(10);
assert.ok(summaries.length >= 1);
assert.ok(summaries[0]!.linesSeen >= 1);
assert.ok(summaries[0]!.cardsMined >= 2);
tracker.destroy();
const db = new DatabaseSync!(dbPath);
const videoRow = db
.prepare('SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1')
.get() as {
canonical_title: string;
source_path: string | null;
duration_ms: number;
} | null;
const telemetryRow = db
.prepare(
`SELECT lines_seen, words_seen, tokens_seen, cards_mined
FROM imm_session_telemetry
ORDER BY sample_ms DESC
LIMIT 1`,
)
.get() as {
lines_seen: number;
words_seen: number;
tokens_seen: number;
cards_mined: number;
} | null;
db.close();
assert.ok(videoRow);
assert.equal(videoRow?.canonical_title, 'Episode 3');
assert.equal(videoRow?.source_path, '/tmp/episode-3.mkv');
assert.ok(Number(videoRow?.duration_ms ?? -1) >= 0);
assert.ok(telemetryRow);
assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1);
assert.ok(Number(telemetryRow?.words_seen ?? 0) >= 2);
assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2);
assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite('applies configurable queue, flush, and retention policy', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({
dbPath,
policy: {
batchSize: 10,
flushIntervalMs: 250,
queueCap: 1500,
payloadCapBytes: 512,
maintenanceIntervalMs: 2 * 60 * 60 * 1000,
retention: {
eventsDays: 14,
telemetryDays: 45,
dailyRollupsDays: 730,
monthlyRollupsDays: 3650,
vacuumIntervalDays: 14,
},
},
});
const privateApi = tracker as unknown as {
batchSize: number;
flushIntervalMs: number;
queueCap: number;
maxPayloadBytes: number;
maintenanceIntervalMs: number;
eventsRetentionMs: number;
telemetryRetentionMs: number;
dailyRollupRetentionMs: number;
monthlyRollupRetentionMs: number;
vacuumIntervalMs: number;
};
assert.equal(privateApi.batchSize, 10);
assert.equal(privateApi.flushIntervalMs, 250);
assert.equal(privateApi.queueCap, 1500);
assert.equal(privateApi.maxPayloadBytes, 512);
assert.equal(privateApi.maintenanceIntervalMs, 7_200_000);
assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000);
assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000);
assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000);
assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000);
assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite('monthly rollups are grouped by calendar month', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as {
db: NodeDatabaseSync;
runRollupMaintenance: () => void;
};
const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0);
const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0);
privateApi.db.exec(`
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
source_type,
duration_ms,
created_at_ms,
updated_at_ms
) VALUES (
1,
'local:/tmp/video.mkv',
'Episode',
1,
0,
${januaryStartedAtMs},
${januaryStartedAtMs}
)
`);
privateApi.db.exec(`
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
status,
created_at_ms,
updated_at_ms,
ended_at_ms
) VALUES (
1,
'11111111-1111-1111-1111-111111111111',
1,
${januaryStartedAtMs},
2,
${januaryStartedAtMs},
${januaryStartedAtMs},
${januaryStartedAtMs + 5000}
)
`);
privateApi.db.exec(`
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
words_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES (
1,
${januaryStartedAtMs + 1000},
5000,
5000,
1,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0
)
`);
privateApi.db.exec(`
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
status,
created_at_ms,
updated_at_ms,
ended_at_ms
) VALUES (
2,
'22222222-2222-2222-2222-222222222222',
1,
${februaryStartedAtMs},
2,
${februaryStartedAtMs},
${februaryStartedAtMs},
${februaryStartedAtMs + 5000}
)
`);
privateApi.db.exec(`
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
words_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES (
2,
${februaryStartedAtMs + 1000},
4000,
4000,
2,
3,
3,
1,
1,
1,
0,
0,
0,
0,
0
)
`);
privateApi.runRollupMaintenance();
const rows = await tracker.getMonthlyRollups(10);
const videoRows = rows.filter((row) => row.videoId === 1);
assert.equal(videoRows.length, 2);
assert.equal(videoRows[0]!.rollupDayOrMonth, 202602);
assert.equal(videoRows[1]!.rollupDayOrMonth, 202601);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite('flushSingle reuses cached prepared statements', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let originalPrepare: NodeDatabaseSync['prepare'] | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as {
db: NodeDatabaseSync;
flushSingle: (write: {
kind: 'telemetry' | 'event';
sessionId: number;
sampleMs: number;
eventType?: number;
lineIndex?: number | null;
segmentStartMs?: number | null;
segmentEndMs?: number | null;
wordsDelta?: number;
cardsDelta?: number;
payloadJson?: string | null;
totalWatchedMs?: number;
activeWatchedMs?: number;
linesSeen?: number;
wordsSeen?: number;
tokensSeen?: number;
cardsMined?: number;
lookupCount?: number;
lookupHits?: number;
pauseCount?: number;
pauseMs?: number;
seekForwardCount?: number;
seekBackwardCount?: number;
mediaBufferEvents?: number;
}) => void;
};
originalPrepare = privateApi.db.prepare;
let prepareCalls = 0;
privateApi.db.prepare = (...args: Parameters<NodeDatabaseSync['prepare']>) => {
prepareCalls += 1;
return originalPrepare!.apply(privateApi.db, args);
};
const preparedRestore = originalPrepare;
privateApi.db.exec(`
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
source_type,
duration_ms,
created_at_ms,
updated_at_ms
) VALUES (
1,
'local:/tmp/prepared.mkv',
'Prepared',
1,
0,
1000,
1000
)
`);
privateApi.db.exec(`
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
status,
created_at_ms,
updated_at_ms,
ended_at_ms
) VALUES (
1,
'33333333-3333-3333-3333-333333333333',
1,
1000,
2,
1000,
1000,
2000
)
`);
privateApi.flushSingle({
kind: 'telemetry',
sessionId: 1,
sampleMs: 1500,
totalWatchedMs: 1000,
activeWatchedMs: 1000,
linesSeen: 1,
wordsSeen: 2,
tokensSeen: 2,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
});
privateApi.flushSingle({
kind: 'event',
sessionId: 1,
sampleMs: 1600,
eventType: 1,
lineIndex: 1,
segmentStartMs: 0,
segmentEndMs: 1000,
wordsDelta: 2,
cardsDelta: 0,
payloadJson: '{"event":"subtitle-line"}',
});
privateApi.db.prepare = preparedRestore;
assert.equal(prepareCalls, 0);
} finally {
if (tracker && originalPrepare) {
const privateApi = tracker as unknown as { db: NodeDatabaseSync };
privateApi.db.prepare = originalPrepare;
}
tracker?.destroy();
cleanupDbPath(dbPath);
}
});

View File

@@ -0,0 +1,654 @@
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
import { getLocalVideoMetadata } from './immersion-tracker/metadata';
import { pruneRetention, runRollupMaintenance } from './immersion-tracker/maintenance';
import { finalizeSessionRecord, startSessionRecord } from './immersion-tracker/session';
import {
applyPragmas,
createTrackerPreparedStatements,
ensureSchema,
executeQueuedWrite,
getOrCreateVideoRecord,
type TrackerPreparedStatements,
updateVideoMetadataRecord,
updateVideoTitleRecord,
} from './immersion-tracker/storage';
import {
getDailyRollups,
getMonthlyRollups,
getQueryHints,
getSessionSummaries,
getSessionTimeline,
} from './immersion-tracker/query';
import {
buildVideoKey,
calculateTextMetrics,
deriveCanonicalTitle,
isRemoteSource,
normalizeMediaPath,
normalizeText,
resolveBoundedInt,
sanitizePayload,
secToMs,
} from './immersion-tracker/reducer';
import { enqueueWrite } from './immersion-tracker/queue';
import {
DEFAULT_BATCH_SIZE,
DEFAULT_DAILY_ROLLUP_RETENTION_MS,
DEFAULT_EVENTS_RETENTION_MS,
DEFAULT_FLUSH_INTERVAL_MS,
DEFAULT_MAINTENANCE_INTERVAL_MS,
DEFAULT_MAX_PAYLOAD_BYTES,
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
DEFAULT_QUEUE_CAP,
DEFAULT_TELEMETRY_RETENTION_MS,
DEFAULT_VACUUM_INTERVAL_MS,
EVENT_CARD_MINED,
EVENT_LOOKUP,
EVENT_MEDIA_BUFFER,
EVENT_PAUSE_END,
EVENT_PAUSE_START,
EVENT_SEEK_BACKWARD,
EVENT_SEEK_FORWARD,
EVENT_SUBTITLE_LINE,
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
type ImmersionSessionRollupRow,
type ImmersionTrackerOptions,
type QueuedWrite,
type SessionState,
type SessionSummaryQueryRow,
type SessionTimelineRow,
} from './immersion-tracker/types';
export type {
ImmersionSessionRollupRow,
ImmersionTrackerOptions,
ImmersionTrackerPolicy,
SessionSummaryQueryRow,
SessionTimelineRow,
} from './immersion-tracker/types';
export class ImmersionTrackerService {
private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync;
private readonly queue: QueuedWrite[] = [];
private readonly queueCap: number;
private readonly batchSize: number;
private readonly flushIntervalMs: number;
private readonly maintenanceIntervalMs: number;
private readonly maxPayloadBytes: number;
private readonly eventsRetentionMs: number;
private readonly telemetryRetentionMs: number;
private readonly dailyRollupRetentionMs: number;
private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number;
private readonly dbPath: string;
private readonly writeLock = { locked: false };
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
private flushScheduled = false;
private droppedWriteCount = 0;
private lastVacuumMs = 0;
private isDestroyed = false;
private sessionState: SessionState | null = null;
private currentVideoKey = '';
private currentMediaPathOrUrl = '';
private readonly preparedStatements: TrackerPreparedStatements;
constructor(options: ImmersionTrackerOptions) {
this.dbPath = options.dbPath;
const parentDir = path.dirname(this.dbPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
const policy = options.policy ?? {};
this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000);
this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000);
this.flushIntervalMs = resolveBoundedInt(
policy.flushIntervalMs,
DEFAULT_FLUSH_INTERVAL_MS,
50,
60_000,
);
this.maintenanceIntervalMs = resolveBoundedInt(
policy.maintenanceIntervalMs,
DEFAULT_MAINTENANCE_INTERVAL_MS,
60_000,
7 * 24 * 60 * 60 * 1000,
);
this.maxPayloadBytes = resolveBoundedInt(
policy.payloadCapBytes,
DEFAULT_MAX_PAYLOAD_BYTES,
64,
8192,
);
const retention = policy.retention ?? {};
this.eventsRetentionMs =
resolveBoundedInt(
retention.eventsDays,
Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.telemetryRetentionMs =
resolveBoundedInt(
retention.telemetryDays,
Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.dailyRollupRetentionMs =
resolveBoundedInt(
retention.dailyRollupsDays,
Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.monthlyRollupRetentionMs =
resolveBoundedInt(
retention.monthlyRollupsDays,
Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.vacuumIntervalMs =
resolveBoundedInt(
retention.vacuumIntervalDays,
Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.db = new DatabaseSync(this.dbPath);
applyPragmas(this.db);
ensureSchema(this.db);
this.preparedStatements = createTrackerPreparedStatements(this.db);
this.scheduleMaintenance();
this.scheduleFlush();
}
destroy(): void {
if (this.isDestroyed) return;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.maintenanceTimer) {
clearInterval(this.maintenanceTimer);
this.maintenanceTimer = null;
}
this.finalizeActiveSession();
this.isDestroyed = true;
this.db.close();
}
async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
return getSessionSummaries(this.db, limit);
}
async getSessionTimeline(sessionId: number, limit = 200): Promise<SessionTimelineRow[]> {
return getSessionTimeline(this.db, sessionId, limit);
}
async getQueryHints(): Promise<{
totalSessions: number;
activeSessions: number;
}> {
return getQueryHints(this.db);
}
async getDailyRollups(limit = 60): Promise<ImmersionSessionRollupRow[]> {
return getDailyRollups(this.db, limit);
}
async getMonthlyRollups(limit = 24): Promise<ImmersionSessionRollupRow[]> {
return getMonthlyRollups(this.db, limit);
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const normalizedPath = normalizeMediaPath(mediaPath);
const normalizedTitle = normalizeText(mediaTitle);
this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
);
if (normalizedPath === this.currentMediaPathOrUrl) {
if (normalizedTitle && normalizedTitle !== this.currentVideoKey) {
this.currentVideoKey = normalizedTitle;
this.updateVideoTitleForActiveSession(normalizedTitle);
this.logger.debug('Media title updated for existing session');
} else {
this.logger.debug('Media change ignored; path unchanged');
}
return;
}
this.finalizeActiveSession();
this.currentMediaPathOrUrl = normalizedPath;
this.currentVideoKey = normalizedTitle;
if (!normalizedPath) {
this.logger.info('Media path cleared; immersion session tracking paused');
return;
}
const sourceType = isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL;
const videoKey = buildVideoKey(normalizedPath, sourceType);
const canonicalTitle = normalizedTitle || deriveCanonicalTitle(normalizedPath);
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null;
const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null;
const sessionInfo = {
videoId: getOrCreateVideoRecord(this.db, videoKey, {
canonicalTitle,
sourcePath,
sourceUrl,
sourceType,
}),
startedAtMs: Date.now(),
};
this.logger.info(
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
);
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
}
handleMediaTitleUpdate(mediaTitle: string | null): void {
if (!this.sessionState) return;
const normalizedTitle = normalizeText(mediaTitle);
if (!normalizedTitle) return;
this.currentVideoKey = normalizedTitle;
this.updateVideoTitleForActiveSession(normalizedTitle);
}
recordSubtitleLine(text: string, startSec: number, endSec: number): void {
if (!this.sessionState || !text.trim()) return;
const cleaned = normalizeText(text);
if (!cleaned) return;
const metrics = calculateTextMetrics(cleaned);
this.sessionState.currentLineIndex += 1;
this.sessionState.linesSeen += 1;
this.sessionState.wordsSeen += metrics.words;
this.sessionState.tokensSeen += metrics.tokens;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
lineIndex: this.sessionState.currentLineIndex,
segmentStartMs: secToMs(startSec),
segmentEndMs: secToMs(endSec),
wordsDelta: metrics.words,
cardsDelta: 0,
eventType: EVENT_SUBTITLE_LINE,
payloadJson: sanitizePayload(
{
event: 'subtitle-line',
text: cleaned,
words: metrics.words,
},
this.maxPayloadBytes,
),
});
}
recordPlaybackPosition(mediaTimeSec: number | null): void {
if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) {
return;
}
const nowMs = Date.now();
const mediaMs = Math.round(mediaTimeSec * 1000);
if (this.sessionState.lastWallClockMs <= 0) {
this.sessionState.lastWallClockMs = nowMs;
this.sessionState.lastMediaMs = mediaMs;
return;
}
const wallDeltaMs = nowMs - this.sessionState.lastWallClockMs;
if (wallDeltaMs > 0 && wallDeltaMs < 60_000) {
this.sessionState.totalWatchedMs += wallDeltaMs;
if (!this.sessionState.isPaused) {
this.sessionState.activeWatchedMs += wallDeltaMs;
}
}
if (this.sessionState.lastMediaMs !== null) {
const mediaDeltaMs = mediaMs - this.sessionState.lastMediaMs;
if (Math.abs(mediaDeltaMs) >= 1_000) {
if (mediaDeltaMs > 0) {
this.sessionState.seekForwardCount += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_SEEK_FORWARD,
wordsDelta: 0,
cardsDelta: 0,
segmentStartMs: this.sessionState.lastMediaMs,
segmentEndMs: mediaMs,
payloadJson: sanitizePayload(
{
fromMs: this.sessionState.lastMediaMs,
toMs: mediaMs,
},
this.maxPayloadBytes,
),
});
} else if (mediaDeltaMs < 0) {
this.sessionState.seekBackwardCount += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_SEEK_BACKWARD,
wordsDelta: 0,
cardsDelta: 0,
segmentStartMs: this.sessionState.lastMediaMs,
segmentEndMs: mediaMs,
payloadJson: sanitizePayload(
{
fromMs: this.sessionState.lastMediaMs,
toMs: mediaMs,
},
this.maxPayloadBytes,
),
});
}
}
}
this.sessionState.lastWallClockMs = nowMs;
this.sessionState.lastMediaMs = mediaMs;
this.sessionState.pendingTelemetry = true;
}
recordPauseState(isPaused: boolean): void {
if (!this.sessionState) return;
if (this.sessionState.isPaused === isPaused) return;
const nowMs = Date.now();
this.sessionState.isPaused = isPaused;
if (isPaused) {
this.sessionState.lastPauseStartMs = nowMs;
this.sessionState.pauseCount += 1;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_PAUSE_START,
cardsDelta: 0,
wordsDelta: 0,
payloadJson: sanitizePayload({ paused: true }, this.maxPayloadBytes),
});
} else {
if (this.sessionState.lastPauseStartMs) {
const pauseMs = Math.max(0, nowMs - this.sessionState.lastPauseStartMs);
this.sessionState.pauseMs += pauseMs;
this.sessionState.lastPauseStartMs = null;
}
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_PAUSE_END,
cardsDelta: 0,
wordsDelta: 0,
payloadJson: sanitizePayload({ paused: false }, this.maxPayloadBytes),
});
}
this.sessionState.pendingTelemetry = true;
}
recordLookup(hit: boolean): void {
if (!this.sessionState) return;
this.sessionState.lookupCount += 1;
if (hit) {
this.sessionState.lookupHits += 1;
}
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_LOOKUP,
cardsDelta: 0,
wordsDelta: 0,
payloadJson: sanitizePayload(
{
hit,
},
this.maxPayloadBytes,
),
});
}
recordCardsMined(count = 1): void {
if (!this.sessionState) return;
this.sessionState.cardsMined += count;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_CARD_MINED,
wordsDelta: 0,
cardsDelta: count,
payloadJson: sanitizePayload({ cardsMined: count }, this.maxPayloadBytes),
});
}
recordMediaBufferEvent(): void {
if (!this.sessionState) return;
this.sessionState.mediaBufferEvents += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_MEDIA_BUFFER,
cardsDelta: 0,
wordsDelta: 0,
payloadJson: sanitizePayload(
{
buffer: true,
},
this.maxPayloadBytes,
),
});
}
private recordWrite(write: QueuedWrite): void {
if (this.isDestroyed) return;
const { dropped } = enqueueWrite(this.queue, write, this.queueCap);
if (dropped > 0) {
this.droppedWriteCount += dropped;
this.logger.warn(`Immersion tracker queue overflow; dropped ${dropped} oldest writes`);
}
if (write.kind === 'event' || this.queue.length >= this.batchSize) {
this.scheduleFlush(0);
}
}
private flushTelemetry(force = false): void {
if (!this.sessionState || (!force && !this.sessionState.pendingTelemetry)) {
return;
}
this.recordWrite({
kind: 'telemetry',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
totalWatchedMs: this.sessionState.totalWatchedMs,
activeWatchedMs: this.sessionState.activeWatchedMs,
linesSeen: this.sessionState.linesSeen,
wordsSeen: this.sessionState.wordsSeen,
tokensSeen: this.sessionState.tokensSeen,
cardsMined: this.sessionState.cardsMined,
lookupCount: this.sessionState.lookupCount,
lookupHits: this.sessionState.lookupHits,
pauseCount: this.sessionState.pauseCount,
pauseMs: this.sessionState.pauseMs,
seekForwardCount: this.sessionState.seekForwardCount,
seekBackwardCount: this.sessionState.seekBackwardCount,
mediaBufferEvents: this.sessionState.mediaBufferEvents,
});
this.sessionState.pendingTelemetry = false;
}
private scheduleFlush(delayMs = this.flushIntervalMs): void {
if (this.flushScheduled || this.writeLock.locked) return;
this.flushScheduled = true;
this.flushTimer = setTimeout(() => {
this.flushScheduled = false;
this.flushNow();
}, delayMs);
}
private flushNow(): void {
if (this.writeLock.locked || this.isDestroyed) return;
if (this.queue.length === 0) {
this.flushScheduled = false;
return;
}
this.flushTelemetry();
if (this.queue.length === 0) {
this.flushScheduled = false;
return;
}
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length));
this.writeLock.locked = true;
try {
this.db.exec('BEGIN IMMEDIATE');
for (const write of batch) {
this.flushSingle(write);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
this.queue.unshift(...batch);
this.logger.warn('Immersion tracker flush failed, retrying later', error as Error);
} finally {
this.writeLock.locked = false;
this.flushScheduled = false;
if (this.queue.length > 0) {
this.scheduleFlush(this.flushIntervalMs);
}
}
}
private flushSingle(write: QueuedWrite): void {
executeQueuedWrite(write, this.preparedStatements);
}
private scheduleMaintenance(): void {
this.maintenanceTimer = setInterval(() => {
this.runMaintenance();
}, this.maintenanceIntervalMs);
this.runMaintenance();
}
private runMaintenance(): void {
if (this.isDestroyed) return;
try {
this.flushTelemetry(true);
this.flushNow();
const nowMs = Date.now();
pruneRetention(this.db, nowMs, {
eventsRetentionMs: this.eventsRetentionMs,
telemetryRetentionMs: this.telemetryRetentionMs,
dailyRollupRetentionMs: this.dailyRollupRetentionMs,
monthlyRollupRetentionMs: this.monthlyRollupRetentionMs,
});
this.runRollupMaintenance();
if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
this.db.exec('VACUUM');
this.lastVacuumMs = nowMs;
}
} catch (error) {
this.logger.warn(
'Immersion tracker maintenance failed, will retry later',
(error as Error).message,
);
}
}
private runRollupMaintenance(): void {
runRollupMaintenance(this.db);
}
private startSession(videoId: number, startedAtMs?: number): void {
const { sessionId, state } = startSessionRecord(this.db, videoId, startedAtMs);
this.sessionState = state;
this.recordWrite({
kind: 'telemetry',
sessionId,
sampleMs: state.startedAtMs,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 0,
wordsSeen: 0,
tokensSeen: 0,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
});
this.scheduleFlush(0);
}
private finalizeActiveSession(): void {
if (!this.sessionState) return;
const endedAt = Date.now();
if (this.sessionState.lastPauseStartMs) {
this.sessionState.pauseMs += Math.max(0, endedAt - this.sessionState.lastPauseStartMs);
this.sessionState.lastPauseStartMs = null;
}
const finalWallNow = endedAt;
if (this.sessionState.lastWallClockMs > 0) {
const wallDelta = finalWallNow - this.sessionState.lastWallClockMs;
if (wallDelta > 0 && wallDelta < 60_000) {
this.sessionState.totalWatchedMs += wallDelta;
if (!this.sessionState.isPaused) {
this.sessionState.activeWatchedMs += wallDelta;
}
}
}
this.flushTelemetry(true);
this.flushNow();
this.sessionState.pendingTelemetry = false;
finalizeSessionRecord(this.db, this.sessionState, endedAt);
this.sessionState = null;
}
private captureVideoMetadataAsync(videoId: number, sourceType: number, mediaPath: string): void {
if (sourceType !== SOURCE_TYPE_LOCAL) return;
void (async () => {
try {
const metadata = await getLocalVideoMetadata(mediaPath);
updateVideoMetadataRecord(this.db, videoId, metadata);
} catch (error) {
this.logger.warn('Unable to capture local video metadata', (error as Error).message);
}
})();
}
private updateVideoTitleForActiveSession(canonicalTitle: string): void {
if (!this.sessionState) return;
updateVideoTitleRecord(this.db, this.sessionState.videoId, canonicalTitle);
}
}

View File

@@ -0,0 +1,90 @@
import type { DatabaseSync } from 'node:sqlite';
export function toMonthKey(timestampMs: number): number {
const monthDate = new Date(timestampMs);
return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1;
}
export function pruneRetention(
db: DatabaseSync,
nowMs: number,
policy: {
eventsRetentionMs: number;
telemetryRetentionMs: number;
dailyRollupRetentionMs: number;
monthlyRollupRetentionMs: number;
},
): void {
const eventCutoff = nowMs - policy.eventsRetentionMs;
const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
const dailyCutoff = nowMs - policy.dailyRollupRetentionMs;
const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs;
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
const monthCutoff = toMonthKey(monthlyCutoff);
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run(
telemetryCutoff,
);
}
export function runRollupMaintenance(db: DatabaseSync): void {
db.exec(`
INSERT OR REPLACE INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_words_seen, total_tokens_seen, total_cards, cards_per_hour,
words_per_min, lookup_hit_rate
)
SELECT
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
COALESCE(SUM(t.cards_mined), 0) AS total_cards,
CASE
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
ELSE NULL
END AS cards_per_hour,
CASE
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
ELSE NULL
END AS words_per_min,
CASE
WHEN COALESCE(SUM(t.lookup_count), 0) > 0
THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL)
ELSE NULL
END AS lookup_hit_rate
FROM imm_sessions s
JOIN imm_session_telemetry t
ON t.session_id = s.session_id
GROUP BY rollup_day, s.video_id
`);
db.exec(`
INSERT OR REPLACE INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_words_seen, total_tokens_seen, total_cards
)
SELECT
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
COALESCE(SUM(t.cards_mined), 0) AS total_cards
FROM imm_sessions s
JOIN imm_session_telemetry t
ON t.session_id = s.session_id
GROUP BY rollup_month, s.video_id
`);
}

View File

@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import type { spawn as spawnFn } from 'node:child_process';
import { SOURCE_TYPE_LOCAL } from './types';
import { getLocalVideoMetadata, runFfprobe } from './metadata';
type Spawn = typeof spawnFn;
function createSpawnStub(options: {
stdout?: string;
stderr?: string;
emitError?: boolean;
}): Spawn {
return (() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
queueMicrotask(() => {
if (options.emitError) {
child.emit('error', new Error('ffprobe failed'));
return;
}
if (options.stderr) {
child.stderr.emit('data', Buffer.from(options.stderr));
}
if (options.stdout !== undefined) {
child.stdout.emit('data', Buffer.from(options.stdout));
}
child.emit('close', 0);
});
return child as unknown as ReturnType<Spawn>;
}) as Spawn;
}
test('runFfprobe parses valid JSON from stream and format sections', async () => {
const metadata = await runFfprobe('/tmp/video.mp4', {
spawn: createSpawnStub({
stdout: JSON.stringify({
format: { duration: '12.34', bit_rate: '3456000' },
streams: [
{
codec_type: 'video',
codec_tag_string: 'avc1',
width: 1920,
height: 1080,
avg_frame_rate: '24000/1001',
},
{
codec_type: 'audio',
codec_tag_string: 'mp4a',
},
],
}),
}),
});
assert.equal(metadata.durationMs, 12340);
assert.equal(metadata.bitrateKbps, 3456);
assert.equal(metadata.widthPx, 1920);
assert.equal(metadata.heightPx, 1080);
assert.equal(metadata.fpsX100, 2398);
assert.equal(metadata.containerId, 0);
assert.ok(Number(metadata.codecId) > 0);
assert.ok(Number(metadata.audioCodecId) > 0);
});
test('runFfprobe returns empty metadata for invalid JSON and process errors', async () => {
const invalidJsonMetadata = await runFfprobe('/tmp/broken.mp4', {
spawn: createSpawnStub({ stdout: '{invalid' }),
});
assert.deepEqual(invalidJsonMetadata, {
durationMs: null,
codecId: null,
containerId: null,
widthPx: null,
heightPx: null,
fpsX100: null,
bitrateKbps: null,
audioCodecId: null,
});
const errorMetadata = await runFfprobe('/tmp/error.mp4', {
spawn: createSpawnStub({ emitError: true }),
});
assert.deepEqual(errorMetadata, {
durationMs: null,
codecId: null,
containerId: null,
widthPx: null,
heightPx: null,
fpsX100: null,
bitrateKbps: null,
audioCodecId: null,
});
});
test('getLocalVideoMetadata derives title and falls back to null hash on read errors', async () => {
const successMetadata = await getLocalVideoMetadata('/tmp/Episode 01.mkv', {
spawn: createSpawnStub({ stdout: JSON.stringify({ format: { duration: '0' }, streams: [] }) }),
fs: {
createReadStream: () => {
const stream = new EventEmitter();
queueMicrotask(() => {
stream.emit('data', Buffer.from('hello world'));
stream.emit('end');
});
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
},
promises: {
stat: (async () => ({ size: 1234 }) as unknown) as typeof import('node:fs').promises.stat,
},
} as never,
});
assert.equal(successMetadata.sourceType, SOURCE_TYPE_LOCAL);
assert.equal(successMetadata.canonicalTitle, 'Episode 01');
assert.equal(successMetadata.fileSizeBytes, 1234);
assert.equal(
successMetadata.hashSha256,
createHash('sha256').update('hello world').digest('hex'),
);
const hashFallbackMetadata = await getLocalVideoMetadata('/tmp/Episode 02.mkv', {
spawn: createSpawnStub({ stdout: JSON.stringify({ format: {}, streams: [] }) }),
fs: {
createReadStream: () => {
const stream = new EventEmitter();
queueMicrotask(() => {
stream.emit('error', new Error('read failed'));
});
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
},
promises: {
stat: (async () => ({ size: 5678 }) as unknown) as typeof import('node:fs').promises.stat,
},
} as never,
});
assert.equal(hashFallbackMetadata.canonicalTitle, 'Episode 02');
assert.equal(hashFallbackMetadata.hashSha256, null);
});

View File

@@ -0,0 +1,153 @@
import crypto from 'node:crypto';
import { spawn as nodeSpawn } from 'node:child_process';
import * as fs from 'node:fs';
import {
deriveCanonicalTitle,
emptyMetadata,
hashToCode,
parseFps,
toNullableInt,
} from './reducer';
import { SOURCE_TYPE_LOCAL, type ProbeMetadata, type VideoMetadata } from './types';
type SpawnFn = typeof nodeSpawn;
interface FsDeps {
createReadStream: typeof fs.createReadStream;
promises: {
stat: typeof fs.promises.stat;
};
}
interface MetadataDeps {
spawn?: SpawnFn;
fs?: FsDeps;
}
export async function computeSha256(
mediaPath: string,
deps: MetadataDeps = {},
): Promise<string | null> {
const fileSystem = deps.fs ?? fs;
return new Promise((resolve) => {
const file = fileSystem.createReadStream(mediaPath);
const digest = crypto.createHash('sha256');
file.on('data', (chunk) => digest.update(chunk));
file.on('end', () => resolve(digest.digest('hex')));
file.on('error', () => resolve(null));
});
}
export function runFfprobe(mediaPath: string, deps: MetadataDeps = {}): Promise<ProbeMetadata> {
const spawn = deps.spawn ?? nodeSpawn;
return new Promise((resolve) => {
const child = spawn('ffprobe', [
'-v',
'error',
'-print_format',
'json',
'-show_entries',
'stream=codec_type,codec_tag_string,width,height,avg_frame_rate,bit_rate',
'-show_entries',
'format=duration,bit_rate',
mediaPath,
]);
let output = '';
let errorOutput = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString('utf-8');
});
child.stderr.on('data', (chunk) => {
errorOutput += chunk.toString('utf-8');
});
child.on('error', () => resolve(emptyMetadata()));
child.on('close', () => {
if (errorOutput && output.length === 0) {
resolve(emptyMetadata());
return;
}
try {
const parsed = JSON.parse(output) as {
format?: { duration?: string; bit_rate?: string };
streams?: Array<{
codec_type?: string;
codec_tag_string?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
bit_rate?: string;
}>;
};
const durationText = parsed.format?.duration;
const bitrateText = parsed.format?.bit_rate;
const durationMs = Number(durationText) ? Math.round(Number(durationText) * 1000) : null;
const bitrateKbps = Number(bitrateText) ? Math.round(Number(bitrateText) / 1000) : null;
let codecId: number | null = null;
let containerId: number | null = null;
let widthPx: number | null = null;
let heightPx: number | null = null;
let fpsX100: number | null = null;
let audioCodecId: number | null = null;
for (const stream of parsed.streams ?? []) {
if (stream.codec_type === 'video') {
widthPx = toNullableInt(stream.width);
heightPx = toNullableInt(stream.height);
fpsX100 = parseFps(stream.avg_frame_rate);
codecId = hashToCode(stream.codec_tag_string);
containerId = 0;
}
if (stream.codec_type === 'audio') {
audioCodecId = hashToCode(stream.codec_tag_string);
if (audioCodecId && audioCodecId > 0) {
break;
}
}
}
resolve({
durationMs,
codecId,
containerId,
widthPx,
heightPx,
fpsX100,
bitrateKbps,
audioCodecId,
});
} catch {
resolve(emptyMetadata());
}
});
});
}
export async function getLocalVideoMetadata(
mediaPath: string,
deps: MetadataDeps = {},
): Promise<VideoMetadata> {
const fileSystem = deps.fs ?? fs;
const hash = await computeSha256(mediaPath, deps);
const info = await runFfprobe(mediaPath, deps);
const stat = await fileSystem.promises.stat(mediaPath);
return {
sourceType: SOURCE_TYPE_LOCAL,
canonicalTitle: deriveCanonicalTitle(mediaPath),
durationMs: info.durationMs || 0,
fileSizeBytes: Number.isFinite(stat.size) ? stat.size : null,
codecId: info.codecId ?? null,
containerId: info.containerId ?? null,
widthPx: info.widthPx ?? null,
heightPx: info.heightPx ?? null,
fpsX100: info.fpsX100 ?? null,
bitrateKbps: info.bitrateKbps ?? null,
audioCodecId: info.audioCodecId ?? null,
hashSha256: hash,
screenshotPath: null,
metadataJson: null,
};
}

View File

@@ -0,0 +1,104 @@
import type { DatabaseSync } from 'node:sqlite';
import type {
ImmersionSessionRollupRow,
SessionSummaryQueryRow,
SessionTimelineRow,
} from './types';
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
const prepared = db.prepare(`
SELECT
s.video_id AS videoId,
s.started_at_ms AS startedAtMs,
s.ended_at_ms AS endedAtMs,
COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs,
COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs,
COALESCE(SUM(t.lines_seen), 0) AS linesSeen,
COALESCE(SUM(t.words_seen), 0) AS wordsSeen,
COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen,
COALESCE(SUM(t.cards_mined), 0) AS cardsMined,
COALESCE(SUM(t.lookup_count), 0) AS lookupCount,
COALESCE(SUM(t.lookup_hits), 0) AS lookupHits
FROM imm_sessions s
LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id
GROUP BY s.session_id
ORDER BY s.started_at_ms DESC
LIMIT ?
`);
return prepared.all(limit) as unknown as SessionSummaryQueryRow[];
}
export function getSessionTimeline(
db: DatabaseSync,
sessionId: number,
limit = 200,
): SessionTimelineRow[] {
const prepared = db.prepare(`
SELECT
sample_ms AS sampleMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
words_seen AS wordsSeen,
tokens_seen AS tokensSeen,
cards_mined AS cardsMined
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC
LIMIT ?
`);
return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[];
}
export function getQueryHints(db: DatabaseSync): {
totalSessions: number;
activeSessions: number;
} {
const sessions = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions');
const active = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL');
const totalSessions = Number(sessions.get()?.total ?? 0);
const activeSessions = Number(active.get()?.total ?? 0);
return { totalSessions, activeSessions };
}
export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] {
const prepared = db.prepare(`
SELECT
rollup_day AS rollupDayOrMonth,
video_id AS videoId,
total_sessions AS totalSessions,
total_active_min AS totalActiveMin,
total_lines_seen AS totalLinesSeen,
total_words_seen AS totalWordsSeen,
total_tokens_seen AS totalTokensSeen,
total_cards AS totalCards,
cards_per_hour AS cardsPerHour,
words_per_min AS wordsPerMin,
lookup_hit_rate AS lookupHitRate
FROM imm_daily_rollups
ORDER BY rollup_day DESC, video_id DESC
LIMIT ?
`);
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
}
export function getMonthlyRollups(db: DatabaseSync, limit = 24): ImmersionSessionRollupRow[] {
const prepared = db.prepare(`
SELECT
rollup_month AS rollupDayOrMonth,
video_id AS videoId,
total_sessions AS totalSessions,
total_active_min AS totalActiveMin,
total_lines_seen AS totalLinesSeen,
total_words_seen AS totalWordsSeen,
total_tokens_seen AS totalTokensSeen,
total_cards AS totalCards,
0 AS cardsPerHour,
0 AS wordsPerMin,
0 AS lookupHitRate
FROM imm_monthly_rollups
ORDER BY rollup_month DESC, video_id DESC
LIMIT ?
`);
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
}

View File

@@ -0,0 +1,19 @@
import type { QueuedWrite } from './types';
export function enqueueWrite(
queue: QueuedWrite[],
write: QueuedWrite,
queueCap: number,
): {
dropped: number;
queueLength: number;
} {
let dropped = 0;
if (queue.length >= queueCap) {
const overflow = queue.length - queueCap + 1;
queue.splice(0, overflow);
dropped = overflow;
}
queue.push(write);
return { dropped, queueLength: queue.length };
}

View File

@@ -0,0 +1,144 @@
import path from 'node:path';
import type { ProbeMetadata, SessionState } from './types';
import { SOURCE_TYPE_REMOTE } from './types';
export function createInitialSessionState(
sessionId: number,
videoId: number,
startedAtMs: number,
): SessionState {
return {
sessionId,
videoId,
startedAtMs,
currentLineIndex: 0,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 0,
wordsSeen: 0,
tokensSeen: 0,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
pauseCount: 0,
pauseMs: 0,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
lastWallClockMs: 0,
lastMediaMs: null,
lastPauseStartMs: null,
isPaused: false,
pendingTelemetry: true,
};
}
export function resolveBoundedInt(
value: number | undefined,
fallback: number,
min: number,
max: number,
): number {
if (!Number.isFinite(value)) return fallback;
const candidate = Math.floor(value as number);
if (candidate < min || candidate > max) return fallback;
return candidate;
}
export function sanitizePayload(payload: Record<string, unknown>, maxPayloadBytes: number): string {
const json = JSON.stringify(payload);
return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true });
}
export function calculateTextMetrics(value: string): {
words: number;
tokens: number;
} {
const words = value.split(/\s+/).filter(Boolean).length;
const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0;
const tokens = Math.max(words, cjkCount);
return { words, tokens };
}
export function secToMs(seconds: number): number {
const coerced = Number(seconds);
if (!Number.isFinite(coerced)) return 0;
return Math.round(coerced * 1000);
}
export function normalizeMediaPath(mediaPath: string | null): string {
if (!mediaPath || !mediaPath.trim()) return '';
return mediaPath.trim();
}
export function normalizeText(value: string | null | undefined): string {
if (!value) return '';
return value.trim().replace(/\s+/g, ' ');
}
export function buildVideoKey(mediaPath: string, sourceType: number): string {
if (sourceType === SOURCE_TYPE_REMOTE) {
return `remote:${mediaPath}`;
}
return `local:${mediaPath}`;
}
export function isRemoteSource(mediaPath: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
}
export function deriveCanonicalTitle(mediaPath: string): string {
if (isRemoteSource(mediaPath)) {
try {
const parsed = new URL(mediaPath);
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length > 0) {
const leaf = decodeURIComponent(parts[parts.length - 1]!);
return normalizeText(leaf.replace(/\.[^/.]+$/, ''));
}
return normalizeText(parsed.hostname) || 'unknown';
} catch {
return normalizeText(mediaPath);
}
}
const filename = path.basename(mediaPath);
return normalizeText(filename.replace(/\.[^/.]+$/, ''));
}
export function parseFps(value?: string): number | null {
if (!value || typeof value !== 'string') return null;
const [num, den] = value.split('/');
const n = Number(num);
const d = Number(den);
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
const fps = n / d;
return Number.isFinite(fps) ? Math.round(fps * 100) : null;
}
export function hashToCode(input?: string): number | null {
if (!input) return null;
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff;
}
return hash || null;
}
export function emptyMetadata(): ProbeMetadata {
return {
durationMs: null,
codecId: null,
containerId: null,
widthPx: null,
heightPx: null,
fpsX100: null,
bitrateKbps: null,
audioCodecId: null,
};
}
export function toNullableInt(value: number | null | undefined): number | null {
if (value === null || value === undefined || !Number.isFinite(value)) return null;
return value;
}

View File

@@ -0,0 +1,37 @@
import crypto from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import { createInitialSessionState } from './reducer';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } from './types';
export function startSessionRecord(
db: DatabaseSync,
videoId: number,
startedAtMs = Date.now(),
): { sessionId: number; state: SessionState } {
const sessionUuid = crypto.randomUUID();
const result = db
.prepare(
`
INSERT INTO imm_sessions (
session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs);
const sessionId = Number(result.lastInsertRowid);
return {
sessionId,
state: createInitialSessionState(sessionId, videoId, startedAtMs),
};
}
export function finalizeSessionRecord(
db: DatabaseSync,
sessionState: SessionState,
endedAtMs = Date.now(),
): void {
db.prepare(
'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?',
).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId);
}

View File

@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite';
import { finalizeSessionRecord, startSessionRecord } from './session';
import {
createTrackerPreparedStatements,
ensureSchema,
executeQueuedWrite,
getOrCreateVideoRecord,
} from './storage';
import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types';
type DatabaseSyncCtor = typeof NodeDatabaseSync;
const DatabaseSync: DatabaseSyncCtor | null = (() => {
try {
return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null;
} catch {
return null;
}
})();
const testIfSqlite = DatabaseSync ? test : test.skip;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
testIfSqlite('ensureSchema creates immersion core tables', () => {
const dbPath = makeDbPath();
const db = new DatabaseSync!(dbPath);
try {
ensureSchema(db);
const rows = db
.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
)
.all() as Array<{ name: string }>;
const tableNames = new Set(rows.map((row) => row.name));
assert.ok(tableNames.has('imm_videos'));
assert.ok(tableNames.has('imm_sessions'));
assert.ok(tableNames.has('imm_session_telemetry'));
assert.ok(tableNames.has('imm_session_events'));
assert.ok(tableNames.has('imm_daily_rollups'));
assert.ok(tableNames.has('imm_monthly_rollups'));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
testIfSqlite('start/finalize session updates ended_at and status', () => {
const dbPath = makeDbPath();
const db = new DatabaseSync!(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a.mkv', {
canonicalTitle: 'Slice A Episode',
sourcePath: '/tmp/slice-a.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const startedAtMs = 1_234_567_000;
const endedAtMs = startedAtMs + 8_500;
const { sessionId, state } = startSessionRecord(db, videoId, startedAtMs);
finalizeSessionRecord(db, state, endedAtMs);
const row = db
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
.get(sessionId) as {
ended_at_ms: number | null;
status: number;
} | null;
assert.ok(row);
assert.equal(row?.ended_at_ms, endedAtMs);
assert.equal(row?.status, SESSION_STATUS_ENDED);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
testIfSqlite('executeQueuedWrite inserts event and telemetry rows', () => {
const dbPath = makeDbPath();
const db = new DatabaseSync!(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a-events.mkv', {
canonicalTitle: 'Slice A Events',
sourcePath: '/tmp/slice-a-events.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 5_000);
executeQueuedWrite(
{
kind: 'telemetry',
sessionId,
sampleMs: 6_000,
totalWatchedMs: 1_000,
activeWatchedMs: 900,
linesSeen: 3,
wordsSeen: 6,
tokensSeen: 6,
cardsMined: 1,
lookupCount: 2,
lookupHits: 1,
pauseCount: 1,
pauseMs: 50,
seekForwardCount: 0,
seekBackwardCount: 0,
mediaBufferEvents: 0,
},
stmts,
);
executeQueuedWrite(
{
kind: 'event',
sessionId,
sampleMs: 6_100,
eventType: EVENT_SUBTITLE_LINE,
lineIndex: 1,
segmentStartMs: 0,
segmentEndMs: 800,
wordsDelta: 2,
cardsDelta: 0,
payloadJson: '{"event":"subtitle-line"}',
},
stmts,
);
const telemetryCount = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?')
.get(sessionId) as { total: number };
const eventCount = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_events WHERE session_id = ?')
.get(sessionId) as { total: number };
assert.equal(telemetryCount.total, 1);
assert.equal(eventCount.total, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});

View File

@@ -0,0 +1,328 @@
import type { DatabaseSync } from 'node:sqlite';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata } from './types';
export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
}
export function applyPragmas(db: DatabaseSync): void {
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 2500');
}
export function ensureSchema(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version (
schema_version INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL
);
`);
const currentVersion = db
.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) {
return;
}
db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL,
source_type INTEGER NOT NULL,
source_path TEXT,
source_url TEXT,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
codec_id INTEGER, container_id INTEGER,
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_sessions(
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE,
video_id INTEGER NOT NULL,
started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER,
status INTEGER NOT NULL,
locale_id INTEGER, target_lang_id INTEGER,
difficulty_tier INTEGER, subtitle_mode INTEGER,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_session_telemetry(
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
sample_ms INTEGER NOT NULL,
total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen INTEGER NOT NULL DEFAULT 0,
words_seen INTEGER NOT NULL DEFAULT 0,
tokens_seen INTEGER NOT NULL DEFAULT 0,
cards_mined INTEGER NOT NULL DEFAULT 0,
lookup_count INTEGER NOT NULL DEFAULT 0,
lookup_hits INTEGER NOT NULL DEFAULT 0,
pause_count INTEGER NOT NULL DEFAULT 0,
pause_ms INTEGER NOT NULL DEFAULT 0,
seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_session_events(
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
ts_ms INTEGER NOT NULL,
event_type INTEGER NOT NULL,
line_index INTEGER,
segment_start_ms INTEGER,
segment_end_ms INTEGER,
words_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_daily_rollups(
rollup_day INTEGER NOT NULL,
video_id INTEGER,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_min REAL NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_words_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
cards_per_hour REAL,
words_per_min REAL,
lookup_hit_rate REAL,
PRIMARY KEY (rollup_day, video_id)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_monthly_rollups(
rollup_month INTEGER NOT NULL,
video_id INTEGER,
total_sessions INTEGER NOT NULL DEFAULT 0,
total_active_min REAL NOT NULL DEFAULT 0,
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_words_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (rollup_month, video_id)
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_video_started
ON imm_sessions(video_id, started_at_ms DESC)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_status_started
ON imm_sessions(status, started_at_ms DESC)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_telemetry_session_sample
ON imm_session_telemetry(session_id, sample_ms DESC)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_events_session_ts
ON imm_session_events(session_id, ts_ms DESC)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_events_type_ts
ON imm_session_events(event_type, ts_ms DESC)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_rollups_day_video
ON imm_daily_rollups(rollup_day, video_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_rollups_month_video
ON imm_monthly_rollups(rollup_month, video_id)
`);
db.exec(`
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
VALUES (${SCHEMA_VERSION}, ${Date.now()})
ON CONFLICT DO NOTHING
`);
}
export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements {
return {
telemetryInsertStmt: db.prepare(`
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms,
lines_seen, words_seen, tokens_seen, cards_mined, lookup_count,
lookup_hits, pause_count, pause_ms, seek_forward_count,
seek_backward_count, media_buffer_events
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`),
eventInsertStmt: db.prepare(`
INSERT INTO imm_session_events (
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
words_delta, cards_delta, payload_json
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?
)
`),
};
}
export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
if (write.kind === 'telemetry') {
stmts.telemetryInsertStmt.run(
write.sessionId,
write.sampleMs!,
write.totalWatchedMs!,
write.activeWatchedMs!,
write.linesSeen!,
write.wordsSeen!,
write.tokensSeen!,
write.cardsMined!,
write.lookupCount!,
write.lookupHits!,
write.pauseCount!,
write.pauseMs!,
write.seekForwardCount!,
write.seekBackwardCount!,
write.mediaBufferEvents!,
);
return;
}
stmts.eventInsertStmt.run(
write.sessionId,
write.sampleMs!,
write.eventType!,
write.lineIndex ?? null,
write.segmentStartMs ?? null,
write.segmentEndMs ?? null,
write.wordsDelta ?? 0,
write.cardsDelta ?? 0,
write.payloadJson ?? null,
);
}
export function getOrCreateVideoRecord(
db: DatabaseSync,
videoKey: string,
details: {
canonicalTitle: string;
sourcePath: string | null;
sourceUrl: string | null;
sourceType: number;
},
): number {
const existing = db
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
.get(videoKey) as { video_id: number } | null;
if (existing?.video_id) {
db.prepare(
'UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?',
).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
return existing.video_id;
}
const nowMs = Date.now();
const insert = db.prepare(`
INSERT INTO imm_videos (
video_key, canonical_title, source_type, source_path, source_url,
duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px,
fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path,
metadata_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = insert.run(
videoKey,
details.canonicalTitle || 'unknown',
details.sourceType,
details.sourcePath,
details.sourceUrl,
0,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
nowMs,
nowMs,
);
return Number(result.lastInsertRowid);
}
export function updateVideoMetadataRecord(
db: DatabaseSync,
videoId: number,
metadata: VideoMetadata,
): void {
db.prepare(
`
UPDATE imm_videos
SET
duration_ms = ?,
file_size_bytes = ?,
codec_id = ?,
container_id = ?,
width_px = ?,
height_px = ?,
fps_x100 = ?,
bitrate_kbps = ?,
audio_codec_id = ?,
hash_sha256 = ?,
screenshot_path = ?,
metadata_json = ?,
updated_at_ms = ?
WHERE video_id = ?
`,
).run(
metadata.durationMs,
metadata.fileSizeBytes,
metadata.codecId,
metadata.containerId,
metadata.widthPx,
metadata.heightPx,
metadata.fpsX100,
metadata.bitrateKbps,
metadata.audioCodecId,
metadata.hashSha256,
metadata.screenshotPath,
metadata.metadataJson,
Date.now(),
videoId,
);
}
export function updateVideoTitleRecord(
db: DatabaseSync,
videoId: number,
canonicalTitle: string,
): void {
db.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?').run(
canonicalTitle,
Date.now(),
videoId,
);
}

View File

@@ -0,0 +1,167 @@
export const SCHEMA_VERSION = 1;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
export const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS;
export const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS;
export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
export const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
export const DEFAULT_MAX_PAYLOAD_BYTES = 256;
export const SOURCE_TYPE_LOCAL = 1;
export const SOURCE_TYPE_REMOTE = 2;
export const SESSION_STATUS_ACTIVE = 1;
export const SESSION_STATUS_ENDED = 2;
export const EVENT_SUBTITLE_LINE = 1;
export const EVENT_MEDIA_BUFFER = 2;
export const EVENT_LOOKUP = 3;
export const EVENT_CARD_MINED = 4;
export const EVENT_SEEK_FORWARD = 5;
export const EVENT_SEEK_BACKWARD = 6;
export const EVENT_PAUSE_START = 7;
export const EVENT_PAUSE_END = 8;
export interface ImmersionTrackerOptions {
dbPath: string;
policy?: ImmersionTrackerPolicy;
}
export interface ImmersionTrackerPolicy {
queueCap?: number;
batchSize?: number;
flushIntervalMs?: number;
maintenanceIntervalMs?: number;
payloadCapBytes?: number;
retention?: {
eventsDays?: number;
telemetryDays?: number;
dailyRollupsDays?: number;
monthlyRollupsDays?: number;
vacuumIntervalDays?: number;
};
}
export interface TelemetryAccumulator {
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
cardsMined: number;
lookupCount: number;
lookupHits: number;
pauseCount: number;
pauseMs: number;
seekForwardCount: number;
seekBackwardCount: number;
mediaBufferEvents: number;
}
export interface SessionState extends TelemetryAccumulator {
sessionId: number;
videoId: number;
startedAtMs: number;
currentLineIndex: number;
lastWallClockMs: number;
lastMediaMs: number | null;
lastPauseStartMs: number | null;
isPaused: boolean;
pendingTelemetry: boolean;
}
export interface QueuedWrite {
kind: 'telemetry' | 'event';
sessionId: number;
sampleMs?: number;
totalWatchedMs?: number;
activeWatchedMs?: number;
linesSeen?: number;
wordsSeen?: number;
tokensSeen?: number;
cardsMined?: number;
lookupCount?: number;
lookupHits?: number;
pauseCount?: number;
pauseMs?: number;
seekForwardCount?: number;
seekBackwardCount?: number;
mediaBufferEvents?: number;
eventType?: number;
lineIndex?: number | null;
segmentStartMs?: number | null;
segmentEndMs?: number | null;
wordsDelta?: number;
cardsDelta?: number;
payloadJson?: string | null;
}
export interface VideoMetadata {
sourceType: number;
canonicalTitle: string;
durationMs: number;
fileSizeBytes: number | null;
codecId: number | null;
containerId: number | null;
widthPx: number | null;
heightPx: number | null;
fpsX100: number | null;
bitrateKbps: number | null;
audioCodecId: number | null;
hashSha256: string | null;
screenshotPath: string | null;
metadataJson: string | null;
}
export interface SessionSummaryQueryRow {
videoId: number | null;
startedAtMs: number;
endedAtMs: number | null;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
cardsMined: number;
lookupCount: number;
lookupHits: number;
}
export interface SessionTimelineRow {
sampleMs: number;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
cardsMined: number;
}
export interface ImmersionSessionRollupRow {
rollupDayOrMonth: number;
videoId: number | null;
totalSessions: number;
totalActiveMin: number;
totalLinesSeen: number;
totalWordsSeen: number;
totalTokensSeen: number;
totalCards: number;
cardsPerHour: number | null;
wordsPerMin: number | null;
lookupHitRate: number | null;
}
export interface ProbeMetadata {
durationMs: number | null;
codecId: number | null;
containerId: number | null;
widthPx: number | null;
heightPx: number | null;
fpsX100: number | null;
bitrateKbps: number | null;
audioCodecId: number | null;
}

112
src/core/services/index.ts Normal file
View File

@@ -0,0 +1,112 @@
export { Texthooker } from './texthooker';
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
export { registerGlobalShortcuts } from './shortcut';
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
export {
refreshOverlayShortcutsRuntime,
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export {
copyCurrentSubtitle,
handleMineSentenceDigit,
handleMultiCopyDigit,
markLastCardAsAudioCard,
mineSentenceCard,
triggerFieldGrouping,
updateLastCardFromClipboard,
} from './mining';
export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle';
export { cycleSecondarySubMode } from './subtitle-position';
export {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility,
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
getIgnoredPos1Entries,
JLPT_EXCLUDED_TERMS,
JLPT_IGNORED_MECAB_POS1,
JLPT_IGNORED_MECAB_POS1_ENTRIES,
JLPT_IGNORED_MECAB_POS1_LIST,
shouldIgnoreJlptByTerm,
shouldIgnoreJlptForMecabPos1,
} from './jlpt-token-filter';
export type { JlptIgnoredPos1Entry } from './jlpt-token-filter';
export { loadYomitanExtension } from './yomitan-extension-loader';
export {
getJimakuLanguagePreference,
getJimakuMaxEntryResults,
jimakuFetchJson,
resolveJimakuApiKey,
} from './jimaku';
export {
loadSubtitlePosition,
saveSubtitlePosition,
updateCurrentMediaPath,
} from './subtitle-position';
export {
createOverlayWindow,
enforceOverlayLayerOrder,
ensureOverlayWindowLevel,
updateOverlayWindowBounds,
} from './overlay-window';
export { initializeOverlayRuntime } from './overlay-runtime-init';
export {
setInvisibleOverlayVisible,
setVisibleOverlayVisible,
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from './overlay-visibility';
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MpvIpcClient,
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
resolveCurrentAudioStreamIndex,
sendMpvCommandRuntime,
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from './mpv';
export type { MpvRuntimeClientLike, MpvTrackProperty } from './mpv';
export {
applyMpvSubtitleRenderMetricsPatch,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
sanitizeMpvSubtitleRenderMetrics,
} from './mpv-render-metrics';
export { createOverlayContentMeasurementStore } from './overlay-content-measurement';
export { parseClipboardVideoPath } from './overlay-drop';
export { handleMpvCommandFromIpc } from './ipc-command';
export { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
export { createNumericShortcutRuntime } from './numeric-shortcut';
export { runStartupBootstrapRuntime } from './startup';
export { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from './subsync-runner';
export { registerAnkiJimakuIpcRuntime } from './anki-jimaku';
export { ImmersionTrackerService } from './immersion-tracker-service';
export {
authenticateWithPassword as authenticateWithPasswordRuntime,
listItems as listJellyfinItemsRuntime,
listLibraries as listJellyfinLibrariesRuntime,
listSubtitleTracks as listJellyfinSubtitleTracksRuntime,
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from './jellyfin';
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager';
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence';

View File

@@ -0,0 +1,92 @@
import {
RuntimeOptionApplyResult,
RuntimeOptionId,
SubsyncManualRunRequest,
SubsyncResult,
} from '../../types';
export interface HandleMpvCommandFromIpcOptions {
specialCommands: {
SUBSYNC_TRIGGER: string;
RUNTIME_OPTIONS_OPEN: string;
RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string;
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void;
mpvSendCommand: (command: (string | number)[]) => void;
isMpvConnected: () => boolean;
hasRuntimeOptionsManager: () => boolean;
}
export function handleMpvCommandFromIpc(
command: (string | number)[],
options: HandleMpvCommandFromIpcOptions,
): void {
const first = typeof command[0] === 'string' ? command[0] : '';
if (first === options.specialCommands.SUBSYNC_TRIGGER) {
options.triggerSubsyncFromConfig();
return;
}
if (first === options.specialCommands.RUNTIME_OPTIONS_OPEN) {
options.openRuntimeOptionsPalette();
return;
}
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(':');
const id = idToken as RuntimeOptionId;
const direction: 1 | -1 = directionToken === 'prev' ? -1 : 1;
const result = options.runtimeOptionsCycle(id, direction);
if (!result.ok && result.error) {
options.showMpvOsd(result.error);
}
return;
}
if (options.isMpvConnected()) {
if (first === options.specialCommands.REPLAY_SUBTITLE) {
options.mpvReplaySubtitle();
} else if (first === options.specialCommands.PLAY_NEXT_SUBTITLE) {
options.mpvPlayNextSubtitle();
} else {
options.mpvSendCommand(command);
}
}
}
export async function runSubsyncManualFromIpc(
request: SubsyncManualRunRequest,
options: {
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
runWithSpinner: (task: () => Promise<SubsyncResult>) => Promise<SubsyncResult>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
},
): Promise<SubsyncResult> {
if (options.isSubsyncInProgress()) {
const busy = 'Subsync already running';
options.showMpvOsd(busy);
return { ok: false, message: busy };
}
try {
options.setSubsyncInProgress(true);
const result = await options.runWithSpinner(() => options.runSubsyncManual(request));
options.showMpvOsd(result.message);
return result;
} catch (error) {
const message = `Subsync failed: ${(error as Error).message}`;
options.showMpvOsd(message);
return { ok: false, message };
} finally {
options.setSubsyncInProgress(false);
}
}

View File

@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
handle: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
}
function createFakeIpcRegistrar(): {
registrar: {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
};
handlers: FakeIpcRegistrar;
} {
const handlers: FakeIpcRegistrar = {
on: new Map(),
handle: new Map(),
};
return {
registrar: {
on: (channel, listener) => {
handlers.on.set(channel, listener);
},
handle: (channel, listener) => {
handlers.handle.set(channel, listener);
},
},
handlers,
};
}
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => ({}),
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => {
calls.push('clearAnilistToken');
},
openAnilistSetup: () => {
calls.push('openAnilistSetup');
},
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
retryAnilistQueueNow: async () => {
calls.push('retryAnilistQueueNow');
return { ok: true, message: 'done' };
},
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
});
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
deadLetter: 0,
});
assert.deepEqual(await deps.retryAnilistQueueNow(), {
ok: true,
message: 'done',
});
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
});
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<{ id: string; value: unknown }> = [];
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
registerIpcHandlers(
{
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: (id, value) => {
calls.push({ id, value });
return { ok: true };
},
cycleRuntimeOption: (id, direction) => {
cycles.push({ id, direction });
return { ok: true };
},
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setRuntimeOption);
assert.ok(setHandler);
const invalidIdResult = await setHandler!({}, '__invalid__', true);
assert.deepEqual(invalidIdResult, { ok: false, error: 'Invalid runtime option id' });
const invalidValueResult = await setHandler!({}, 'anki.autoUpdateNewCards', 42);
assert.deepEqual(invalidValueResult, {
ok: false,
error: 'Invalid runtime option value payload',
});
const validResult = await setHandler!({}, 'anki.autoUpdateNewCards', true);
assert.deepEqual(validResult, { ok: true });
assert.deepEqual(calls, [{ id: 'anki.autoUpdateNewCards', value: true }]);
const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption);
assert.ok(cycleHandler);
const invalidDirection = await cycleHandler!({}, 'anki.kikuFieldGrouping', 2);
assert.deepEqual(invalidDirection, {
ok: false,
error: 'Invalid runtime option cycle direction',
});
await cycleHandler!({}, 'anki.kikuFieldGrouping', -1);
assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]);
});
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = [];
const modals: unknown[] = [];
registerIpcHandlers(
{
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: (modal) => {
modals.push(modal);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: (position) => {
saves.push(position);
},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
assert.deepEqual(saves, [
{ yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined },
]);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
assert.deepEqual(modals, ['subsync']);
});

397
src/core/services/ipc.ts Normal file
View File

@@ -0,0 +1,397 @@
import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
import type {
RuntimeOptionId,
RuntimeOptionValue,
SubtitlePosition,
SubsyncManualRunRequest,
SubsyncResult,
} from '../../types';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
parseOptionalForwardingOptions,
parseOverlayHostedModal,
parseRuntimeOptionDirection,
parseRuntimeOptionId,
parseRuntimeOptionValue,
parseSubtitlePosition,
parseSubsyncManualRunRequest,
} from '../../shared/ipc/validators';
export interface IpcServiceDeps {
getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
getVisibleOverlayVisibility: () => boolean;
toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
}
interface WindowLike {
isDestroyed: () => boolean;
focus: () => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
webContents: {
toggleDevTools: () => void;
};
}
interface MecabTokenizerLike {
getStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setEnabled: (enabled: boolean) => void;
}
interface MpvClientLike {
currentSecondarySubText?: string;
}
interface IpcMainRegistrar {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
}
export interface IpcDepsRuntimeOptions {
getInvisibleWindow: () => WindowLike | null;
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
getInvisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
}
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
return {
getInvisibleWindow: () => options.getInvisibleWindow(),
isVisibleOverlayVisible: options.getVisibleOverlayVisibility,
setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => {
const invisibleWindow = options.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions);
},
onOverlayModalClosed: options.onOverlayModalClosed,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.toggleDevTools();
},
getVisibleOverlayVisibility: options.getVisibleOverlayVisibility,
toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
saveSubtitlePosition: options.saveSubtitlePosition,
getMecabStatus: () => {
const mecabTokenizer = options.getMecabTokenizer();
return mecabTokenizer
? mecabTokenizer.getStatus()
: { available: false, enabled: false, path: null };
},
setMecabEnabled: (enabled) => {
const mecabTokenizer = options.getMecabTokenizer();
if (!mecabTokenizer) return;
mecabTokenizer.setEnabled(enabled);
},
handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.focus();
},
runSubsyncManual: options.runSubsyncManual,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow,
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
};
}
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
ipc.on(
IPC_CHANNELS.command.setIgnoreMouseEvents,
(event: unknown, ignore: unknown, options: unknown = {}) => {
if (typeof ignore !== 'boolean') return;
const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
if (senderWindow && !senderWindow.isDestroyed()) {
const invisibleWindow = deps.getInvisibleWindow();
if (
senderWindow === invisibleWindow &&
deps.isVisibleOverlayVisible() &&
invisibleWindow &&
!invisibleWindow.isDestroyed()
) {
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
} else {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
}
},
);
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal);
});
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings();
});
ipc.on(IPC_CHANNELS.command.quitApp, () => {
deps.quitApp();
});
ipc.on(IPC_CHANNELS.command.toggleDevTools, () => {
deps.toggleDevTools();
});
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
return deps.getVisibleOverlayVisibility();
});
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
deps.toggleVisibleOverlay();
});
ipc.handle(IPC_CHANNELS.request.getVisibleOverlayVisibility, () => {
return deps.getVisibleOverlayVisibility();
});
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
return deps.getInvisibleOverlayVisibility();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
return await deps.tokenizeCurrentSubtitle();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleRaw, () => {
return deps.getCurrentSubtitleRaw();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleAss, () => {
return deps.getCurrentSubtitleAss();
});
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
return deps.getMpvSubtitleRenderMetrics();
});
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
return deps.getSubtitlePosition();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleStyle, () => {
return deps.getSubtitleStyle();
});
ipc.on(IPC_CHANNELS.command.saveSubtitlePosition, (_event: unknown, position: unknown) => {
const parsedPosition = parseSubtitlePosition(position);
if (!parsedPosition) return;
deps.saveSubtitlePosition(parsedPosition);
});
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus();
});
ipc.on(IPC_CHANNELS.command.setMecabEnabled, (_event: unknown, enabled: unknown) => {
if (typeof enabled !== 'boolean') return;
deps.setMecabEnabled(enabled);
});
ipc.on(IPC_CHANNELS.command.mpvCommand, (_event: unknown, command: unknown) => {
const parsedCommand = parseMpvCommand(command);
if (!parsedCommand) return;
deps.handleMpvCommand(parsedCommand);
});
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
return deps.getKeybindings();
});
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
return deps.getConfiguredShortcuts();
});
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
return deps.getSecondarySubMode();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSecondarySub, () => {
return deps.getCurrentSecondarySub();
});
ipc.handle(IPC_CHANNELS.request.focusMainWindow, () => {
deps.focusMainWindow();
});
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
const parsedRequest = parseSubsyncManualRunRequest(request);
if (!parsedRequest) {
return { ok: false, message: 'Invalid subsync manual request payload' };
}
return await deps.runSubsyncManual(parsedRequest);
});
ipc.handle(IPC_CHANNELS.request.getAnkiConnectStatus, () => {
return deps.getAnkiConnectStatus();
});
ipc.handle(IPC_CHANNELS.request.getRuntimeOptions, () => {
return deps.getRuntimeOptions();
});
ipc.handle(IPC_CHANNELS.request.setRuntimeOption, (_event, id: unknown, value: unknown) => {
const parsedId = parseRuntimeOptionId(id);
if (!parsedId) {
return { ok: false, error: 'Invalid runtime option id' };
}
const parsedValue = parseRuntimeOptionValue(value);
if (parsedValue === null) {
return { ok: false, error: 'Invalid runtime option value payload' };
}
return deps.setRuntimeOption(parsedId, parsedValue);
});
ipc.handle(IPC_CHANNELS.request.cycleRuntimeOption, (_event, id: unknown, direction: unknown) => {
const parsedId = parseRuntimeOptionId(id);
if (!parsedId) {
return { ok: false, error: 'Invalid runtime option id' };
}
const parsedDirection = parseRuntimeOptionDirection(direction);
if (!parsedDirection) {
return { ok: false, error: 'Invalid runtime option cycle direction' };
}
return deps.cycleRuntimeOption(parsedId, parsedDirection);
});
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
});
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
if (tokenIndex === null) {
deps.reportHoveredSubtitleToken(null);
return;
}
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
return;
}
deps.reportHoveredSubtitleToken(tokenIndex as number);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
return deps.getAnilistStatus();
});
ipc.handle(IPC_CHANNELS.request.clearAnilistToken, () => {
deps.clearAnilistToken();
return { ok: true };
});
ipc.handle(IPC_CHANNELS.request.openAnilistSetup, () => {
deps.openAnilistSetup();
return { ok: true };
});
ipc.handle(IPC_CHANNELS.request.getAnilistQueueStatus, () => {
return deps.getAnilistQueueStatus();
});
ipc.handle(IPC_CHANNELS.request.retryAnilistNow, async () => {
return await deps.retryAnilistQueueNow();
});
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
return deps.appendClipboardVideoToQueue();
});
}

View File

@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(listener);
return this;
}
close(): void {
this.emit('close');
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners[event] ?? []) {
listener(...args);
}
}
}
test('Jellyfin remote service has no traffic until started', async () => {
let socketCreateCount = 0;
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local:8096',
accessToken: 'token-0',
deviceId: 'device-0',
webSocketFactory: () => {
socketCreateCount += 1;
return new FakeWebSocket() as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(socketCreateCount, 0);
assert.equal(fetchCalls.length, 0);
assert.equal(service.isConnected(), false);
});
test('start posts capabilities on socket connect', async () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local:8096',
accessToken: 'token-1',
deviceId: 'device-1',
webSocketFactory: (url) => {
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0]!.emit('open');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0]!.input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full');
assert.equal(service.isConnected(), true);
});
test('socket headers include jellyfin authorization metadata', () => {
const seenHeaders: Record<string, string>[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local:8096',
accessToken: 'token-auth',
deviceId: 'device-auth',
clientName: 'SubMiner',
clientVersion: '0.1.0',
deviceName: 'SubMiner',
socketHeadersFactory: (_url, headers) => {
seenHeaders.push(headers);
return new FakeWebSocket() as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
});
service.start();
assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
});
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
const sockets: FakeWebSocket[] = [];
const playPayloads: unknown[] = [];
const playstatePayloads: unknown[] = [];
const commandPayloads: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-2',
deviceId: 'device-2',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
onPlay: (payload) => playPayloads.push(payload),
onPlaystate: (payload) => playstatePayloads.push(payload),
onGeneralCommand: (payload) => commandPayloads.push(payload),
});
service.start();
const socket = sockets[0]!;
socket.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemId: 'movie-1' } }));
socket.emit(
'message',
JSON.stringify({ MessageType: 'Playstate', Data: JSON.stringify({ Command: 'Pause' }) }),
);
socket.emit(
'message',
Buffer.from(
JSON.stringify({
MessageType: 'GeneralCommand',
Data: { Name: 'DisplayMessage' },
}),
'utf8',
),
);
assert.deepEqual(playPayloads, [{ ItemId: 'movie-1' }]);
assert.deepEqual(playstatePayloads, [{ Command: 'Pause' }]);
assert.deepEqual(commandPayloads, [{ Name: 'DisplayMessage' }]);
});
test('schedules reconnect with bounded exponential backoff', () => {
const sockets: FakeWebSocket[] = [];
const delays: number[] = [];
const pendingTimers: Array<() => void> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-3',
deviceId: 'device-3',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
reconnectBaseDelayMs: 100,
reconnectMaxDelayMs: 400,
setTimer: ((handler: () => void, delay?: number) => {
pendingTimers.push(handler);
delays.push(Number(delay));
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => {
return;
}) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('close');
pendingTimers.shift()?.();
sockets[1]!.emit('close');
pendingTimers.shift()?.();
sockets[2]!.emit('close');
pendingTimers.shift()?.();
sockets[3]!.emit('close');
assert.deepEqual(delays, [100, 200, 400, 400]);
assert.equal(sockets.length, 4);
});
test('Jellyfin remote stop prevents further reconnect/network activity', () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const pendingTimers: Array<() => void> = [];
const clearedTimers: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop',
deviceId: 'device-stop',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
setTimer: ((handler: () => void) => {
pendingTimers.push(handler);
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: ((timer) => {
clearedTimers.push(timer);
}) as typeof clearTimeout,
});
service.start();
assert.equal(sockets.length, 1);
sockets[0]!.emit('close');
assert.equal(pendingTimers.length, 1);
service.stop();
for (const reconnect of pendingTimers) reconnect();
assert.ok(clearedTimers.length >= 1);
assert.equal(sockets.length, 1);
assert.equal(fetchCalls.length, 0);
assert.equal(service.isConnected(), false);
});
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
let shouldFailTimeline = false;
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-4',
deviceId: 'device-4',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
return new Response('boom', { status: 500 });
}
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0]!.emit('open');
await new Promise((resolve) => setTimeout(resolve, 0));
const expectedPayload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
});
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload));
const ok = await service.reportProgress({
itemId: 'movie-2',
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
});
shouldFailTimeline = true;
const failed = await service.reportProgress({
itemId: 'movie-2',
positionTicks: 999,
});
const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress'));
assert.ok(timelineCall);
assert.equal(ok, true);
assert.equal(failed, false);
assert.ok(typeof timelineCall.init.body === 'string');
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = [];
const calls: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-5',
deviceId: 'device-5',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith('/Sessions')) {
return new Response(JSON.stringify([{ DeviceId: 'device-5' }]), { status: 200 });
}
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0]!.emit('open');
const ok = await service.advertiseNow();
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
});

View File

@@ -0,0 +1,431 @@
import WebSocket from 'ws';
export interface JellyfinRemoteSessionMessage {
MessageType?: string;
Data?: unknown;
}
export interface JellyfinTimelinePlaybackState {
itemId: string;
mediaSourceId?: string;
positionTicks?: number;
playbackStartTimeTicks?: number;
isPaused?: boolean;
isMuted?: boolean;
canSeek?: boolean;
volumeLevel?: number;
playbackRate?: number;
playMethod?: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playlistItemId?: string | null;
eventName?: string;
}
export interface JellyfinTimelinePayload {
ItemId: string;
MediaSourceId?: string;
PositionTicks: number;
PlaybackStartTimeTicks: number;
IsPaused: boolean;
IsMuted: boolean;
CanSeek: boolean;
VolumeLevel: number;
PlaybackRate: number;
PlayMethod: string;
AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null;
EventName: string;
}
interface JellyfinRemoteSocket {
on(event: 'open', listener: () => void): this;
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: unknown) => void): this;
close(): void;
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
serverUrl: string;
accessToken: string;
deviceId: string;
capabilities?: {
PlayableMediaTypes?: string;
SupportedCommands?: string;
SupportsMediaControl?: boolean;
};
onPlay?: (payload: unknown) => void;
onPlaystate?: (payload: unknown) => void;
onGeneralCommand?: (payload: unknown) => void;
fetchImpl?: typeof fetch;
webSocketFactory?: (url: string) => JellyfinRemoteSocket;
socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
setTimer?: typeof setTimeout;
clearTimer?: typeof clearTimeout;
reconnectBaseDelayMs?: number;
reconnectMaxDelayMs?: number;
clientName?: string;
clientVersion?: string;
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
}
function normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
function clampVolume(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeTicks(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value));
}
function parseMessageData(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | null {
const serialized =
typeof rawData === 'string'
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString('utf8')
: null;
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as JellyfinRemoteSessionMessage;
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
}
}
function asNullableInteger(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
return value;
}
function createDefaultCapabilities(): {
PlayableMediaTypes: string;
SupportedCommands: string;
SupportsMediaControl: boolean;
} {
return {
PlayableMediaTypes: 'Video,Audio',
SupportedCommands:
'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent',
SupportsMediaControl: true,
};
}
function buildAuthorizationHeader(params: {
clientName: string;
deviceName: string;
clientVersion: string;
deviceId: string;
accessToken: string;
}): string {
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
}
export function buildJellyfinTimelinePayload(
state: JellyfinTimelinePlaybackState,
): JellyfinTimelinePayload {
return {
ItemId: state.itemId,
MediaSourceId: state.mediaSourceId,
PositionTicks: normalizeTicks(state.positionTicks),
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
IsPaused: state.isPaused === true,
IsMuted: state.isMuted === true,
CanSeek: state.canSeek !== false,
VolumeLevel: clampVolume(state.volumeLevel),
PlaybackRate:
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
? state.playbackRate
: 1,
PlayMethod: state.playMethod || 'DirectPlay',
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate',
};
}
export class JellyfinRemoteSessionService {
private readonly serverUrl: string;
private readonly accessToken: string;
private readonly deviceId: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory?: (url: string) => JellyfinRemoteSocket;
private readonly socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
private readonly setTimer: typeof setTimeout;
private readonly clearTimer: typeof clearTimeout;
private readonly onPlay?: (payload: unknown) => void;
private readonly onPlaystate?: (payload: unknown) => void;
private readonly onGeneralCommand?: (payload: unknown) => void;
private readonly capabilities: {
PlayableMediaTypes: string;
SupportedCommands: string;
SupportsMediaControl: boolean;
};
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
private socket: JellyfinRemoteSocket | null = null;
private running = false;
private connected = false;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: JellyfinRemoteSessionServiceOptions) {
this.serverUrl = normalizeServerUrl(options.serverUrl);
this.accessToken = options.accessToken;
this.deviceId = options.deviceId;
this.fetchImpl = options.fetchImpl ?? fetch;
this.webSocketFactory = options.webSocketFactory;
this.socketHeadersFactory = options.socketHeadersFactory;
this.setTimer = options.setTimer ?? setTimeout;
this.clearTimer = options.clearTimer ?? clearTimeout;
this.onPlay = options.onPlay;
this.onPlaystate = options.onPlaystate;
this.onGeneralCommand = options.onGeneralCommand;
this.capabilities = {
...createDefaultCapabilities(),
...(options.capabilities ?? {}),
};
const clientName = options.clientName || 'SubMiner';
const clientVersion = options.clientVersion || '0.1.0';
const deviceName = options.deviceName || clientName;
this.authHeader = buildAuthorizationHeader({
clientName,
deviceName,
clientVersion,
deviceId: this.deviceId,
accessToken: this.accessToken,
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
options.reconnectMaxDelayMs ?? 10_000,
);
}
public start(): void {
if (this.running) return;
this.running = true;
this.reconnectAttempt = 0;
this.connectSocket();
}
public stop(): void {
this.running = false;
this.connected = false;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
public isConnected(): boolean {
return this.connected;
}
public async advertiseNow(): Promise<boolean> {
await this.postCapabilities();
return this.isRegisteredOnServer();
}
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
}
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
}
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop',
});
}
private connectSocket(): void {
if (!this.running) return;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
const socket = this.createSocket(this.createSocketUrl());
this.socket = socket;
let disconnected = false;
socket.on('open', () => {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.onConnected?.();
void this.postCapabilities();
});
socket.on('message', (rawData) => {
this.handleInboundMessage(rawData);
});
const handleDisconnect = () => {
if (disconnected) return;
disconnected = true;
if (this.socket === socket) {
this.socket = null;
}
this.connected = false;
this.onDisconnected?.();
if (this.running) {
this.scheduleReconnect();
}
};
socket.on('close', handleDisconnect);
socket.on('error', handleDisconnect);
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
this.reconnectBaseDelayMs * 2 ** this.reconnectAttempt,
);
this.reconnectAttempt += 1;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
}
this.reconnectTimer = this.setTimer(() => {
this.reconnectTimer = null;
this.connectSocket();
}, delay);
}
private createSocketUrl(): string {
const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL('/socket', baseUrl);
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socketUrl.searchParams.set('api_key', this.accessToken);
socketUrl.searchParams.set('deviceId', this.deviceId);
return socketUrl.toString();
}
private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
};
if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers);
}
if (this.webSocketFactory) {
return this.webSocketFactory(url);
}
return new WebSocket(url, { headers }) as unknown as JellyfinRemoteSocket;
}
private async postCapabilities(): Promise<void> {
const payload = this.capabilities;
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
if (fullEndpointOk) return;
await this.postJson('/Sessions/Capabilities', payload);
}
private async isRegisteredOnServer(): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}/Sessions`, {
method: 'GET',
headers: {
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
});
if (!response.ok) return false;
const sessions = (await response.json()) as Array<Record<string, unknown>>;
return sessions.some((session) => String(session.DeviceId || '') === this.deviceId);
} catch {
return false;
}
}
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
return this.postJson(path, payload);
}
private async postJson(path: string, payload: unknown): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
body: JSON.stringify(payload),
});
return response.ok;
} catch {
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
const payload = parseMessageData(message.Data);
if (messageType === 'Play') {
this.onPlay?.(payload);
return;
}
if (messageType === 'Playstate') {
this.onPlaystate?.(payload);
return;
}
if (messageType === 'GeneralCommand') {
this.onGeneralCommand?.(payload);
}
}
}

View File

@@ -0,0 +1,140 @@
import * as fs from 'fs';
import * as path from 'path';
import { safeStorage } from 'electron';
interface PersistedSessionPayload {
encryptedSession?: string;
plaintextSession?: {
accessToken?: string;
userId?: string;
};
// Legacy payload fields (token only).
encryptedToken?: string;
plaintextToken?: string;
updatedAt?: number;
}
export interface JellyfinStoredSession {
accessToken: string;
userId: string;
}
export interface JellyfinTokenStore {
loadSession: () => JellyfinStoredSession | null;
saveSession: (session: JellyfinStoredSession) => void;
clearSession: () => void;
}
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedSessionPayload): void {
ensureDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
}
export function createJellyfinTokenStore(
filePath: string,
logger: {
info: (message: string) => void;
warn: (message: string, details?: unknown) => void;
error: (message: string, details?: unknown) => void;
},
): JellyfinTokenStore {
return {
loadSession(): JellyfinStoredSession | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw) as PersistedSessionPayload;
if (typeof parsed.encryptedSession === 'string' && parsed.encryptedSession.length > 0) {
const encrypted = Buffer.from(parsed.encryptedSession, 'base64');
if (!safeStorage.isEncryptionAvailable()) {
logger.warn('Jellyfin session encryption is not available on this system.');
return null;
}
const decrypted = safeStorage.decryptString(encrypted).trim();
const session = JSON.parse(decrypted) as Partial<JellyfinStoredSession>;
const accessToken = typeof session.accessToken === 'string' ? session.accessToken.trim() : '';
const userId = typeof session.userId === 'string' ? session.userId.trim() : '';
if (!accessToken || !userId) return null;
return { accessToken, userId };
}
if (parsed.plaintextSession && typeof parsed.plaintextSession === 'object') {
const accessToken =
typeof parsed.plaintextSession.accessToken === 'string'
? parsed.plaintextSession.accessToken.trim()
: '';
const userId =
typeof parsed.plaintextSession.userId === 'string'
? parsed.plaintextSession.userId.trim()
: '';
if (accessToken && userId) {
const session = { accessToken, userId };
this.saveSession(session);
return session;
}
}
if (
(typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) ||
(typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0)
) {
logger.warn('Ignoring legacy Jellyfin token-only store payload because userId is missing.');
}
} catch (error) {
logger.error('Failed to read Jellyfin session store.', error);
}
return null;
},
saveSession(session: JellyfinStoredSession): void {
const accessToken = session.accessToken.trim();
const userId = session.userId.trim();
if (!accessToken || !userId) {
this.clearSession();
return;
}
try {
if (!safeStorage.isEncryptionAvailable()) {
logger.warn(
'Jellyfin session encryption unavailable; storing session in plaintext fallback.',
);
writePayload(filePath, {
plaintextSession: {
accessToken,
userId,
},
updatedAt: Date.now(),
});
return;
}
const encrypted = safeStorage.encryptString(JSON.stringify({ accessToken, userId }));
writePayload(filePath, {
encryptedSession: encrypted.toString('base64'),
updatedAt: Date.now(),
});
} catch (error) {
logger.error('Failed to persist Jellyfin session.', error);
}
},
clearSession(): void {
if (!fs.existsSync(filePath)) return;
try {
fs.unlinkSync(filePath);
logger.info('Cleared stored Jellyfin session.');
} catch (error) {
logger.error('Failed to clear stored Jellyfin session.', error);
}
},
};
}

View File

@@ -0,0 +1,690 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
authenticateWithPassword,
listItems,
listLibraries,
listSubtitleTracks,
resolvePlaybackPlan,
ticksToSeconds,
} from './jellyfin';
const clientInfo = {
deviceId: 'subminer-test',
clientName: 'SubMiner',
clientVersion: '0.1.0-test',
};
test('authenticateWithPassword returns token and user', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /Users\/AuthenticateByName$/);
return new Response(
JSON.stringify({
AccessToken: 'abc123',
User: { Id: 'user-1' },
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const session = await authenticateWithPassword(
'http://jellyfin.local:8096/',
'kyle',
'pw',
clientInfo,
);
assert.equal(session.serverUrl, 'http://jellyfin.local:8096');
assert.equal(session.accessToken, 'abc123');
assert.equal(session.userId, 'user-1');
} finally {
globalThis.fetch = originalFetch;
}
});
test('listLibraries maps server response', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Items: [
{
Id: 'lib-1',
Name: 'TV',
CollectionType: 'tvshows',
Type: 'CollectionFolder',
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const libraries = await listLibraries(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
);
assert.deepEqual(libraries, [
{
id: 'lib-1',
name: 'TV',
collectionType: 'tvshows',
type: 'CollectionFolder',
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('listItems supports search and formats title', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /SearchTerm=planet/);
return new Response(
JSON.stringify({
Items: [
{
Id: 'ep-1',
Name: 'Pilot',
Type: 'Episode',
SeriesName: 'Space Show',
ParentIndexNumber: 1,
IndexNumber: 2,
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const items = await listItems(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
libraryId: 'lib-1',
searchTerm: 'planet',
limit: 25,
},
);
assert.equal(items[0]!.title, 'Space Show S01E02 Pilot');
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan chooses direct play when allowed', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-1',
Name: 'Movie A',
UserData: { PlaybackPositionTicks: 20_000_000 },
MediaSources: [
{
Id: 'ms-1',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 1,
DefaultSubtitleStreamIndex: 3,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ['mkv'],
},
{ itemId: 'movie-1' },
);
assert.equal(plan.mode, 'direct');
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
assert.equal(plan.subtitleStreamIndex, null);
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-2',
Name: 'Movie B',
UserData: { PlaybackPositionTicks: 10_000_000 },
MediaSources: [
{
Id: 'ms-2',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 4,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: false,
directPlayContainers: ['mkv'],
transcodeVideoCodec: 'h264',
},
{ itemId: 'movie-2' },
);
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan falls back to transcode when direct container not allowed', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-3',
Name: 'Movie C',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: 'ms-3',
Container: 'avi',
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ['mkv', 'mp4'],
transcodeVideoCodec: 'h265',
},
{
itemId: 'movie-3',
audioStreamIndex: 2,
subtitleStreamIndex: 5,
},
);
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.equal(url.searchParams.get('VideoCodec'), 'h265');
assert.equal(url.searchParams.get('AudioStreamIndex'), '2');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '5');
} finally {
globalThis.fetch = originalFetch;
}
});
test('listSubtitleTracks returns all subtitle streams with delivery urls', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-1',
MediaSources: [
{
Id: 'ms-1',
MediaStreams: [
{
Type: 'Subtitle',
Index: 2,
Language: 'eng',
DisplayTitle: 'English Full',
IsDefault: true,
DeliveryMethod: 'Embed',
},
{
Type: 'Subtitle',
Index: 3,
Language: 'jpn',
Title: 'Japanese Signs',
IsForced: true,
IsExternal: true,
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
IsExternalUrl: false,
},
{
Type: 'Subtitle',
Index: 4,
Language: 'spa',
Title: 'Spanish External',
DeliveryMethod: 'External',
DeliveryUrl: 'https://cdn.example.com/subs.srt',
IsExternalUrl: true,
},
],
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
'movie-1',
);
assert.equal(tracks.length, 3);
assert.deepEqual(
tracks.map((track) => track.index),
[2, 3, 4],
);
assert.equal(
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
);
assert.equal(
tracks[1]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
);
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan falls back to transcode when direct play blocked', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-1',
Name: 'Movie A',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: 'ms-1',
Container: 'avi',
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ['mkv', 'mp4'],
transcodeVideoCodec: 'h265',
},
{ itemId: 'movie-1' },
);
assert.equal(plan.mode, 'transcode');
assert.match(plan.url, /master\.m3u8\?/);
assert.match(plan.url, /VideoCodec=h265/);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan reuses server transcoding url and appends missing params', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-4',
Name: 'Movie D',
UserData: { PlaybackPositionTicks: 50_000_000 },
MediaSources: [
{
Id: 'ms-4',
Container: 'mkv',
SupportsDirectStream: false,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 3,
TranscodingUrl: '/Videos/movie-4/master.m3u8?VideoCodec=hevc',
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
},
{
itemId: 'movie-4',
subtitleStreamIndex: 8,
},
);
assert.equal(plan.mode, 'transcode');
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
assert.equal(url.searchParams.get('api_key'), 'token');
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan preserves episode metadata, stream selection, and resume ticks', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'ep-2',
Type: 'Episode',
Name: 'A New Hope',
SeriesName: 'Galaxy Quest',
ParentIndexNumber: 2,
IndexNumber: 7,
UserData: { PlaybackPositionTicks: 35_000_000 },
MediaSources: [
{
Id: 'ms-ep-2',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 6,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ['mkv'],
},
{
itemId: 'ep-2',
subtitleStreamIndex: 9,
},
);
assert.equal(plan.mode, 'direct');
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
assert.equal(plan.audioStreamIndex, 6);
assert.equal(plan.subtitleStreamIndex, 9);
assert.equal(plan.startTimeTicks, 35_000_000);
const url = new URL(plan.url);
assert.equal(url.searchParams.get('AudioStreamIndex'), '6');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9');
assert.equal(url.searchParams.get('StartTimeTicks'), '35000000');
} finally {
globalThis.fetch = originalFetch;
}
});
test('listSubtitleTracks falls back from PlaybackInfo to item media sources', async () => {
const originalFetch = globalThis.fetch;
let requestCount = 0;
globalThis.fetch = (async (input) => {
requestCount += 1;
if (requestCount === 1) {
assert.match(String(input), /\/Items\/movie-fallback\/PlaybackInfo\?/);
return new Response('Playback info unavailable', { status: 500 });
}
return new Response(
JSON.stringify({
Id: 'movie-fallback',
MediaSources: [
{
Id: 'ms-fallback',
MediaStreams: [
{
Type: 'Subtitle',
Index: 11,
Language: 'eng',
Title: 'English',
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt',
IsExternalUrl: false,
},
],
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
'movie-fallback',
);
assert.equal(requestCount, 2);
assert.equal(tracks.length, 1);
assert.equal(tracks[0]!.index, 11);
assert.equal(
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('authenticateWithPassword surfaces invalid credentials and server status failures', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })) as typeof fetch;
try {
await assert.rejects(
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'badpw', clientInfo),
/Invalid Jellyfin username or password\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response('Oops', { status: 500, statusText: 'Internal Server Error' })) as typeof fetch;
try {
await assert.rejects(
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo),
/Jellyfin login failed \(500 Internal Server Error\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('listLibraries surfaces token-expiry auth errors', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('Forbidden', { status: 403, statusText: 'Forbidden' })) as typeof fetch;
try {
await assert.rejects(
() =>
listLibraries(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'expired',
userId: 'u1',
username: 'kyle',
},
clientInfo,
),
/Jellyfin authentication failed \(invalid or expired token\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-empty',
Name: 'Movie Empty',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{ enabled: true },
{ itemId: 'movie-empty' },
),
/No playable media source found for Jellyfin item\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: 'movie-no-stream',
Name: 'Movie No Stream',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: 'ms-none',
Container: 'avi',
SupportsDirectStream: false,
SupportsTranscoding: false,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{ enabled: true },
{ itemId: 'movie-no-stream' },
),
/Jellyfin item cannot be streamed by direct play or transcoding\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,523 @@
import { JellyfinConfig } from '../../types';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
export interface JellyfinAuthSession {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
}
export interface JellyfinLibrary {
id: string;
name: string;
collectionType: string;
type: string;
}
export interface JellyfinPlaybackSelection {
itemId: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}
export interface JellyfinPlaybackPlan {
mode: 'direct' | 'transcode';
url: string;
title: string;
startTimeTicks: number;
audioStreamIndex: number | null;
subtitleStreamIndex: number | null;
}
export interface JellyfinSubtitleTrack {
index: number;
language: string;
title: string;
codec: string;
isDefault: boolean;
isForced: boolean;
isExternal: boolean;
deliveryMethod: string;
deliveryUrl: string | null;
}
interface JellyfinAuthResponse {
AccessToken?: string;
User?: { Id?: string; Name?: string };
}
interface JellyfinMediaStream {
Index?: number;
Type?: string;
IsExternal?: boolean;
IsDefault?: boolean;
IsForced?: boolean;
Language?: string;
DisplayTitle?: string;
Title?: string;
Codec?: string;
DeliveryMethod?: string;
DeliveryUrl?: string;
IsExternalUrl?: boolean;
}
interface JellyfinMediaSource {
Id?: string;
Container?: string;
SupportsDirectStream?: boolean;
SupportsTranscoding?: boolean;
TranscodingUrl?: string;
DefaultAudioStreamIndex?: number;
DefaultSubtitleStreamIndex?: number;
MediaStreams?: JellyfinMediaStream[];
LiveStreamId?: string;
}
interface JellyfinItemUserData {
PlaybackPositionTicks?: number;
}
interface JellyfinItem {
Id?: string;
Name?: string;
Type?: string;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
UserData?: JellyfinItemUserData;
MediaSources?: JellyfinMediaSource[];
}
interface JellyfinItemsResponse {
Items?: JellyfinItem[];
}
interface JellyfinPlaybackInfoResponse {
MediaSources?: JellyfinMediaSource[];
}
export interface JellyfinClientInfo {
deviceId: string;
clientName: string;
clientVersion: string;
}
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
function ensureString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asIntegerOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
itemId: string,
mediaSourceId: string,
): string | null {
const deliveryUrl = ensureString(stream.DeliveryUrl).trim();
if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
if (!resolved.searchParams.has('api_key')) {
resolved.searchParams.set('api_key', session.accessToken);
}
return resolved.toString();
}
const streamIndex = asIntegerOrNull(stream.Index);
if (streamIndex === null || !itemId || !mediaSourceId) return null;
const codec = ensureString(stream.Codec).toLowerCase();
const ext =
codec === 'subrip'
? 'srt'
: codec === 'webvtt'
? 'vtt'
: codec === 'vtt'
? 'vtt'
: codec === 'ass'
? 'ass'
: codec === 'ssa'
? 'ssa'
: 'srt';
const fallback = new URL(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`,
);
if (!fallback.searchParams.has('api_key')) {
fallback.searchParams.set('api_key', session.accessToken);
}
return fallback.toString();
}
function createAuthorizationHeader(client: JellyfinClientInfo, token?: string): string {
const parts = [
`Client="${client.clientName}"`,
`Device="${client.clientName}"`,
`DeviceId="${client.deviceId}"`,
`Version="${client.clientVersion}"`,
];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(', ')}`;
}
async function jellyfinRequestJson<T>(
path: string,
init: RequestInit,
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<T> {
const headers = new Headers(init.headers ?? {});
headers.set('Content-Type', 'application/json');
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
headers.set('X-Emby-Token', session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, {
...init,
headers,
});
if (response.status === 401 || response.status === 403) {
throw new Error('Jellyfin authentication failed (invalid or expired token).');
}
if (!response.ok) {
throw new Error(`Jellyfin request failed (${response.status} ${response.statusText}).`);
}
return response.json() as Promise<T>;
}
function createDirectPlayUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
): string {
const query = new URLSearchParams({
static: 'true',
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
query.set('LiveStreamId', mediaSource.LiveStreamId);
}
if (plan.audioStreamIndex !== null) {
query.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set('StartTimeTicks', String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
}
function createTranscodeUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
config: JellyfinConfig,
): string {
if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
if (!url.searchParams.has('api_key')) {
url.searchParams.set('api_key', session.accessToken);
}
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
if (!url.searchParams.has('SubtitleStreamIndex') && plan.subtitleStreamIndex !== null) {
url.searchParams.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
}
if (!url.searchParams.has('StartTimeTicks') && plan.startTimeTicks > 0) {
url.searchParams.set('StartTimeTicks', String(plan.startTimeTicks));
}
return url.toString();
}
const query = new URLSearchParams({
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts',
});
if (plan.audioStreamIndex !== null) {
query.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set('StartTimeTicks', String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`;
}
function getStreamDefaults(source: JellyfinMediaSource): {
audioStreamIndex: number | null;
} {
const audioDefault = asIntegerOrNull(source.DefaultAudioStreamIndex);
if (audioDefault !== null) return { audioStreamIndex: audioDefault };
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const defaultAudio = streams.find(
(stream) => stream.Type === 'Audio' && stream.IsDefault === true,
);
return {
audioStreamIndex: asIntegerOrNull(defaultAudio?.Index),
};
}
function getDisplayTitle(item: JellyfinItem): string {
if (item.Type === 'Episode') {
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
const prefix = item.SeriesName ? `${item.SeriesName} ` : '';
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim();
}
return ensureString(item.Name).trim() || 'Jellyfin Item';
}
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
if (source.SupportsDirectStream !== true) return false;
if (config.directPlayPreferred === false) return false;
const container = ensureString(source.Container).toLowerCase();
const allowlist = Array.isArray(config.directPlayContainers)
? config.directPlayContainers.map((entry) => entry.toLowerCase())
: [];
if (!container || allowlist.length === 0) return true;
return allowlist.includes(container);
}
export async function authenticateWithPassword(
serverUrl: string,
username: string,
password: string,
client: JellyfinClientInfo,
): Promise<JellyfinAuthSession> {
const normalizedUrl = normalizeBaseUrl(serverUrl);
if (!normalizedUrl) throw new Error('Missing Jellyfin server URL.');
if (!username.trim()) throw new Error('Missing Jellyfin username.');
if (!password) throw new Error('Missing Jellyfin password.');
const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Jellyfin username or password.');
}
if (!response.ok) {
throw new Error(`Jellyfin login failed (${response.status} ${response.statusText}).`);
}
const payload = (await response.json()) as JellyfinAuthResponse;
const accessToken = ensureString(payload.AccessToken);
const userId = ensureString(payload.User?.Id);
if (!accessToken || !userId) {
throw new Error('Jellyfin login response missing token/user.');
}
return {
serverUrl: normalizedUrl,
accessToken,
userId,
username: username.trim(),
};
}
export async function listLibraries(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<JellyfinLibrary[]> {
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Views`,
{ method: 'GET' },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name, 'Untitled'),
collectionType: ensureString((item as { CollectionType?: string }).CollectionType),
type: ensureString(item.Type),
}));
}
export async function listItems(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
options: {
libraryId: string;
searchTerm?: string;
limit?: number;
},
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
const query = new URLSearchParams({
ParentId: options.libraryId,
Recursive: 'true',
IncludeItemTypes: 'Movie,Episode,Audio',
Fields: 'MediaSources,UserData',
SortBy: 'SortName',
SortOrder: 'Ascending',
Limit: String(options.limit ?? 100),
});
if (options.searchTerm?.trim()) {
query.set('SearchTerm', options.searchTerm.trim());
}
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Items?${query.toString()}`,
{ method: 'GET' },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name),
type: ensureString(item.Type),
title: getDisplayTitle(item),
}));
}
export async function listSubtitleTracks(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
itemId: string,
): Promise<JellyfinSubtitleTrack[]> {
if (!itemId.trim()) throw new Error('Missing Jellyfin item id.');
let source: JellyfinMediaSource | undefined;
try {
const playbackInfo = await jellyfinRequestJson<JellyfinPlaybackInfoResponse>(
`/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(session.userId)}`,
{
method: 'POST',
body: JSON.stringify({ UserId: session.userId }),
},
session,
client,
);
source = Array.isArray(playbackInfo.MediaSources) ? playbackInfo.MediaSources[0] : undefined;
} catch {}
if (!source) {
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${itemId}?Fields=MediaSources`,
{ method: 'GET' },
session,
client,
);
source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined;
}
if (!source) {
throw new Error('No playable media source found for Jellyfin item.');
}
const mediaSourceId = ensureString(source.Id);
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const tracks: JellyfinSubtitleTrack[] = [];
for (const stream of streams) {
if (stream.Type !== 'Subtitle') continue;
const index = asIntegerOrNull(stream.Index);
if (index === null) continue;
tracks.push({
index,
language: ensureString(stream.Language),
title: ensureString(stream.DisplayTitle || stream.Title),
codec: ensureString(stream.Codec),
isDefault: stream.IsDefault === true,
isForced: stream.IsForced === true,
isExternal: stream.IsExternal === true,
deliveryMethod: ensureString(stream.DeliveryMethod),
deliveryUrl: resolveDeliveryUrl(session, stream, itemId, mediaSourceId),
});
}
return tracks;
}
export async function resolvePlaybackPlan(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
config: JellyfinConfig,
selection: JellyfinPlaybackSelection,
): Promise<JellyfinPlaybackPlan> {
if (!selection.itemId) {
throw new Error('Missing Jellyfin item id.');
}
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`,
{ method: 'GET' },
session,
client,
);
const source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined;
if (!source) {
throw new Error('No playable media source found for Jellyfin item.');
}
const defaults = getStreamDefaults(source);
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
const basePlan: JellyfinPlaybackPlan = {
mode: 'transcode',
url: '',
title: getDisplayTitle(item),
startTimeTicks,
audioStreamIndex,
subtitleStreamIndex,
};
if (shouldPreferDirectPlay(source, config)) {
return {
...basePlan,
mode: 'direct',
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (source.SupportsTranscoding !== true && source.SupportsDirectStream === true) {
return {
...basePlan,
mode: 'direct',
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (source.SupportsTranscoding !== true) {
throw new Error('Jellyfin item cannot be streamed by direct play or transcoding.');
}
return {
...basePlan,
mode: 'transcode',
url: createTranscodeUrl(session, selection.itemId, source, basePlan, config),
};
}
export function ticksToSeconds(ticks: number): number {
return Math.max(0, Math.floor(ticks / JELLYFIN_TICKS_PER_SECOND));
}

View File

@@ -0,0 +1,71 @@
import { JimakuApiResponse, JimakuConfig, JimakuLanguagePreference } from '../../types';
import {
jimakuFetchJson as jimakuFetchJsonRequest,
resolveJimakuApiKey as resolveJimakuApiKeyFromConfig,
} from '../../jimaku/utils';
export function getJimakuConfig(getResolvedConfig: () => { jimaku?: JimakuConfig }): JimakuConfig {
const config = getResolvedConfig();
return config.jimaku ?? {};
}
export function getJimakuBaseUrl(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultBaseUrl: string,
): string {
const config = getJimakuConfig(getResolvedConfig);
return config.apiBaseUrl || defaultBaseUrl;
}
export function getJimakuLanguagePreference(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultPreference: JimakuLanguagePreference,
): JimakuLanguagePreference {
const config = getJimakuConfig(getResolvedConfig);
return config.languagePreference || defaultPreference;
}
export function getJimakuMaxEntryResults(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultValue: number,
): number {
const config = getJimakuConfig(getResolvedConfig);
const value = config.maxEntryResults;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return defaultValue;
}
export async function resolveJimakuApiKey(
getResolvedConfig: () => { jimaku?: JimakuConfig },
): Promise<string | null> {
return resolveJimakuApiKeyFromConfig(getJimakuConfig(getResolvedConfig));
}
export async function jimakuFetchJson<T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {},
options: {
getResolvedConfig: () => { jimaku?: JimakuConfig };
defaultBaseUrl: string;
defaultMaxEntryResults: number;
defaultLanguagePreference: JimakuLanguagePreference;
},
): Promise<JimakuApiResponse<T>> {
const apiKey = await resolveJimakuApiKey(options.getResolvedConfig);
if (!apiKey) {
return {
ok: false,
error: {
error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.',
code: 401,
},
};
}
return jimakuFetchJsonRequest<T>(endpoint, query, {
baseUrl: getJimakuBaseUrl(options.getResolvedConfig, options.defaultBaseUrl),
apiKey,
});
}

View File

@@ -0,0 +1,85 @@
export type JlptIgnoredPos1Entry = {
pos1: string;
reason: string;
};
// Token-level lexical terms excluded from JLPT highlighting.
// These are not tied to POS and act as a safety layer for non-dictionary cases.
export const JLPT_EXCLUDED_TERMS = new Set([
'この',
'その',
'あの',
'どの',
'これ',
'それ',
'あれ',
'どれ',
'ここ',
'そこ',
'あそこ',
'どこ',
'こと',
'ああ',
'ええ',
'うう',
'おお',
'はは',
'へえ',
'ふう',
'ほう',
]);
export function shouldIgnoreJlptByTerm(term: string): boolean {
return JLPT_EXCLUDED_TERMS.has(term);
}
// MeCab POS1 categories that should be excluded from JLPT-level token tagging.
// These are filtered out because they are typically functional or non-lexical words.
export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [
{
pos1: '助詞',
reason: 'Particles (ko/kara/nagara etc.): mostly grammatical glue, not independent vocabulary.',
},
{
pos1: '助動詞',
reason: 'Auxiliary verbs (past tense, politeness, modality): grammar helpers.',
},
{
pos1: '記号',
reason: 'Symbols/punctuation and symbols-like tokens.',
},
{
pos1: '補助記号',
reason: 'Auxiliary symbols (e.g. bracket-like or markup tokens).',
},
{
pos1: '連体詞',
reason: 'Adnominal forms (e.g. demonstratives like "この").',
},
{
pos1: '感動詞',
reason: 'Interjections/onomatopoeia-style exclamations.',
},
{
pos1: '接続詞',
reason: 'Conjunctions that connect clauses, usually not target vocab items.',
},
{
pos1: '接頭詞',
reason: 'Prefixes/prefix-like grammatical elements.',
},
] as const satisfies readonly JlptIgnoredPos1Entry[];
export const JLPT_IGNORED_MECAB_POS1 = JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1);
export const JLPT_IGNORED_MECAB_POS1_LIST: readonly string[] = JLPT_IGNORED_MECAB_POS1;
const JLPT_IGNORED_MECAB_POS1_SET = new Set<string>(JLPT_IGNORED_MECAB_POS1_LIST);
export function getIgnoredPos1Entries(): readonly JlptIgnoredPos1Entry[] {
return JLPT_IGNORED_MECAB_POS1_ENTRIES;
}
export function shouldIgnoreJlptForMecabPos1(pos1: string): boolean {
return JLPT_IGNORED_MECAB_POS1_SET.has(pos1);
}

View File

@@ -0,0 +1,179 @@
import * as fs from 'fs';
import * as path from 'path';
import type { JlptLevel } from '../../types';
export interface JlptVocabLookupOptions {
searchPaths: string[];
log: (message: string) => void;
}
const JLPT_BANK_FILES: { level: JlptLevel; filename: string }[] = [
{ level: 'N1', filename: 'term_meta_bank_1.json' },
{ level: 'N2', filename: 'term_meta_bank_2.json' },
{ level: 'N3', filename: 'term_meta_bank_3.json' },
{ level: 'N4', filename: 'term_meta_bank_4.json' },
{ level: 'N5', filename: 'term_meta_bank_5.json' },
];
const JLPT_LEVEL_PRECEDENCE: Record<JlptLevel, number> = {
N1: 5,
N2: 4,
N3: 3,
N4: 2,
N5: 1,
};
const NOOP_LOOKUP = (): null => null;
function normalizeJlptTerm(value: string): string {
return value.trim();
}
function hasFrequencyDisplayValue(meta: unknown): boolean {
if (!meta || typeof meta !== 'object') return false;
const frequency = (meta as { frequency?: unknown }).frequency;
if (!frequency || typeof frequency !== 'object') return false;
return Object.prototype.hasOwnProperty.call(frequency as Record<string, unknown>, 'displayValue');
}
function addEntriesToMap(
rawEntries: unknown,
level: JlptLevel,
terms: Map<string, JlptLevel>,
log: (message: string) => void,
): void {
const shouldUpdateLevel = (
existingLevel: JlptLevel | undefined,
incomingLevel: JlptLevel,
): boolean =>
existingLevel === undefined ||
JLPT_LEVEL_PRECEDENCE[incomingLevel] > JLPT_LEVEL_PRECEDENCE[existingLevel];
if (!Array.isArray(rawEntries)) {
return;
}
for (const rawEntry of rawEntries) {
if (!Array.isArray(rawEntry)) {
continue;
}
const [term, _entryId, meta] = rawEntry as [unknown, unknown, unknown];
if (typeof term !== 'string') {
continue;
}
const normalizedTerm = normalizeJlptTerm(term);
if (!normalizedTerm) {
continue;
}
if (!hasFrequencyDisplayValue(meta)) {
continue;
}
const existingLevel = terms.get(normalizedTerm);
if (shouldUpdateLevel(existingLevel, level)) {
terms.set(normalizedTerm, level);
continue;
}
log(
`JLPT dictionary already has ${normalizedTerm} as ${existingLevel}; keeping that level instead of ${level}`,
);
}
}
function collectDictionaryFromPath(
dictionaryPath: string,
log: (message: string) => void,
): Map<string, JlptLevel> {
const terms = new Map<string, JlptLevel>();
for (const bank of JLPT_BANK_FILES) {
const bankPath = path.join(dictionaryPath, bank.filename);
if (!fs.existsSync(bankPath)) {
log(`JLPT bank file missing for ${bank.level}: ${bankPath}`);
continue;
}
let rawText: string;
try {
rawText = fs.readFileSync(bankPath, 'utf-8');
} catch {
log(`Failed to read JLPT bank file ${bankPath}`);
continue;
}
let rawEntries: unknown;
try {
rawEntries = JSON.parse(rawText) as unknown;
} catch {
log(`Failed to parse JLPT bank file as JSON: ${bankPath}`);
continue;
}
if (!Array.isArray(rawEntries)) {
log(`JLPT bank file has unsupported format (expected JSON array): ${bankPath}`);
continue;
}
const beforeSize = terms.size;
addEntriesToMap(rawEntries, bank.level, terms, log);
if (terms.size === beforeSize) {
log(`JLPT bank file contained no extractable entries: ${bankPath}`);
}
}
return terms;
}
export async function createJlptVocabularyLookup(
options: JlptVocabLookupOptions,
): Promise<(term: string) => JlptLevel | null> {
const attemptedPaths: string[] = [];
let foundDictionaryPathCount = 0;
let foundBankCount = 0;
const resolvedBanks: string[] = [];
for (const dictionaryPath of options.searchPaths) {
attemptedPaths.push(dictionaryPath);
if (!fs.existsSync(dictionaryPath)) {
continue;
}
if (!fs.statSync(dictionaryPath).isDirectory()) {
continue;
}
foundDictionaryPathCount += 1;
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
if (terms.size > 0) {
resolvedBanks.push(dictionaryPath);
foundBankCount += 1;
options.log(`JLPT dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
return (term: string): JlptLevel | null => {
if (!term) return null;
const normalized = normalizeJlptTerm(term);
return normalized ? (terms.get(normalized) ?? null) : null;
};
}
options.log(
`JLPT dictionary directory exists but contains no readable term_meta_bank_*.json files: ${dictionaryPath}`,
);
}
options.log(
`JLPT dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(', ')}`,
);
if (foundDictionaryPathCount > 0 && foundBankCount === 0) {
options.log(
'JLPT dictionary directories found, but none contained valid term_meta_bank_*.json files.',
);
}
if (resolvedBanks.length > 0 && foundBankCount > 0) {
options.log(`JLPT dictionary search matched path(s): ${resolvedBanks.join(', ')}`);
}
return NOOP_LOOKUP;
}

View File

@@ -0,0 +1,209 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
copyCurrentSubtitle,
handleMineSentenceDigit,
handleMultiCopyDigit,
mineSentenceCard,
} from './mining';
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitle({
subtitleTimingTracker: null,
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), 'Subtitle tracker not available');
copyCurrentSubtitle({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), 'No current subtitle');
assert.deepEqual(copied, []);
});
test('copyCurrentSubtitle copies current subtitle text', () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitle({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => 'hello world',
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ['hello world']);
assert.equal(osd.at(-1), 'Copied subtitle');
});
test('mineSentenceCard handles missing integration and disconnected mpv', async () => {
const osd: string[] = [];
assert.equal(
await mineSentenceCard({
ankiIntegration: null,
mpvClient: null,
showMpvOsd: (text) => osd.push(text),
}),
false,
);
assert.equal(osd.at(-1), 'AnkiConnect integration not enabled');
assert.equal(
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => false,
},
mpvClient: {
connected: false,
currentSubText: 'line',
currentSubStart: 1,
currentSubEnd: 2,
},
showMpvOsd: (text) => osd.push(text),
}),
false,
);
assert.equal(osd.at(-1), 'MPV not connected');
});
test('mineSentenceCard creates sentence card from mpv subtitle state', async () => {
const created: Array<{
sentence: string;
startTime: number;
endTime: number;
secondarySub?: string;
}> = [];
const createdCard = await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: 'subtitle line',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'secondary line',
},
showMpvOsd: () => {},
});
assert.equal(createdCard, true);
assert.deepEqual(created, [
{
sentence: 'subtitle line',
startTime: 10,
endTime: 12,
secondarySub: 'secondary line',
},
]);
});
test('handleMultiCopyDigit copies available history and reports truncation', () => {
const osd: string[] = [];
const copied: string[] = [];
handleMultiCopyDigit(5, {
subtitleTimingTracker: {
getRecentBlocks: (count) => ['a', 'b'].slice(0, count),
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ['a\n\nb']);
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
});
test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {
throw new Error('mine boom');
},
},
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }),
onCardsMined: (count) => {
cardsMined += count;
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
assert.equal((logs[0]?.err as Error).message, 'mine boom');
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
assert.equal(cardsMined, 0);
});
test('handleMineSentenceDigit increments successful card count', async () => {
const osd: string[] = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => true,
},
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: () => {},
onCardsMined: (count) => {
cardsMined += count;
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cardsMined, 1);
});

181
src/core/services/mining.ts Normal file
View File

@@ -0,0 +1,181 @@
interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[];
getCurrentSubtitle: () => string | null;
findTiming: (text: string) => { startTime: number; endTime: number } | null;
}
interface AnkiIntegrationLike {
updateLastAddedFromClipboard: (clipboardText: string) => Promise<void>;
triggerFieldGroupingForLastAddedCard: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
createSentenceCard: (
sentence: string,
startTime: number,
endTime: number,
secondarySub?: string,
) => Promise<boolean>;
}
interface MpvClientLike {
connected: boolean;
currentSubText: string;
currentSubStart: number;
currentSubEnd: number;
currentSecondarySubText?: string;
}
export function handleMultiCopyDigit(
count: number,
deps: {
subtitleTimingTracker: SubtitleTimingTrackerLike | null;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
},
): void {
if (!deps.subtitleTimingTracker) return;
const availableCount = Math.min(count, 200);
const blocks = deps.subtitleTimingTracker.getRecentBlocks(availableCount);
if (blocks.length === 0) {
deps.showMpvOsd('No subtitle history available');
return;
}
const actualCount = blocks.length;
deps.writeClipboardText(blocks.join('\n\n'));
if (actualCount < count) {
deps.showMpvOsd(`Only ${actualCount} lines available, copied ${actualCount}`);
} else {
deps.showMpvOsd(`Copied ${actualCount} lines`);
}
}
export function copyCurrentSubtitle(deps: {
subtitleTimingTracker: SubtitleTimingTrackerLike | null;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
}): void {
if (!deps.subtitleTimingTracker) {
deps.showMpvOsd('Subtitle tracker not available');
return;
}
const currentSubtitle = deps.subtitleTimingTracker.getCurrentSubtitle();
if (!currentSubtitle) {
deps.showMpvOsd('No current subtitle');
return;
}
deps.writeClipboardText(currentSubtitle);
deps.showMpvOsd('Copied subtitle');
}
function requireAnkiIntegration(
ankiIntegration: AnkiIntegrationLike | null,
showMpvOsd: (text: string) => void,
): AnkiIntegrationLike | null {
if (!ankiIntegration) {
showMpvOsd('AnkiConnect integration not enabled');
return null;
}
return ankiIntegration;
}
export async function updateLastCardFromClipboard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}): Promise<void> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
if (!anki) return;
await anki.updateLastAddedFromClipboard(deps.readClipboardText());
}
export async function triggerFieldGrouping(deps: {
ankiIntegration: AnkiIntegrationLike | null;
showMpvOsd: (text: string) => void;
}): Promise<void> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
if (!anki) return;
await anki.triggerFieldGroupingForLastAddedCard();
}
export async function markLastCardAsAudioCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
showMpvOsd: (text: string) => void;
}): Promise<void> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
if (!anki) return;
await anki.markLastCardAsAudioCard();
}
export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
if (!anki) return false;
const mpvClient = deps.mpvClient;
if (!mpvClient || !mpvClient.connected) {
deps.showMpvOsd('MPV not connected');
return false;
}
if (!mpvClient.currentSubText) {
deps.showMpvOsd('No current subtitle');
return false;
}
return await anki.createSentenceCard(
mpvClient.currentSubText,
mpvClient.currentSubStart,
mpvClient.currentSubEnd,
mpvClient.currentSecondarySubText || undefined,
);
}
export function handleMineSentenceDigit(
count: number,
deps: {
subtitleTimingTracker: SubtitleTimingTrackerLike | null;
ankiIntegration: AnkiIntegrationLike | null;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined?: (count: number) => void;
},
): void {
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
if (blocks.length === 0) {
deps.showMpvOsd('No subtitle history available');
return;
}
const timings: { startTime: number; endTime: number }[] = [];
for (const block of blocks) {
const timing = deps.subtitleTimingTracker.findTiming(block);
if (timing) timings.push(timing);
}
if (timings.length === 0) {
deps.showMpvOsd('Subtitle timing not found');
return;
}
const rangeStart = Math.min(...timings.map((t) => t.startTime));
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
const sentence = blocks.join(' ');
const cardsToMine = 1;
deps.ankiIntegration
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
}
})
.catch((err) => {
deps.logError('mineSentenceMultiple failed:', err);
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
});
}

View File

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
sendMpvCommandRuntime,
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from './mpv';
test('showMpvOsdRuntime sends show-text when connected', () => {
const commands: (string | number)[][] = [];
showMpvOsdRuntime(
{
connected: true,
send: ({ command }) => {
commands.push(command);
},
},
'hello',
);
assert.deepEqual(commands, [['show-text', 'hello', '3000']]);
});
test('showMpvOsdRuntime logs fallback when disconnected', () => {
const logs: string[] = [];
showMpvOsdRuntime(
{
connected: false,
send: () => {},
},
'hello',
(line) => {
logs.push(line);
},
);
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
});
test('mpv runtime command wrappers call expected client methods', () => {
const calls: string[] = [];
const client = {
connected: true,
send: ({ command }: { command: (string | number)[] }) => {
calls.push(`send:${command.join(',')}`);
},
replayCurrentSubtitle: () => {
calls.push('replay');
},
playNextSubtitle: () => {
calls.push('next');
},
setSubVisibility: (visible: boolean) => {
calls.push(`subVisible:${visible}`);
},
};
replayCurrentSubtitleRuntime(client);
playNextSubtitleRuntime(client);
sendMpvCommandRuntime(client, ['script-message', 'x']);
setMpvSubVisibilityRuntime(client, false);
assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']);
});

View File

@@ -0,0 +1,173 @@
import {
MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH,
MPV_REQUEST_ID_SECONDARY_SUBTEXT,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
MPV_REQUEST_ID_SUB_BOLD,
MPV_REQUEST_ID_SUB_BORDER_SIZE,
MPV_REQUEST_ID_SUB_FONT,
MPV_REQUEST_ID_SUB_FONT_SIZE,
MPV_REQUEST_ID_SUB_ITALIC,
MPV_REQUEST_ID_SUB_MARGIN_X,
MPV_REQUEST_ID_SUB_MARGIN_Y,
MPV_REQUEST_ID_SUB_POS,
MPV_REQUEST_ID_SUB_SCALE,
MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
MPV_REQUEST_ID_SUB_SPACING,
MPV_REQUEST_ID_SUBTEXT,
MPV_REQUEST_ID_SUBTEXT_ASS,
MPV_REQUEST_ID_SUB_USE_MARGINS,
MPV_REQUEST_ID_PAUSE,
} from './mpv-protocol';
type MpvProtocolCommand = {
command: unknown[];
request_id?: number;
};
export interface MpvSendCommand {
(command: MpvProtocolCommand): boolean;
}
const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-text',
'path',
'sub-start',
'sub-end',
'time-pos',
'secondary-sub-text',
'aid',
'sub-pos',
'sub-font-size',
'sub-scale',
'sub-margin-y',
'sub-margin-x',
'sub-font',
'sub-spacing',
'sub-bold',
'sub-italic',
'sub-scale-by-window',
'osd-height',
'osd-dimensions',
'sub-text-ass',
'sub-border-size',
'sub-shadow-offset',
'sub-ass-override',
'sub-use-margins',
'pause',
'media-title',
];
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
{
command: ['get_property', 'sub-text'],
request_id: MPV_REQUEST_ID_SUBTEXT,
},
{
command: ['get_property', 'sub-text-ass'],
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
},
{
command: ['get_property', 'path'],
request_id: MPV_REQUEST_ID_PATH,
},
{
command: ['get_property', 'media-title'],
},
{
command: ['get_property', 'pause'],
request_id: MPV_REQUEST_ID_PAUSE,
},
{
command: ['get_property', 'secondary-sub-text'],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,
},
{
command: ['get_property', 'secondary-sub-visibility'],
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
},
{
command: ['get_property', 'aid'],
request_id: MPV_REQUEST_ID_AID,
},
{
command: ['get_property', 'sub-pos'],
request_id: MPV_REQUEST_ID_SUB_POS,
},
{
command: ['get_property', 'sub-font-size'],
request_id: MPV_REQUEST_ID_SUB_FONT_SIZE,
},
{
command: ['get_property', 'sub-scale'],
request_id: MPV_REQUEST_ID_SUB_SCALE,
},
{
command: ['get_property', 'sub-margin-y'],
request_id: MPV_REQUEST_ID_SUB_MARGIN_Y,
},
{
command: ['get_property', 'sub-margin-x'],
request_id: MPV_REQUEST_ID_SUB_MARGIN_X,
},
{
command: ['get_property', 'sub-font'],
request_id: MPV_REQUEST_ID_SUB_FONT,
},
{
command: ['get_property', 'sub-spacing'],
request_id: MPV_REQUEST_ID_SUB_SPACING,
},
{
command: ['get_property', 'sub-bold'],
request_id: MPV_REQUEST_ID_SUB_BOLD,
},
{
command: ['get_property', 'sub-italic'],
request_id: MPV_REQUEST_ID_SUB_ITALIC,
},
{
command: ['get_property', 'sub-scale-by-window'],
request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
},
{
command: ['get_property', 'osd-height'],
request_id: MPV_REQUEST_ID_OSD_HEIGHT,
},
{
command: ['get_property', 'osd-dimensions'],
request_id: MPV_REQUEST_ID_OSD_DIMENSIONS,
},
{
command: ['get_property', 'sub-border-size'],
request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE,
},
{
command: ['get_property', 'sub-shadow-offset'],
request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
},
{
command: ['get_property', 'sub-ass-override'],
request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
},
{
command: ['get_property', 'sub-use-margins'],
request_id: MPV_REQUEST_ID_SUB_USE_MARGINS,
},
];
export function subscribeToMpvProperties(send: MpvSendCommand): void {
MPV_SUBTITLE_PROPERTY_OBSERVATIONS.forEach((property, index) => {
send({ command: ['observe_property', index + 1, property] });
});
}
export function requestMpvInitialState(send: MpvSendCommand): void {
MPV_INITIAL_PROPERTY_REQUESTS.forEach((payload) => {
send(payload);
});
}

View File

@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
parseVisibilityProperty,
asBoolean,
} from './mpv-protocol';
function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
deps: MpvProtocolHandleMessageDeps;
state: {
subText: string;
secondarySubText: string;
events: Array<unknown>;
commands: unknown[];
mediaPath: string;
restored: number;
};
} {
const state = {
subText: '',
secondarySubText: '',
events: [] as Array<unknown>,
commands: [] as unknown[],
mediaPath: '',
restored: 0,
};
const metrics: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
return {
state,
deps: {
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ['ja'] },
}),
getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false,
emitSubtitleChange: (payload) => state.events.push(payload),
emitSubtitleAssChange: (payload) => state.events.push(payload),
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
state.subText = text;
},
setCurrentSubStart: () => {},
getCurrentSubStart: () => 0,
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
emitMediaPathChange: (payload) => {
state.mediaPath = payload.path;
},
emitMediaTitleChange: (payload) => state.events.push(payload),
emitSubtitleMetricsChange: (payload) => state.events.push(payload),
setCurrentSecondarySubText: (text) => {
state.secondarySubText = text;
},
resolvePendingRequest: () => false,
setSecondarySubVisibility: () => {},
syncCurrentAudioStreamIndex: () => {},
setCurrentAudioTrackId: () => {},
setCurrentTimePos: () => {},
getCurrentTimePos: () => 0,
getPendingPauseAtSubEnd: () => false,
setPendingPauseAtSubEnd: () => {},
getPauseAtTime: () => null,
setPauseAtTime: () => {},
emitTimePosChange: () => {},
emitPauseChange: () => {},
autoLoadSecondarySubTrack: () => {},
setCurrentVideoPath: () => {},
emitSecondarySubtitleVisibility: (payload) => state.events.push(payload),
setCurrentAudioStreamIndex: () => {},
sendCommand: (payload) => {
state.commands.push(payload);
return true;
},
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
...overrides,
},
};
}
test('dispatchMpvProtocolMessage emits subtitle text on property change', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-text', data: '字幕' },
deps,
);
assert.equal(state.subText, '字幕');
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{ type: 'audio', id: 1, lang: 'eng' },
{ type: 'sub', id: 2, lang: 'ja' },
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
});
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {
let pendingPauseAtSubEnd = true;
let pauseAtTime: number | null = null;
const { deps, state } = createDeps({
getPendingPauseAtSubEnd: () => pendingPauseAtSubEnd,
setPendingPauseAtSubEnd: (next) => {
pendingPauseAtSubEnd = next;
},
getCurrentSubText: () => '字幕',
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
setPauseAtTime: (next) => {
pauseAtTime = next;
},
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
assert.equal(pendingPauseAtSubEnd, false);
assert.equal(pauseAtTime, 42);
assert.deepEqual(state.events, [{ text: '字幕', start: 0, end: 0 }]);
assert.deepEqual(state.commands[state.commands.length - 1], {
command: ['set_property', 'pause', false],
});
});
test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer', () => {
const parsed = splitMpvMessagesFromBuffer(
'{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"',
);
assert.equal(parsed.messages.length, 2);
assert.equal(parsed.nextBuffer, '{"partial"');
assert.equal(parsed.messages[0]!.event, 'shutdown');
assert.equal(parsed.messages[1]!.name, 'media-title');
});
test('splitMpvMessagesFromBuffer reports invalid JSON lines', () => {
const errors: Array<{ line: string; error?: string }> = [];
splitMpvMessagesFromBuffer('{"event":"x"}\n{invalid}\n', undefined, (line, error) => {
errors.push({ line, error: String(error) });
});
assert.equal(errors.length, 1);
assert.equal(errors[0]!.line, '{invalid}');
});
test('visibility and boolean parsers handle text values', () => {
assert.equal(parseVisibilityProperty('true'), true);
assert.equal(parseVisibilityProperty('0'), false);
assert.equal(parseVisibilityProperty('unknown'), null);
assert.equal(asBoolean('yes', false), true);
assert.equal(asBoolean('0', true), false);
});

View File

@@ -0,0 +1,396 @@
import { MpvSubtitleRenderMetrics } from '../../types';
export type MpvMessage = {
event?: string;
name?: string;
data?: unknown;
request_id?: number;
error?: string;
};
export const MPV_REQUEST_ID_SUBTEXT = 101;
export const MPV_REQUEST_ID_PATH = 102;
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104;
export const MPV_REQUEST_ID_AID = 105;
export const MPV_REQUEST_ID_SUB_POS = 106;
export const MPV_REQUEST_ID_SUB_FONT_SIZE = 107;
export const MPV_REQUEST_ID_SUB_SCALE = 108;
export const MPV_REQUEST_ID_SUB_MARGIN_Y = 109;
export const MPV_REQUEST_ID_SUB_MARGIN_X = 110;
export const MPV_REQUEST_ID_SUB_FONT = 111;
export const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112;
export const MPV_REQUEST_ID_OSD_HEIGHT = 113;
export const MPV_REQUEST_ID_OSD_DIMENSIONS = 114;
export const MPV_REQUEST_ID_SUBTEXT_ASS = 115;
export const MPV_REQUEST_ID_SUB_SPACING = 116;
export const MPV_REQUEST_ID_SUB_BOLD = 117;
export const MPV_REQUEST_ID_SUB_ITALIC = 118;
export const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119;
export const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120;
export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121;
export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
export const MPV_REQUEST_ID_PAUSE = 123;
export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export type MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
export interface MpvProtocolParseResult {
messages: MpvMessage[];
nextBuffer: string;
}
export interface MpvProtocolHandleMessageDeps {
getResolvedConfig: () => {
secondarySub?: { secondarySubLanguages?: Array<string> };
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
setCurrentSubStart: (value: number) => void;
getCurrentSubStart: () => number;
setCurrentSubEnd: (value: number) => void;
getCurrentSubEnd: () => number;
emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
setSecondarySubVisibility: (visible: boolean) => void;
syncCurrentAudioStreamIndex: () => void;
setCurrentAudioTrackId: (value: number | null) => void;
setCurrentTimePos: (value: number) => void;
getCurrentTimePos: () => number;
getPendingPauseAtSubEnd: () => boolean;
setPendingPauseAtSubEnd: (value: boolean) => void;
getPauseAtTime: () => number | null;
setPauseAtTime: (value: number | null) => void;
autoLoadSecondarySubTrack: () => void;
setCurrentVideoPath: (value: string) => void;
emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
setPreviousSecondarySubVisibility: (visible: boolean) => void;
setCurrentAudioStreamIndex: (
tracks: Array<{
type?: string;
id?: number;
selected?: boolean;
'ff-index'?: number;
}>,
) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
restorePreviousSecondarySubVisibility: () => void;
}
export function splitMpvMessagesFromBuffer(
buffer: string,
onMessage?: MpvMessageParser,
onError?: MpvParseErrorHandler,
): MpvProtocolParseResult {
const lines = buffer.split('\n');
const nextBuffer = lines.pop() || '';
const messages: MpvMessage[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const message = JSON.parse(line) as MpvMessage;
messages.push(message);
if (onMessage) {
onMessage(message);
}
} catch (error) {
if (onError) {
onError(line, error);
}
}
}
return {
messages,
nextBuffer,
};
}
export async function dispatchMpvProtocolMessage(
msg: MpvMessage,
deps: MpvProtocolHandleMessageDeps,
): Promise<void> {
if (msg.event === 'property-change') {
if (msg.name === 'sub-text') {
const nextSubText = (msg.data as string) || '';
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({
text: nextSubText,
isOverlayVisible: overlayVisible,
});
deps.setCurrentSubText(nextSubText);
} else if (msg.name === 'sub-text-ass') {
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.name === 'sub-start') {
deps.setCurrentSubStart((msg.data as number) || 0);
deps.emitSubtitleTiming({
text: deps.getCurrentSubText(),
start: deps.getCurrentSubStart(),
end: deps.getCurrentSubEnd(),
});
} else if (msg.name === 'sub-end') {
const subEnd = (msg.data as number) || 0;
deps.setCurrentSubEnd(subEnd);
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
deps.setPauseAtTime(subEnd);
deps.setPendingPauseAtSubEnd(false);
deps.sendCommand({ command: ['set_property', 'pause', false] });
}
deps.emitSubtitleTiming({
text: deps.getCurrentSubText(),
start: deps.getCurrentSubStart(),
end: deps.getCurrentSubEnd(),
});
} else if (msg.name === 'secondary-sub-text') {
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
deps.emitSecondarySubtitleChange({ text: nextSubText });
} else if (msg.name === 'aid') {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();
} else if (msg.name === 'time-pos') {
deps.emitTimePosChange({ time: (msg.data as number) || 0 });
deps.setCurrentTimePos((msg.data as number) || 0);
if (
deps.getPauseAtTime() !== null &&
deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number)
) {
deps.setPauseAtTime(null);
deps.sendCommand({ command: ['set_property', 'pause', true] });
}
} else if (msg.name === 'pause') {
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
deps.emitMediaTitleChange({
title: typeof msg.data === 'string' ? msg.data.trim() : null,
});
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
deps.setCurrentVideoPath(path);
deps.emitMediaPathChange({ path });
deps.autoLoadSecondarySubTrack();
deps.syncCurrentAudioStreamIndex();
} else if (msg.name === 'sub-pos') {
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
} else if (msg.name === 'sub-font-size') {
deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number });
} else if (msg.name === 'sub-scale') {
deps.emitSubtitleMetricsChange({ subScale: msg.data as number });
} else if (msg.name === 'sub-margin-y') {
deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number });
} else if (msg.name === 'sub-margin-x') {
deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number });
} else if (msg.name === 'sub-font') {
deps.emitSubtitleMetricsChange({ subFont: msg.data as string });
} else if (msg.name === 'sub-spacing') {
deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number });
} else if (msg.name === 'sub-bold') {
deps.emitSubtitleMetricsChange({
subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold),
});
} else if (msg.name === 'sub-italic') {
deps.emitSubtitleMetricsChange({
subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic),
});
} else if (msg.name === 'sub-border-size') {
deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number });
} else if (msg.name === 'sub-shadow-offset') {
deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number });
} else if (msg.name === 'sub-ass-override') {
deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string });
} else if (msg.name === 'sub-scale-by-window') {
deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
});
} else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
});
} else if (msg.name === 'osd-height') {
deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number });
} else if (msg.name === 'osd-dimensions') {
const dims = msg.data as Record<string, unknown> | null;
if (!dims) {
deps.emitSubtitleMetricsChange({ osdDimensions: null });
} else {
deps.emitSubtitleMetricsChange({
osdDimensions: {
w: asFiniteNumber(dims.w, 0),
h: asFiniteNumber(dims.h, 0),
ml: asFiniteNumber(dims.ml, 0),
mr: asFiniteNumber(dims.mr, 0),
mt: asFiniteNumber(dims.mt, 0),
mb: asFiniteNumber(dims.mb, 0),
},
});
}
}
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
} else if (msg.request_id) {
if (deps.resolvePendingRequest(msg.request_id, msg)) {
return;
}
if (msg.data === undefined) {
return;
}
if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) {
const tracks = msg.data as Array<{
type: string;
lang?: string;
id: number;
}>;
if (Array.isArray(tracks)) {
const config = deps.getResolvedConfig();
const languages = config.secondarySub?.secondarySubLanguages || [];
const subTracks = tracks.filter((track) => track.type === 'sub');
for (const language of languages) {
const match = subTracks.find((track) => track.lang === language);
if (match) {
deps.sendCommand({
command: ['set_property', 'secondary-sid', match.id],
});
break;
}
}
}
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
deps.setCurrentAudioStreamIndex(
msg.data as Array<{
type?: string;
id?: number;
selected?: boolean;
'ff-index'?: number;
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
const nextSubText = (msg.data as string) || '';
deps.setCurrentSubText(nextSubText);
deps.emitSubtitleChange({
text: nextSubText,
isOverlayVisible: deps.isVisibleOverlayVisible(),
});
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) {
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
deps.emitMediaPathChange({ path: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_AID) {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();
} else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) {
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
deps.emitSecondarySubtitleChange({ text: nextSubText });
} else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) {
const previous = parseVisibilityProperty(msg.data);
if (previous !== null) {
deps.setPreviousSecondarySubVisibility(previous);
deps.emitSecondarySubtitleVisibility({ visible: previous });
}
deps.setSecondarySubVisibility(false);
} else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) {
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) {
deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) {
deps.emitSubtitleMetricsChange({ subScale: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) {
deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) {
deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) {
deps.emitSubtitleMetricsChange({ subFont: msg.data as string });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) {
deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) {
deps.emitSubtitleMetricsChange({
subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold),
});
} else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) {
deps.emitSubtitleMetricsChange({
subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic),
});
} else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) {
deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) {
deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) {
deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string });
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) {
deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
});
} else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) {
deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
});
} else if (msg.request_id === MPV_REQUEST_ID_PAUSE) {
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
} else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) {
deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) {
const dims = msg.data as Record<string, unknown> | null;
if (!dims) {
deps.emitSubtitleMetricsChange({ osdDimensions: null });
} else {
deps.emitSubtitleMetricsChange({
osdDimensions: {
w: asFiniteNumber(dims.w, 0),
h: asFiniteNumber(dims.h, 0),
ml: asFiniteNumber(dims.ml, 0),
mr: asFiniteNumber(dims.mr, 0),
mt: asFiniteNumber(dims.mt, 0),
mb: asFiniteNumber(dims.mb, 0),
},
});
}
}
}
}
export function asBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1'].includes(normalized)) return true;
if (['no', 'false', '0'].includes(normalized)) return false;
}
return fallback;
}
export function asFiniteNumber(value: unknown, fallback: number): number {
const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : fallback;
}
export function parseVisibilityProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
return null;
}

View File

@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MpvSubtitleRenderMetrics } from '../../types';
import {
applyMpvSubtitleRenderMetricsPatch,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
} from './mpv-render-metrics';
const BASE: MpvSubtitleRenderMetrics = {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
};
test('applyMpvSubtitleRenderMetricsPatch returns unchanged on empty patch', () => {
const { next, changed } = applyMpvSubtitleRenderMetricsPatch(BASE, {});
assert.equal(changed, false);
assert.deepEqual(next, BASE);
});
test('applyMpvSubtitleRenderMetricsPatch reports changed when patch modifies value', () => {
const { next, changed } = applyMpvSubtitleRenderMetricsPatch(BASE, {
subPos: 95,
});
assert.equal(changed, true);
assert.equal(next.subPos, 95);
});

View File

@@ -0,0 +1,99 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { asBoolean, asFiniteNumber, asString } from '../utils/coerce';
export const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 38,
subScale: 1,
subMarginY: 34,
subMarginX: 19,
subFont: 'sans-serif',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 2.5,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 720,
osdDimensions: null,
};
export function sanitizeMpvSubtitleRenderMetrics(
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics> | null | undefined,
): MpvSubtitleRenderMetrics {
if (!patch) return current;
return updateMpvSubtitleRenderMetrics(current, patch);
}
export function updateMpvSubtitleRenderMetrics(
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
): MpvSubtitleRenderMetrics {
const patchOsd = patch.osdDimensions;
const nextOsdDimensions =
patchOsd &&
typeof patchOsd.w === 'number' &&
typeof patchOsd.h === 'number' &&
typeof patchOsd.ml === 'number' &&
typeof patchOsd.mr === 'number' &&
typeof patchOsd.mt === 'number' &&
typeof patchOsd.mb === 'number'
? {
w: asFiniteNumber(patchOsd.w, 0, 1, 100000),
h: asFiniteNumber(patchOsd.h, 0, 1, 100000),
ml: asFiniteNumber(patchOsd.ml, 0, 0, 100000),
mr: asFiniteNumber(patchOsd.mr, 0, 0, 100000),
mt: asFiniteNumber(patchOsd.mt, 0, 0, 100000),
mb: asFiniteNumber(patchOsd.mb, 0, 0, 100000),
}
: patchOsd === null
? null
: current.osdDimensions;
return {
subPos: asFiniteNumber(patch.subPos, current.subPos, 0, 150),
subFontSize: asFiniteNumber(patch.subFontSize, current.subFontSize, 1, 200),
subScale: asFiniteNumber(patch.subScale, current.subScale, 0.1, 10),
subMarginY: asFiniteNumber(patch.subMarginY, current.subMarginY, 0, 200),
subMarginX: asFiniteNumber(patch.subMarginX, current.subMarginX, 0, 200),
subFont: asString(patch.subFont, current.subFont),
subSpacing: asFiniteNumber(patch.subSpacing, current.subSpacing, -100, 100),
subBold: asBoolean(patch.subBold, current.subBold),
subItalic: asBoolean(patch.subItalic, current.subItalic),
subBorderSize: asFiniteNumber(patch.subBorderSize, current.subBorderSize, 0, 100),
subShadowOffset: asFiniteNumber(patch.subShadowOffset, current.subShadowOffset, 0, 100),
subAssOverride: asString(patch.subAssOverride, current.subAssOverride),
subScaleByWindow: asBoolean(patch.subScaleByWindow, current.subScaleByWindow),
subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins),
osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000),
osdDimensions: nextOsdDimensions,
};
}
export function applyMpvSubtitleRenderMetricsPatch(
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
): { next: MpvSubtitleRenderMetrics; changed: boolean } {
const next = updateMpvSubtitleRenderMetrics(current, patch);
const changed =
next.subPos !== current.subPos ||
next.subFontSize !== current.subFontSize ||
next.subScale !== current.subScale ||
next.subMarginY !== current.subMarginY ||
next.subMarginX !== current.subMarginX ||
next.subFont !== current.subFont ||
next.subSpacing !== current.subSpacing ||
next.subBold !== current.subBold ||
next.subItalic !== current.subItalic ||
next.subBorderSize !== current.subBorderSize ||
next.subShadowOffset !== current.subShadowOffset ||
next.subAssOverride !== current.subAssOverride ||
next.subScaleByWindow !== current.subScaleByWindow ||
next.subUseMargins !== current.subUseMargins ||
next.osdHeight !== current.osdHeight ||
JSON.stringify(next.osdDimensions) !== JSON.stringify(current.osdDimensions);
return { next, changed };
}

View File

@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveCurrentAudioStreamIndex } from './mpv';
test('resolveCurrentAudioStreamIndex returns selected ff-index when no current track id', () => {
assert.equal(
resolveCurrentAudioStreamIndex(
[
{ type: 'audio', id: 1, selected: false, 'ff-index': 1 },
{ type: 'audio', id: 2, selected: true, 'ff-index': 3 },
],
null,
),
3,
);
});
test('resolveCurrentAudioStreamIndex prefers matching current audio track id', () => {
assert.equal(
resolveCurrentAudioStreamIndex(
[
{ type: 'audio', id: 1, selected: true, 'ff-index': 3 },
{ type: 'audio', id: 2, selected: false, 'ff-index': 6 },
],
2,
),
6,
);
});
test('resolveCurrentAudioStreamIndex returns null when tracks are not an array', () => {
assert.equal(resolveCurrentAudioStreamIndex(null, null), null);
});

View File

@@ -0,0 +1,223 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as net from 'node:net';
import { EventEmitter } from 'node:events';
import {
getMpvReconnectDelay,
MpvSocketMessagePayload,
MpvSocketTransport,
scheduleMpvReconnect,
} from './mpv-transport';
class FakeSocket extends EventEmitter {
public connectedPaths: string[] = [];
public writePayloads: string[] = [];
public destroyed = false;
connect(path: string): void {
this.connectedPaths.push(path);
setTimeout(() => {
this.emit('connect');
}, 0);
}
write(payload: string): boolean {
this.writePayloads.push(payload);
return true;
}
destroy(): void {
this.destroyed = true;
this.emit('close');
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
assert.equal(getMpvReconnectDelay(0, true), 1000);
assert.equal(getMpvReconnectDelay(1, true), 1000);
assert.equal(getMpvReconnectDelay(2, true), 2000);
assert.equal(getMpvReconnectDelay(4, true), 5000);
assert.equal(getMpvReconnectDelay(7, true), 10000);
assert.equal(getMpvReconnectDelay(0, false), 200);
assert.equal(getMpvReconnectDelay(2, false), 500);
assert.equal(getMpvReconnectDelay(4, false), 1000);
assert.equal(getMpvReconnectDelay(6, false), 2000);
});
test('scheduleMpvReconnect clears existing timer and increments attempt', () => {
const existing = {} as ReturnType<typeof setTimeout>;
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
const setTimers: Array<ReturnType<typeof setTimeout> | null> = [];
const calls: Array<{ attempt: number; delay: number }> = [];
let connected = 0;
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
cleared.push(timer);
};
const nextAttempt = scheduleMpvReconnect({
attempt: 3,
hasConnectedOnce: true,
getReconnectTimer: () => existing,
setReconnectTimer: (timer) => {
setTimers.push(timer);
},
onReconnectAttempt: (attempt, delay) => {
calls.push({ attempt, delay });
},
connect: () => {
connected += 1;
},
});
(globalThis as any).setTimeout = originalSetTimeout;
(globalThis as any).clearTimeout = originalClearTimeout;
assert.equal(nextAttempt, 4);
assert.equal(cleared.length, 1);
assert.equal(cleared[0]!, existing);
assert.equal(setTimers.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0]!.attempt, 4);
assert.equal(calls[0]!.delay, getMpvReconnectDelay(3, true));
assert.equal(connected, 1);
});
test('MpvSocketTransport connects and sends payloads over a live socket', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
const payload: MpvSocketMessagePayload = {
command: ['sub-seek', 1],
request_id: 1,
};
assert.equal(transport.send(payload), false);
transport.connect();
await wait();
assert.equal(events.includes('connect'), true);
assert.equal(transport.send(payload), true);
const fakeSocket = transport.getSocket() as unknown as FakeSocket;
assert.equal(fakeSocket.connectedPaths.at(0), '/tmp/mpv.sock');
assert.equal(fakeSocket.writePayloads.length, 1);
assert.equal(fakeSocket.writePayloads.at(0), `${JSON.stringify(payload)}\n`);
});
test('MpvSocketTransport reports lifecycle transitions and callback order', async () => {
const events: string[] = [];
const fakeError = new Error('boom');
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
transport.connect();
await wait();
const socket = transport.getSocket() as unknown as FakeSocket;
socket.emit('error', fakeError);
socket.emit('data', Buffer.from('{}'));
socket.destroy();
await wait();
assert.equal(events.includes('connect'), true);
assert.equal(events.includes('data'), true);
assert.equal(events.includes('error'), true);
assert.equal(events.includes('close'), true);
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(socket.destroyed, true);
});
test('MpvSocketTransport ignores connect requests while already connecting or connected', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
transport.connect();
transport.connect();
await wait();
assert.equal(events.includes('connect'), true);
const socket = transport.getSocket() as unknown as FakeSocket;
socket.emit('close');
await wait();
transport.connect();
await wait();
assert.equal(events.filter((entry) => entry === 'connect').length, 2);
});
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},
onClose: () => {},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
transport.connect();
await wait();
assert.equal(transport.isConnected, true);
transport.shutdown();
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null);
});

View File

@@ -0,0 +1,167 @@
import * as net from 'net';
export function getMpvReconnectDelay(attempt: number, hasConnectedOnce: boolean): number {
if (hasConnectedOnce) {
if (attempt < 2) {
return 1000;
}
if (attempt < 4) {
return 2000;
}
if (attempt < 7) {
return 5000;
}
return 10000;
}
if (attempt < 2) {
return 200;
}
if (attempt < 4) {
return 500;
}
if (attempt < 6) {
return 1000;
}
return 2000;
}
export interface MpvReconnectSchedulerDeps {
attempt: number;
hasConnectedOnce: boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
onReconnectAttempt: (attempt: number, delay: number) => void;
connect: () => void;
}
export function scheduleMpvReconnect(deps: MpvReconnectSchedulerDeps): number {
const reconnectTimer = deps.getReconnectTimer();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
const delay = getMpvReconnectDelay(deps.attempt, deps.hasConnectedOnce);
deps.setReconnectTimer(
setTimeout(() => {
deps.onReconnectAttempt(deps.attempt + 1, delay);
deps.connect();
}, delay),
);
return deps.attempt + 1;
}
export interface MpvSocketMessagePayload {
command: unknown[];
request_id?: number;
}
interface MpvSocketTransportEvents {
onConnect: () => void;
onData: (data: Buffer) => void;
onError: (error: Error) => void;
onClose: () => void;
}
export interface MpvSocketTransportOptions {
socketPath: string;
onConnect: () => void;
onData: (data: Buffer) => void;
onError: (error: Error) => void;
onClose: () => void;
socketFactory?: () => net.Socket;
}
export class MpvSocketTransport {
private socketPath: string;
private readonly callbacks: MpvSocketTransportEvents;
private readonly socketFactory: () => net.Socket;
private socketRef: net.Socket | null = null;
public socket: net.Socket | null = null;
public connected = false;
public connecting = false;
constructor(options: MpvSocketTransportOptions) {
this.socketPath = options.socketPath;
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
this.callbacks = {
onConnect: options.onConnect,
onData: options.onData,
onError: options.onError,
onClose: options.onClose,
};
}
setSocketPath(socketPath: string): void {
this.socketPath = socketPath;
}
connect(): void {
if (this.connected || this.connecting) {
return;
}
if (this.socketRef) {
this.socketRef.destroy();
}
this.connecting = true;
this.socketRef = this.socketFactory();
this.socket = this.socketRef;
this.socketRef.on('connect', () => {
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
this.socketRef.on('data', (data: Buffer) => {
this.callbacks.onData(data);
});
this.socketRef.on('error', (error: Error) => {
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
this.socketRef.on('close', () => {
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
this.socketRef.connect(this.socketPath);
}
send(payload: MpvSocketMessagePayload): boolean {
if (!this.connected || !this.socketRef) {
return false;
}
const message = JSON.stringify(payload) + '\n';
this.socketRef.write(message);
return true;
}
shutdown(): void {
if (this.socketRef) {
this.socketRef.destroy();
}
this.socketRef = null;
this.socket = null;
this.connected = false;
this.connecting = false;
}
getSocket(): net.Socket | null {
return this.socketRef;
}
get isConnected(): boolean {
return this.connected;
}
get isConnecting(): boolean {
return this.connecting;
}
}

View File

@@ -0,0 +1,388 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MpvIpcClient,
MpvIpcClientDeps,
MpvIpcClientProtocolDeps,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from './mpv';
import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol';
function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
return {
getResolvedConfig: () => ({}) as any,
autoStartOverlay: false,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
...overrides,
};
}
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
msg,
);
}
test('MpvIpcClient resolves pending request by request_id', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
let resolved: unknown = null;
(client as any).pendingRequests.set(1234, (msg: unknown) => {
resolved = msg;
});
await invokeHandleMessage(client, { request_id: 1234, data: 'ok' });
assert.deepEqual(resolved, { request_id: 1234, data: 'ok' });
assert.equal((client as any).pendingRequests.size, 0);
});
test('MpvIpcClient handles sub-text property change and broadcasts tokenized subtitle', async () => {
const events: Array<{ text: string; isOverlayVisible: boolean }> = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
client.on('subtitle-change', (payload) => {
events.push(payload);
});
await invokeHandleMessage(client, {
event: 'property-change',
name: 'sub-text',
data: '字幕',
});
assert.equal(events.length, 1);
assert.equal(events[0]!.text, '字幕');
assert.equal(events[0]!.isOverlayVisible, false);
});
test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const seen: Array<Record<string, unknown>> = [];
(client as any).handleMessage = (msg: Record<string, unknown>) => {
seen.push(msg);
};
(client as any).buffer =
'{"event":"property-change","name":"path","data":"a"}\n{"request_id":1,"data":"ok"}\n{"partial":';
(client as any).processBuffer();
assert.equal(seen.length, 2);
assert.equal(seen[0]!.name, 'path');
assert.equal(seen[1]!.request_id, 1);
assert.equal((client as any).buffer, '{"partial":');
});
test('MpvIpcClient request rejects when disconnected', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
await assert.rejects(async () => client.request(['get_property', 'path']), /MPV not connected/);
});
test('MpvIpcClient requestProperty throws on mpv error response', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).request = async () => ({ error: 'property unavailable' });
await assert.rejects(
async () => client.requestProperty('path'),
/Failed to read MPV property 'path': property unavailable/,
);
});
test('MpvIpcClient connect does not log connect-request at info level', () => {
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
const originalInfo = console.info;
const infoLines: string[] = [];
process.env.SUBMINER_LOG_LEVEL = 'info';
console.info = (message?: unknown) => {
infoLines.push(String(message ?? ''));
};
try {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).transport.connect = () => {};
client.connect();
} finally {
process.env.SUBMINER_LOG_LEVEL = originalLevel;
console.info = originalInfo;
}
const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.'));
assert.equal(requestLogs.length, 0);
});
test('MpvIpcClient connect logs connect-request at debug level', () => {
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
const originalDebug = console.debug;
const debugLines: string[] = [];
process.env.SUBMINER_LOG_LEVEL = 'debug';
console.debug = (message?: unknown) => {
debugLines.push(String(message ?? ''));
};
try {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).transport.connect = () => {};
client.connect();
} finally {
process.env.SUBMINER_LOG_LEVEL = originalLevel;
console.debug = originalDebug;
}
const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.'));
assert.equal(requestLogs.length, 1);
});
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = [];
(client as any).pendingRequests.set(10, (msg: unknown) => {
resolved.push(msg);
});
(client as any).pendingRequests.set(11, (msg: unknown) => {
resolved.push(msg);
});
(client as any).failPendingRequests();
assert.deepEqual(resolved, [
{ request_id: 10, error: 'disconnected' },
{ request_id: 11, error: 'disconnected' },
]);
assert.equal((client as any).pendingRequests.size, 0);
});
test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => null,
setReconnectTimer: (timer) => {
timers.push(timer);
},
}),
);
let connectCalled = false;
(client as any).connect = () => {
connectCalled = true;
};
const originalSetTimeout = globalThis.setTimeout;
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
try {
(client as any).scheduleReconnect();
} finally {
(globalThis as any).setTimeout = originalSetTimeout;
}
assert.equal(timers.length, 1);
assert.equal(connectCalled, true);
});
test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
const existingTimer = {} as ReturnType<typeof setTimeout>;
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => existingTimer,
setReconnectTimer: (timer) => {
timers.push(timer);
},
}),
);
let connectCalled = false;
(client as any).connect = () => {
connectCalled = true;
};
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
cleared.push(timer);
};
try {
(client as any).scheduleReconnect();
} finally {
(globalThis as any).setTimeout = originalSetTimeout;
(globalThis as any).clearTimeout = originalClearTimeout;
}
assert.equal(cleared.length, 1);
assert.equal(cleared[0], existingTimer);
assert.equal(timers.length, 1);
assert.equal(connectCalled, true);
});
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => null,
setReconnectTimer: (timer) => {
timers.push(timer);
},
}),
);
const resolved: Array<unknown> = [];
(client as any).pendingRequests.set(1, (message: unknown) => {
resolved.push(message);
});
let reconnectConnectCount = 0;
(client as any).connect = () => {
reconnectConnectCount += 1;
};
const originalSetTimeout = globalThis.setTimeout;
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
try {
(client as any).transport.callbacks.onClose();
} finally {
(globalThis as any).setTimeout = originalSetTimeout;
}
assert.equal(resolved.length, 1);
assert.deepEqual(resolved[0], { request_id: 1, error: 'disconnected' });
assert.equal(reconnectConnectCount, 1);
assert.equal(timers.length, 1);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
commands.length = 0;
callbacks.onConnect();
const hasSecondaryVisibilityReset = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'set_property' &&
(command as { command: unknown[] }).command[1] === 'secondary-sub-visibility' &&
(command as { command: unknown[] }).command[2] === 'no',
);
const hasTrackSubscription = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'observe_property' &&
(command as { command: unknown[] }).command[1] === 1 &&
(command as { command: unknown[] }).command[2] === 'sub-text',
);
const hasPathRequest = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'get_property' &&
(command as { command: unknown[] }).command[1] === 'path',
);
assert.equal(hasSecondaryVisibilityReset, true);
assert.equal(hasTrackSubscription, true);
assert.equal(hasPathRequest, true);
});
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const previous: boolean[] = [];
client.on('secondary-subtitle-visibility', ({ visible }) => {
previous.push(visible);
});
(client as any).send = (payload: unknown) => {
commands.push(payload);
return true;
};
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
data: 'yes',
});
assert.deepEqual(previous, [true]);
assert.deepEqual(commands, [
{
command: ['set_property', 'secondary-sub-visibility', 'no'],
},
]);
});
test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const previous: boolean[] = [];
client.on('secondary-subtitle-visibility', ({ visible }) => {
previous.push(visible);
});
(client as any).send = (payload: unknown) => {
commands.push(payload);
return true;
};
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
data: 'yes',
});
client.restorePreviousSecondarySubVisibility();
assert.equal(previous[0], true);
assert.equal(previous.length, 1);
assert.deepEqual(commands, [
{
command: ['set_property', 'secondary-sub-visibility', 'no'],
},
{
command: ['set_property', 'secondary-sub-visibility', 'yes'],
},
]);
client.restorePreviousSecondarySubVisibility();
assert.equal(commands.length, 2);
});
test('MpvIpcClient updates current audio stream index from track list', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
await invokeHandleMessage(client, {
event: 'property-change',
name: 'aid',
data: 3,
});
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
data: [
{ type: 'sub', id: 5 },
{ type: 'audio', id: 1, selected: false, 'ff-index': 7 },
{ type: 'audio', id: 3, selected: false, 'ff-index': 11 },
{ type: 'audio', id: 4, selected: true, 'ff-index': 9 },
],
});
assert.equal(client.currentAudioStreamIndex, 11);
});

496
src/core/services/mpv.ts Normal file
View File

@@ -0,0 +1,496 @@
import { EventEmitter } from 'events';
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from './mpv-protocol';
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
import { createLogger } from '../../logger';
const logger = createLogger('main:mpv');
export type MpvTrackProperty = {
type?: string;
id?: number;
selected?: boolean;
'ff-index'?: number;
};
export function resolveCurrentAudioStreamIndex(
tracks: Array<MpvTrackProperty> | null | undefined,
currentAudioTrackId: number | null,
): number | null {
if (!Array.isArray(tracks)) {
return null;
}
const audioTracks = tracks.filter((track) => track.type === 'audio');
const activeTrack =
audioTracks.find((track) => track.id === currentAudioTrackId) ||
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.['ff-index'];
return typeof ffIndex === 'number' && Number.isInteger(ffIndex) && ffIndex >= 0 ? ffIndex : null;
}
export interface MpvRuntimeClientLike {
connected: boolean;
send: (payload: { command: (string | number)[] }) => void;
replayCurrentSubtitle?: () => void;
playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void;
}
export function showMpvOsdRuntime(
mpvClient: MpvRuntimeClientLike | null,
text: string,
fallbackLog: (text: string) => void = (line) => logger.info(line),
): void {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ['show-text', text, '3000'] });
return;
}
fallbackLog(`OSD (MPV not connected): ${text}`);
}
export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.replayCurrentSubtitle) return;
mpvClient.replayCurrentSubtitle();
}
export function playNextSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.playNextSubtitle) return;
mpvClient.playNextSubtitle();
}
export function sendMpvCommandRuntime(
mpvClient: MpvRuntimeClientLike | null,
command: (string | number)[],
): void {
if (!mpvClient) return;
mpvClient.send({ command });
}
export function setMpvSubVisibilityRuntime(
mpvClient: MpvRuntimeClientLike | null,
visible: boolean,
): void {
if (!mpvClient?.setSubVisibility) return;
mpvClient.setSubVisibility(visible);
}
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
}
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap {
'connection-change': { connected: boolean };
'subtitle-change': { text: string; isOverlayVisible: boolean };
'subtitle-ass-change': { text: string };
'subtitle-timing': { text: string; start: number; end: number };
'time-pos-change': { time: number };
'pause-change': { paused: boolean };
'secondary-subtitle-change': { text: string };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean };
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter();
private buffer = '';
public connected = false;
private connecting = false;
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
public currentVideoPath = '';
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
public currentSubText = '';
public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null;
private currentAudioTrackId: number | null = null;
private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
private previousSecondarySubVisibility: boolean | null = null;
private pauseAtTime: number | null = null;
private pendingPauseAtSubEnd = false;
private nextDynamicRequestId = 1000;
private pendingRequests = new Map<number, (message: MpvMessage) => void>();
constructor(socketPath: string, deps: MpvIpcClientDeps) {
this.deps = deps;
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
logger.debug('Connected to MPV socket');
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
}
this.firstConnection = false;
},
onData: (data) => {
this.buffer += data.toString();
this.processBuffer();
},
onError: (err: Error) => {
logger.debug('MPV socket error:', err.message);
this.failPendingRequests();
},
onClose: () => {
logger.debug('MPV socket closed');
this.connected = false;
this.connecting = false;
this.socket = null;
this.emit('connection-change', { connected: false });
this.failPendingRequests();
this.scheduleReconnect();
},
});
}
on<EventName extends MpvIpcClientEventName>(
event: EventName,
listener: (payload: MpvIpcClientEventMap[EventName]) => void,
): void {
this.eventBus.on(event as string, listener);
}
off<EventName extends MpvIpcClientEventName>(
event: EventName,
listener: (payload: MpvIpcClientEventMap[EventName]) => void,
): void {
this.eventBus.off(event as string, listener);
}
private emit<EventName extends MpvIpcClientEventName>(
event: EventName,
payload: MpvIpcClientEventMap[EventName],
): void {
this.eventBus.emit(event as string, payload);
}
private emitSubtitleMetricsChange(patch: Partial<MpvSubtitleRenderMetrics>): void {
this.mpvSubtitleRenderMetrics = {
...this.mpvSubtitleRenderMetrics,
...patch,
};
this.emit('subtitle-metrics-change', { patch });
}
setSocketPath(socketPath: string): void {
this.transport.setSocketPath(socketPath);
}
connect(): void {
if (this.connected || this.connecting) {
logger.debug(
`MPV IPC connect request skipped; connected=${this.connected}, connecting=${this.connecting}`,
);
return;
}
logger.debug('MPV IPC connect requested.');
this.connecting = true;
this.transport.connect();
}
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
hasConnectedOnce: this.hasConnectedOnce,
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`);
},
connect: () => {
this.connect();
},
});
}
private processBuffer(): void {
const parsed = splitMpvMessagesFromBuffer(
this.buffer,
(message) => {
this.handleMessage(message);
},
(line, error) => {
logger.error('Failed to parse MPV message:', line, error);
},
);
this.buffer = parsed.nextBuffer;
}
private async handleMessage(msg: MpvMessage): Promise<void> {
await dispatchMpvProtocolMessage(msg, this.createProtocolMessageDeps());
}
private createProtocolMessageDeps(): MpvProtocolHandleMessageDeps {
return {
getResolvedConfig: () => this.deps.getResolvedConfig(),
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
emitSubtitleChange: (payload) => {
this.emit('subtitle-change', payload);
},
emitSubtitleAssChange: (payload) => {
this.emit('subtitle-ass-change', payload);
},
emitSubtitleTiming: (payload) => {
this.emit('subtitle-timing', payload);
},
emitTimePosChange: (payload) => {
this.emit('time-pos-change', payload);
},
emitPauseChange: (payload) => {
this.emit('pause-change', payload);
},
emitSecondarySubtitleChange: (payload) => {
this.emit('secondary-subtitle-change', payload);
},
getCurrentSubText: () => this.currentSubText,
setCurrentSubText: (text: string) => {
this.currentSubText = text;
},
setCurrentSubStart: (value: number) => {
this.currentSubStart = value;
},
getCurrentSubStart: () => this.currentSubStart,
setCurrentSubEnd: (value: number) => {
this.currentSubEnd = value;
},
getCurrentSubEnd: () => this.currentSubEnd,
emitMediaPathChange: (payload) => {
this.emit('media-path-change', payload);
},
emitMediaTitleChange: (payload) => {
this.emit('media-title-change', payload);
},
emitSubtitleMetricsChange: (patch) => {
this.emitSubtitleMetricsChange(patch);
},
setCurrentSecondarySubText: (text: string) => {
this.currentSecondarySubText = text;
},
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
this.tryResolvePendingRequest(requestId, message),
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
syncCurrentAudioStreamIndex: () => {
this.syncCurrentAudioStreamIndex();
},
setCurrentAudioTrackId: (value: number | null) => {
this.currentAudioTrackId = value;
},
setCurrentTimePos: (value: number) => {
this.currentTimePos = value;
},
getCurrentTimePos: () => this.currentTimePos,
getPendingPauseAtSubEnd: () => this.pendingPauseAtSubEnd,
setPendingPauseAtSubEnd: (value: boolean) => {
this.pendingPauseAtSubEnd = value;
},
getPauseAtTime: () => this.pauseAtTime,
setPauseAtTime: (value: number | null) => {
this.pauseAtTime = value;
},
autoLoadSecondarySubTrack: () => {
this.autoLoadSecondarySubTrack();
},
setCurrentVideoPath: (value: string) => {
this.currentVideoPath = value;
},
emitSecondarySubtitleVisibility: (payload) => {
this.emit('secondary-subtitle-visibility', payload);
},
setPreviousSecondarySubVisibility: (visible: boolean) => {
this.previousSecondarySubVisibility = visible;
},
setCurrentAudioStreamIndex: (tracks) => {
this.updateCurrentAudioStreamIndex(tracks);
},
sendCommand: (payload) => this.send(payload),
restorePreviousSecondarySubVisibility: () => {
this.restorePreviousSecondarySubVisibility();
},
};
}
private autoLoadSecondarySubTrack(): void {
const config = this.deps.getResolvedConfig();
if (!config.secondarySub?.autoLoadSecondarySub) return;
const languages = config.secondarySub.secondarySubLanguages;
if (!languages || languages.length === 0) return;
setTimeout(() => {
this.send({
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
});
}, 500);
}
private syncCurrentAudioStreamIndex(): void {
this.send({
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
});
}
private updateCurrentAudioStreamIndex(
tracks: Array<{
type?: string;
id?: number;
selected?: boolean;
'ff-index'?: number;
}>,
): void {
this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(tracks, this.currentAudioTrackId);
}
send(command: { command: unknown[]; request_id?: number }): boolean {
if (!this.connected || !this.socket) {
return false;
}
return this.transport.send(command);
}
request(command: unknown[]): Promise<MpvMessage> {
return new Promise((resolve, reject) => {
if (!this.connected || !this.socket) {
reject(new Error('MPV not connected'));
return;
}
const requestId = this.nextDynamicRequestId++;
this.pendingRequests.set(requestId, resolve);
const sent = this.send({ command, request_id: requestId });
if (!sent) {
this.pendingRequests.delete(requestId);
reject(new Error('Failed to send MPV request'));
return;
}
setTimeout(() => {
if (this.pendingRequests.delete(requestId)) {
reject(new Error('MPV request timed out'));
}
}, 4000);
});
}
async requestProperty(name: string): Promise<unknown> {
const response = await this.request(['get_property', name]);
if (response.error && response.error !== 'success') {
throw new Error(`Failed to read MPV property '${name}': ${response.error}`);
}
return response.data;
}
private failPendingRequests(): void {
for (const [requestId, resolve] of this.pendingRequests.entries()) {
resolve({ request_id: requestId, error: 'disconnected' });
}
this.pendingRequests.clear();
}
private tryResolvePendingRequest(requestId: number, message: MpvMessage): boolean {
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return false;
}
this.pendingRequests.delete(requestId);
pending(message);
return true;
}
setSubVisibility(visible: boolean): void {
this.send({
command: ['set_property', 'sub-visibility', visible ? 'yes' : 'no'],
});
}
replayCurrentSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ['sub-seek', 0] });
}
playNextSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ['sub-seek', 1] });
}
restorePreviousSecondarySubVisibility(): void {
const previous = this.previousSecondarySubVisibility;
if (previous === null) return;
this.send({
command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'],
});
this.previousSecondarySubVisibility = null;
}
private setSecondarySubVisibility(visible: boolean): void {
this.send({
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
});
}
}

View File

@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut';
test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => {
const registered: string[] = [];
const unregistered: string[] = [];
const osd: string[] = [];
const handlers = new Map<string, () => void>();
const runtime = createNumericShortcutRuntime({
globalShortcut: {
register: (accelerator, callback) => {
registered.push(accelerator);
handlers.set(accelerator, callback);
return true;
},
unregister: (accelerator) => {
unregistered.push(accelerator);
handlers.delete(accelerator);
},
},
showMpvOsd: (text) => {
osd.push(text);
},
setTimer: () => setTimeout(() => {}, 1000),
clearTimer: (timer) => clearTimeout(timer),
});
const session = runtime.createSession();
session.start({
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: 'Select count',
timeout: 'Timed out',
},
});
assert.equal(session.isActive(), true);
assert.ok(registered.includes('1'));
assert.ok(registered.includes('Escape'));
assert.equal(osd[0], 'Select count');
handlers.get('Escape')?.();
assert.equal(session.isActive(), false);
assert.ok(unregistered.includes('Escape'));
});
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
const handlers = new Map<string, () => void>();
const unregistered: string[] = [];
const osd: string[] = [];
const session = createNumericShortcutSession({
registerShortcut: (accelerator, handler) => {
handlers.set(accelerator, handler);
return true;
},
unregisterShortcut: (accelerator) => {
unregistered.push(accelerator);
handlers.delete(accelerator);
},
setTimer: () => setTimeout(() => {}, 0),
clearTimer: (timer) => clearTimeout(timer),
showMpvOsd: (text) => {
osd.push(text);
},
});
const digits: number[] = [];
session.start({
timeoutMs: 5000,
onDigit: (digit) => {
digits.push(digit);
},
messages: {
prompt: 'Pick a digit',
timeout: 'Timed out',
},
});
assert.equal(session.isActive(), true);
assert.equal(osd[0], 'Pick a digit');
assert.ok(handlers.has('3'));
handlers.get('3')?.();
assert.deepEqual(digits, [3]);
assert.equal(session.isActive(), false);
assert.ok(unregistered.includes('Escape'));
assert.ok(unregistered.includes('1'));
assert.ok(unregistered.includes('9'));
});
test('numeric shortcut session emits timeout message', () => {
const osd: string[] = [];
const session = createNumericShortcutSession({
registerShortcut: () => true,
unregisterShortcut: () => {},
setTimer: (handler) => {
handler();
return setTimeout(() => {}, 0);
},
clearTimer: (timer) => clearTimeout(timer),
showMpvOsd: (text) => {
osd.push(text);
},
});
session.start({
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: 'Pick a digit',
timeout: 'Timed out',
cancelled: 'Aborted',
},
});
assert.equal(session.isActive(), false);
assert.ok(osd.includes('Timed out'));
});
test('numeric shortcut session handles escape cancellation', () => {
const handlers = new Map<string, () => void>();
const osd: string[] = [];
const session = createNumericShortcutSession({
registerShortcut: (accelerator, handler) => {
handlers.set(accelerator, handler);
return true;
},
unregisterShortcut: (accelerator) => {
handlers.delete(accelerator);
},
setTimer: () => setTimeout(() => {}, 10000),
clearTimer: (timer) => clearTimeout(timer),
showMpvOsd: (text) => {
osd.push(text);
},
});
session.start({
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: 'Pick a digit',
timeout: 'Timed out',
cancelled: 'Aborted',
},
});
handlers.get('Escape')?.();
assert.equal(session.isActive(), false);
assert.ok(osd.includes('Aborted'));
});

View File

@@ -0,0 +1,121 @@
interface GlobalShortcutLike {
register: (accelerator: string, callback: () => void) => boolean;
unregister: (accelerator: string) => void;
}
export interface NumericShortcutRuntimeOptions {
globalShortcut: GlobalShortcutLike;
showMpvOsd: (text: string) => void;
setTimer: (handler: () => void, timeoutMs: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
}
export function createNumericShortcutRuntime(options: NumericShortcutRuntimeOptions) {
const createSession = () =>
createNumericShortcutSession({
registerShortcut: (accelerator, handler) =>
options.globalShortcut.register(accelerator, handler),
unregisterShortcut: (accelerator) => options.globalShortcut.unregister(accelerator),
setTimer: options.setTimer,
clearTimer: options.clearTimer,
showMpvOsd: options.showMpvOsd,
});
return {
createSession,
};
}
export interface NumericShortcutSessionMessages {
prompt: string;
timeout: string;
cancelled?: string;
}
export interface NumericShortcutSessionDeps {
registerShortcut: (accelerator: string, handler: () => void) => boolean;
unregisterShortcut: (accelerator: string) => void;
setTimer: (handler: () => void, timeoutMs: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
showMpvOsd: (text: string) => void;
}
export interface NumericShortcutSessionStartParams {
timeoutMs: number;
onDigit: (digit: number) => void;
messages: NumericShortcutSessionMessages;
}
export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) {
let active = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
let digitShortcuts: string[] = [];
let escapeShortcut: string | null = null;
let cancelledMessage = 'Cancelled';
const cancel = (showCancelled = false): void => {
if (!active) return;
active = false;
if (timeout) {
deps.clearTimer(timeout);
timeout = null;
}
for (const shortcut of digitShortcuts) {
deps.unregisterShortcut(shortcut);
}
digitShortcuts = [];
if (escapeShortcut) {
deps.unregisterShortcut(escapeShortcut);
escapeShortcut = null;
}
if (showCancelled) {
deps.showMpvOsd(cancelledMessage);
}
};
const start = ({ timeoutMs, onDigit, messages }: NumericShortcutSessionStartParams): void => {
cancel();
cancelledMessage = messages.cancelled ?? 'Cancelled';
active = true;
for (let i = 1; i <= 9; i++) {
const shortcut = i.toString();
if (
deps.registerShortcut(shortcut, () => {
if (!active) return;
cancel();
onDigit(i);
})
) {
digitShortcuts.push(shortcut);
}
}
if (
deps.registerShortcut('Escape', () => {
cancel(true);
})
) {
escapeShortcut = 'Escape';
}
timeout = deps.setTimer(() => {
if (!active) return;
cancel();
deps.showMpvOsd(messages.timeout);
}, timeoutMs);
deps.showMpvOsd(messages.prompt);
};
return {
start,
cancel,
isActive: (): boolean => active,
};
}

View File

@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice } from '../../types';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
const sent: unknown[][] = [];
const restoreSet = new Set<'runtime-options' | 'subsync'>();
let visibleOverlayVisible = false;
const ok = sendToVisibleOverlayRuntime({
mainWindow: {
isDestroyed: () => false,
webContents: {
isLoading: () => false,
send: (...args: unknown[]) => {
sent.push(args);
},
},
} as unknown as Electron.BrowserWindow,
visibleOverlayVisible,
setVisibleOverlayVisible: (visible: boolean) => {
visibleOverlayVisible = visible;
},
channel: 'runtime-options:open',
restoreOnModalClose: 'runtime-options',
restoreVisibleOverlayOnModalClose: restoreSet,
});
assert.equal(ok, true);
assert.equal(visibleOverlayVisible, true);
assert.equal(restoreSet.has('runtime-options'), true);
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (next) => {
resolver = next;
},
sendToVisibleOverlay: () => false,
});
const result = await callback({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
assert.equal(result.cancelled, true);
assert.equal(result.keepNoteId, 0);
assert.equal(result.deleteNoteId, 0);
});

View File

@@ -0,0 +1,68 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallback } from './field-grouping';
import { BrowserWindow } from 'electron';
export function sendToVisibleOverlayRuntime<T extends string>(options: {
mainWindow: BrowserWindow | null;
visibleOverlayVisible: boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
channel: string;
payload?: unknown;
restoreOnModalClose?: T;
restoreVisibleOverlayOnModalClose: Set<T>;
}): boolean {
if (!options.mainWindow || options.mainWindow.isDestroyed()) return false;
const wasVisible = options.visibleOverlayVisible;
if (!options.visibleOverlayVisible) {
options.setVisibleOverlayVisible(true);
}
if (!wasVisible && options.restoreOnModalClose) {
options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose);
}
const sendNow = (): void => {
if (options.payload === undefined) {
options.mainWindow!.webContents.send(options.channel);
} else {
options.mainWindow!.webContents.send(options.channel, options.payload);
}
};
if (options.mainWindow.webContents.isLoading()) {
options.mainWindow.webContents.once('did-finish-load', () => {
if (
options.mainWindow &&
!options.mainWindow.isDestroyed() &&
!options.mainWindow.webContents.isLoading()
) {
sendNow();
}
});
return true;
}
sendNow();
return true;
}
export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) =>
options.sendToVisibleOverlay('kiku:field-grouping-request', data),
});
}

View File

@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOverlayContentMeasurementStore,
sanitizeOverlayContentMeasurement,
} from './overlay-content-measurement';
test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
},
500,
);
assert.deepEqual(measurement, {
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
});
});
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'invisible',
measuredAtMs: 100,
viewport: { width: 0, height: 1080 },
contentRect: { x: 0, y: 0, width: 100, height: 20 },
},
500,
);
assert.equal(measurement, null);
});
test('overlay measurement store keeps latest payload per layer', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
// noop
},
});
const visible = store.report({
layer: 'visible',
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
});
const invisible = store.report({
layer: 'invisible',
measuredAtMs: 910,
viewport: { width: 1280, height: 720 },
contentRect: { x: 20, y: 30, width: 300, height: 40 },
});
assert.equal(visible?.layer, 'visible');
assert.equal(invisible?.layer, 'invisible');
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
assert.equal(store.getLatestByLayer('invisible')?.contentRect?.height, 40);
});
test('overlay measurement store rate-limits invalid payload warnings', () => {
let now = 1_000;
const warnings: string[] = [];
const store = createOverlayContentMeasurementStore({
now: () => now,
warn: (message) => {
warnings.push(message);
},
});
store.report({ layer: 'visible' });
store.report({ layer: 'visible' });
assert.equal(warnings.length, 0);
now = 11_000;
store.report({ layer: 'visible' });
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /Dropped 3 invalid measurement payload/);
});

View File

@@ -0,0 +1,148 @@
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-content-measurement');
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>;
export function sanitizeOverlayContentMeasurement(
payload: unknown,
nowMs: number,
): OverlayContentMeasurement | null {
if (!payload || typeof payload !== 'object') return null;
const candidate = payload as {
layer?: unknown;
measuredAtMs?: unknown;
viewport?: { width?: unknown; height?: unknown };
contentRect?: {
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
} | null;
};
if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') {
return null;
}
const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT);
const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT);
if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
return null;
}
const measuredAtMs = readFiniteInRange(
candidate.measuredAtMs,
1,
nowMs + MAX_FUTURE_TIMESTAMP_MS,
);
if (!Number.isFinite(measuredAtMs)) {
return null;
}
const contentRect = sanitizeOverlayContentRect(candidate.contentRect);
if (candidate.contentRect !== null && !contentRect) {
return null;
}
return {
layer: candidate.layer,
measuredAtMs,
viewport: { width: viewportWidth, height: viewportHeight },
contentRect,
};
}
function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
if (rect === null || rect === undefined) {
return null;
}
if (!rect || typeof rect !== 'object') {
return null;
}
const candidate = rect as {
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
};
const width = readFiniteInRange(candidate.width, 0, MAX_RECT_DIMENSION);
const height = readFiniteInRange(candidate.height, 0, MAX_RECT_DIMENSION);
const x = readFiniteInRange(candidate.x, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
const y = readFiniteInRange(candidate.y, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return null;
}
return { x, y, width, height };
}
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
}
if (value < min || value > max) {
return Number.NaN;
}
return value;
}
export function createOverlayContentMeasurementStore(options?: {
now?: () => number;
warn?: (message: string) => void;
}) {
const now = options?.now ?? (() => Date.now());
const warn = options?.warn ?? ((message: string) => logger.warn(message));
const latestByLayer: OverlayMeasurementStore = {
visible: null,
invisible: null,
};
let droppedInvalid = 0;
let lastInvalidLogAtMs = 0;
function report(payload: unknown): OverlayContentMeasurement | null {
const nowMs = now();
const measurement = sanitizeOverlayContentMeasurement(payload, nowMs);
if (!measurement) {
droppedInvalid += 1;
if (droppedInvalid > 0 && nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS) {
warn(
`[overlay-content-bounds] Dropped ${droppedInvalid} invalid measurement payload(s) in the last ${INVALID_LOG_THROTTLE_MS}ms.`,
);
droppedInvalid = 0;
lastInvalidLogAtMs = nowMs;
}
return null;
}
latestByLayer[measurement.layer] = measurement;
return measurement;
}
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null {
return latestByLayer[layer];
}
return {
getLatestByLayer,
report,
};
}

View File

@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMpvLoadfileCommands,
collectDroppedVideoPaths,
parseClipboardVideoPath,
type DropDataTransferLike,
} from './overlay-drop';
function makeTransfer(data: Partial<DropDataTransferLike>): DropDataTransferLike {
return {
files: data.files,
getData: data.getData,
};
}
test('collectDroppedVideoPaths keeps supported dropped file paths in order', () => {
const transfer = makeTransfer({
files: [
{ path: '/videos/ep02.mkv' },
{ path: '/videos/notes.txt' },
{ path: '/videos/ep03.MP4' },
],
});
const result = collectDroppedVideoPaths(transfer);
assert.deepEqual(result, ['/videos/ep02.mkv', '/videos/ep03.MP4']);
});
test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', () => {
const transfer = makeTransfer({
getData: (format: string) =>
format === 'text/uri-list'
? '#comment\nfile:///tmp/ep01.mkv\nfile:///tmp/ep01.mkv\nfile:///tmp/ep02.webm\nfile:///tmp/readme.md\n'
: '',
});
const result = collectDroppedVideoPaths(transfer);
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
});
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
assert.deepEqual(commands, [
['loadfile', '/tmp/ep01.mkv', 'replace'],
['loadfile', '/tmp/ep02.mkv', 'append'],
]);
});
test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], true);
assert.deepEqual(commands, [
['loadfile', '/tmp/ep01.mkv', 'append'],
['loadfile', '/tmp/ep02.mkv', 'append'],
]);
});
test('parseClipboardVideoPath accepts quoted local paths', () => {
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
});
test('parseClipboardVideoPath accepts file URI and rejects non-video', () => {
assert.equal(parseClipboardVideoPath('file:///tmp/ep11.mp4'), '/tmp/ep11.mp4');
assert.equal(parseClipboardVideoPath('/tmp/notes.txt'), null);
});

View File

@@ -0,0 +1,130 @@
export type DropFileLike = { path?: string } | { name: string };
export interface DropDataTransferLike {
files?: ArrayLike<DropFileLike>;
getData?: (format: string) => string;
}
const VIDEO_EXTENSIONS = new Set([
'.3gp',
'.avi',
'.flv',
'.m2ts',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.webm',
'.wmv',
]);
function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.');
return dot >= 0 ? normalized.slice(dot).toLowerCase() : '';
}
function isSupportedVideoPath(pathValue: string): boolean {
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
}
function parseUriList(data: string): string[] {
if (!data.trim()) return [];
const out: string[] = [];
for (const line of data.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.toLowerCase().startsWith('file://')) continue;
try {
const parsed = new URL(trimmed);
let filePath = decodeURIComponent(parsed.pathname);
if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
if (filePath && isSupportedVideoPath(filePath)) {
out.push(filePath);
}
} catch {
continue;
}
}
return out;
}
export function parseClipboardVideoPath(text: string): string | null {
const trimmed = text.trim();
if (!trimmed) return null;
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1).trim()
: trimmed;
if (!unquoted) return null;
if (unquoted.toLowerCase().startsWith('file://')) {
try {
const parsed = new URL(unquoted);
let filePath = decodeURIComponent(parsed.pathname);
if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
return filePath && isSupportedVideoPath(filePath) ? filePath : null;
} catch {
return null;
}
}
return isSupportedVideoPath(unquoted) ? unquoted : null;
}
export function collectDroppedVideoPaths(dataTransfer: DropDataTransferLike | null | undefined): string[] {
if (!dataTransfer) return [];
const out: string[] = [];
const seen = new Set<string>();
const addPath = (candidate: string | null | undefined): void => {
if (!candidate) return;
const trimmed = candidate.trim();
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
seen.add(trimmed);
out.push(trimmed);
};
if (dataTransfer.files) {
for (let i = 0; i < dataTransfer.files.length; i += 1) {
const file = dataTransfer.files[i] as { path?: string } | undefined;
addPath(file?.path);
}
}
if (typeof dataTransfer.getData === 'function') {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
addPath(pathValue);
}
}
return out;
}
export function buildMpvLoadfileCommands(
paths: string[],
append: boolean,
): Array<(string | number)[]> {
if (append) {
return paths.map((pathValue) => ['loadfile', pathValue, 'append']);
}
return paths.map((pathValue, index) => [
'loadfile',
pathValue,
index === 0 ? 'replace' : 'append',
]);
}

View File

@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager';
test('overlay manager initializes with empty windows and hidden overlays', () => {
const manager = createOverlayManager();
assert.equal(manager.getMainWindow(), null);
assert.equal(manager.getInvisibleWindow(), null);
assert.equal(manager.getSecondaryWindow(), null);
assert.equal(manager.getVisibleOverlayVisible(), false);
assert.equal(manager.getInvisibleOverlayVisible(), false);
assert.deepEqual(manager.getOverlayWindows(), []);
});
test('overlay manager stores window references and returns stable window order', () => {
const manager = createOverlayManager();
const visibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
});
test('overlay manager excludes destroyed windows', () => {
const manager = createOverlayManager();
manager.setMainWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
manager.setInvisibleWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
manager.setSecondaryWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1);
});
test('overlay manager stores visibility state', () => {
const manager = createOverlayManager();
manager.setVisibleOverlayVisible(true);
manager.setInvisibleOverlayVisible(true);
assert.equal(manager.getVisibleOverlayVisible(), true);
assert.equal(manager.getInvisibleOverlayVisible(), true);
});
test('overlay manager broadcasts to non-destroyed windows', () => {
const manager = createOverlayManager();
const calls: unknown[][] = [];
const aliveWindow = {
isDestroyed: () => false,
webContents: {
send: (...args: unknown[]) => {
calls.push(args);
},
},
} as unknown as Electron.BrowserWindow;
const deadWindow = {
isDestroyed: () => true,
webContents: {
send: (..._args: unknown[]) => {},
},
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
webContents: {
send: (...args: unknown[]) => {
calls.push(args);
},
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(aliveWindow);
manager.setInvisibleWindow(deadWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [
['x', 1, 'a'],
['x', 1, 'a'],
]);
});
test('overlay manager applies bounds by layer', () => {
const manager = createOverlayManager();
const visibleCalls: Electron.Rectangle[] = [];
const invisibleCalls: Electron.Rectangle[] = [];
const visibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
visibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const secondaryWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setOverlayWindowBounds('visible', {
x: 10,
y: 20,
width: 30,
height: 40,
});
manager.setOverlayWindowBounds('invisible', {
x: 1,
y: 2,
width: 3,
height: 4,
});
manager.setSecondaryWindowBounds({
x: 8,
y: 9,
width: 10,
height: 11,
});
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
assert.deepEqual(invisibleCalls, [
{ x: 1, y: 2, width: 3, height: 4 },
{ x: 8, y: 9, width: 10, height: 11 },
]);
});
test('runtime-option and debug broadcasts use expected channels', () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime(
() => [],
(channel, ...args) => {
broadcasts.push([channel, ...args]);
},
);
let state = false;
const changed = setOverlayDebugVisualizationEnabledRuntime(
state,
true,
(enabled) => {
state = enabled;
},
(channel, ...args) => {
broadcasts.push([channel, ...args]);
},
);
assert.equal(changed, true);
assert.equal(state, true);
assert.deepEqual(broadcasts, [
['runtime-options:changed', []],
['overlay-debug-visualization:set', true],
]);
});

View File

@@ -0,0 +1,108 @@
import { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
type OverlayLayer = 'visible' | 'invisible';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getSecondaryWindow: () => BrowserWindow | null;
setSecondaryWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
setInvisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[];
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}
export function createOverlayManager(): OverlayManager {
let mainWindow: BrowserWindow | null = null;
let invisibleWindow: BrowserWindow | null = null;
let secondaryWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false;
let invisibleOverlayVisible = false;
return {
getMainWindow: () => mainWindow,
setMainWindow: (window) => {
mainWindow = window;
},
getInvisibleWindow: () => invisibleWindow,
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getSecondaryWindow: () => secondaryWindow,
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
},
setSecondaryWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, secondaryWindow);
},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
},
getInvisibleOverlayVisible: () => invisibleOverlayVisible,
setInvisibleOverlayVisible: (visible) => {
invisibleOverlayVisible = visible;
},
getOverlayWindows: () => {
const windows: BrowserWindow[] = [];
if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
return windows;
},
broadcastToOverlayWindows: (channel, ...args) => {
const windows: BrowserWindow[] = [];
if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
for (const window of windows) {
window.webContents.send(channel, ...args);
}
},
};
}
export function broadcastRuntimeOptionsChangedRuntime(
getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): void {
broadcastToOverlayWindows('runtime-options:changed', getRuntimeOptionsState());
}
export function setOverlayDebugVisualizationEnabledRuntime(
currentEnabled: boolean,
nextEnabled: boolean,
setState: (enabled: boolean) => void,
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): boolean {
if (currentEnabled === nextEnabled) return false;
setState(nextEnabled);
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
return true;
}

View File

@@ -0,0 +1,107 @@
import { BrowserWindow } from 'electron';
import { AnkiIntegration } from '../../anki-integration';
import { BaseWindowTracker, createWindowTracker } from '../../window-trackers';
import {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from '../../types';
export function initializeOverlayRuntime(options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => {
send?: (payload: { command: string[] }) => void;
} | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}): {
invisibleOverlayVisible: boolean;
} {
options.createMainWindow();
options.createInvisibleWindow();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
}
if (options.isInvisibleOverlayVisible()) {
options.updateInvisibleOverlayVisibility();
}
};
windowTracker.onWindowLost = () => {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
};
windowTracker.start();
}
const config = options.getResolvedConfig();
const subtitleTimingTracker = options.getSubtitleTimingTracker();
const mpvClient = options.getMpvClient();
const runtimeOptionsManager = options.getRuntimeOptionsManager();
if (config.ankiConnect && subtitleTimingTracker && mpvClient && runtimeOptionsManager) {
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
config.ankiConnect,
);
const integration = new AnkiIntegration(
effectiveAnkiConfig,
subtitleTimingTracker as never,
mpvClient as never,
(text: string) => {
if (mpvClient && typeof mpvClient.send === 'function') {
mpvClient.send({
command: ['show-text', text, '3000'],
});
}
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(),
);
integration.start();
options.setAnkiIntegration(integration);
}
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
return { invisibleOverlayVisible };
}

View File

@@ -0,0 +1,282 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ConfiguredShortcuts } from '../utils/shortcut-config';
import {
createOverlayShortcutRuntimeHandlers,
OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback,
} from './overlay-shortcut-handler';
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
...overrides,
};
}
function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
const calls: string[] = [];
const osd: string[] = [];
const deps: OverlayShortcutRuntimeDeps = {
showMpvOsd: (text) => {
osd.push(text);
},
openRuntimeOptions: () => {
calls.push('openRuntimeOptions');
},
openJimaku: () => {
calls.push('openJimaku');
},
markAudioCard: async () => {
calls.push('markAudioCard');
},
copySubtitleMultiple: (timeoutMs) => {
calls.push(`copySubtitleMultiple:${timeoutMs}`);
},
copySubtitle: () => {
calls.push('copySubtitle');
},
toggleSecondarySub: () => {
calls.push('toggleSecondarySub');
},
updateLastCardFromClipboard: async () => {
calls.push('updateLastCardFromClipboard');
},
triggerFieldGrouping: async () => {
calls.push('triggerFieldGrouping');
},
triggerSubsync: async () => {
calls.push('triggerSubsync');
},
mineSentence: async () => {
calls.push('mineSentence');
},
mineSentenceMultiple: (timeoutMs) => {
calls.push(`mineSentenceMultiple:${timeoutMs}`);
},
...overrides,
};
return { deps, calls, osd };
}
test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', async () => {
const { deps, calls } = createDeps();
const { overlayHandlers, fallbackHandlers } = createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.copySubtitle();
overlayHandlers.copySubtitleMultiple(1111);
overlayHandlers.toggleSecondarySub();
overlayHandlers.openRuntimeOptions();
overlayHandlers.openJimaku();
overlayHandlers.mineSentenceMultiple(2222);
overlayHandlers.updateLastCardFromClipboard();
fallbackHandlers.mineSentence();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
'copySubtitle',
'copySubtitleMultiple:1111',
'toggleSecondarySub',
'openRuntimeOptions',
'openJimaku',
'mineSentenceMultiple:2222',
'updateLastCardFromClipboard',
'mineSentence',
]);
});
test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => {
const logs: unknown[][] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
logs.push(args);
};
try {
const { deps, osd } = createDeps({
markAudioCard: async () => {
throw new Error('audio boom');
},
});
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.markAudioCard();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(typeof logs[0]?.[0], 'string');
assert.ok(String(logs[0]?.[0]).includes('markLastCardAsAudioCard failed:'));
assert.ok(String(logs[0]?.[0]).includes('audio boom'));
assert.ok(osd.some((entry) => entry.includes('Audio card failed: audio boom')));
} finally {
console.error = originalError;
}
});
test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => {
const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: 'Ctrl+M',
multiCopyTimeoutMs: 4321,
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === 'Ctrl+M';
},
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
toggleSecondarySub: () => handled.push('toggleSecondarySub'),
updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'),
triggerFieldGrouping: () => handled.push('triggerFieldGrouping'),
triggerSubsync: () => handled.push('triggerSubsync'),
mineSentence: () => handled.push('mineSentence'),
mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`),
},
);
assert.equal(result, true);
assert.deepEqual(handled, ['copySubtitleMultiple:4321']);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+M', allowWhenRegistered: false }]);
});
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
toggleSecondarySub: 'Ctrl+2',
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === 'Ctrl+2';
},
{
openRuntimeOptions: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
toggleSecondarySub: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
},
);
assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]);
});
test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
openJimaku: 'Ctrl+J',
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === 'Ctrl+J';
},
{
openRuntimeOptions: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
toggleSecondarySub: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
},
);
assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]);
});
test('runOverlayShortcutLocalFallback returns false when no action matches', () => {
const shortcuts = makeShortcuts({
copySubtitle: 'Ctrl+C',
});
let called = false;
const result = runOverlayShortcutLocalFallback({} as Electron.Input, shortcuts, () => false, {
openRuntimeOptions: () => {
called = true;
},
openJimaku: () => {
called = true;
},
markAudioCard: () => {
called = true;
},
copySubtitleMultiple: () => {
called = true;
},
copySubtitle: () => {
called = true;
},
toggleSecondarySub: () => {
called = true;
},
updateLastCardFromClipboard: () => {
called = true;
},
triggerFieldGrouping: () => {
called = true;
},
triggerSubsync: () => {
called = true;
},
mineSentence: () => {
called = true;
},
mineSentenceMultiple: () => {
called = true;
},
});
assert.equal(result, false);
assert.equal(called, false);
});

View File

@@ -0,0 +1,208 @@
import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { OverlayShortcutHandlers } from './overlay-shortcut';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openJimaku: () => void;
markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
toggleSecondarySub: () => void;
updateLastCardFromClipboard: () => void;
triggerFieldGrouping: () => void;
triggerSubsync: () => void;
mineSentence: () => void;
mineSentenceMultiple: (timeoutMs: number) => void;
}
export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
toggleSecondarySub: () => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsync: () => Promise<void>;
mineSentence: () => Promise<void>;
mineSentenceMultiple: (timeoutMs: number) => void;
}
function wrapAsync(
task: () => Promise<void>,
deps: OverlayShortcutRuntimeDeps,
logLabel: string,
osdLabel: string,
): () => void {
return () => {
task().catch((err) => {
logger.error(`${logLabel} failed:`, err);
deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`);
});
};
}
export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntimeDeps): {
overlayHandlers: OverlayShortcutHandlers;
fallbackHandlers: OverlayShortcutFallbackHandlers;
} {
const overlayHandlers: OverlayShortcutHandlers = {
copySubtitle: () => {
deps.copySubtitle();
},
copySubtitleMultiple: (timeoutMs) => {
deps.copySubtitleMultiple(timeoutMs);
},
updateLastCardFromClipboard: wrapAsync(
() => deps.updateLastCardFromClipboard(),
deps,
'updateLastCardFromClipboard',
'Update failed',
),
triggerFieldGrouping: wrapAsync(
() => deps.triggerFieldGrouping(),
deps,
'triggerFieldGrouping',
'Field grouping failed',
),
triggerSubsync: wrapAsync(
() => deps.triggerSubsync(),
deps,
'triggerSubsyncFromConfig',
'Subsync failed',
),
mineSentence: wrapAsync(
() => deps.mineSentence(),
deps,
'mineSentenceCard',
'Mine sentence failed',
),
mineSentenceMultiple: (timeoutMs) => {
deps.mineSentenceMultiple(timeoutMs);
},
toggleSecondarySub: () => deps.toggleSecondarySub(),
markAudioCard: wrapAsync(
() => deps.markAudioCard(),
deps,
'markLastCardAsAudioCard',
'Audio card failed',
),
openRuntimeOptions: () => {
deps.openRuntimeOptions();
},
openJimaku: () => {
deps.openJimaku();
},
};
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle,
toggleSecondarySub: overlayHandlers.toggleSecondarySub,
updateLastCardFromClipboard: overlayHandlers.updateLastCardFromClipboard,
triggerFieldGrouping: overlayHandlers.triggerFieldGrouping,
triggerSubsync: overlayHandlers.triggerSubsync,
mineSentence: overlayHandlers.mineSentence,
mineSentenceMultiple: overlayHandlers.mineSentenceMultiple,
};
return { overlayHandlers, fallbackHandlers };
}
export function runOverlayShortcutLocalFallback(
input: Electron.Input,
shortcuts: ConfiguredShortcuts,
matcher: (input: Electron.Input, accelerator: string, allowWhenRegistered?: boolean) => boolean,
handlers: OverlayShortcutFallbackHandlers,
): boolean {
const actions: Array<{
accelerator: string | null | undefined;
run: () => void;
allowWhenRegistered?: boolean;
}> = [
{
accelerator: shortcuts.openRuntimeOptions,
run: () => {
handlers.openRuntimeOptions();
},
},
{
accelerator: shortcuts.openJimaku,
run: () => {
handlers.openJimaku();
},
allowWhenRegistered: true,
},
{
accelerator: shortcuts.markAudioCard,
run: () => {
handlers.markAudioCard();
},
},
{
accelerator: shortcuts.copySubtitleMultiple,
run: () => {
handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs);
},
},
{
accelerator: shortcuts.copySubtitle,
run: () => {
handlers.copySubtitle();
},
},
{
accelerator: shortcuts.toggleSecondarySub,
run: () => handlers.toggleSecondarySub(),
allowWhenRegistered: true,
},
{
accelerator: shortcuts.updateLastCardFromClipboard,
run: () => {
handlers.updateLastCardFromClipboard();
},
},
{
accelerator: shortcuts.triggerFieldGrouping,
run: () => {
handlers.triggerFieldGrouping();
},
},
{
accelerator: shortcuts.triggerSubsync,
run: () => {
handlers.triggerSubsync();
},
},
{
accelerator: shortcuts.mineSentence,
run: () => {
handlers.mineSentence();
},
},
{
accelerator: shortcuts.mineSentenceMultiple,
run: () => {
handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs);
},
},
];
for (const action of actions) {
if (!action.accelerator) continue;
if (matcher(input, action.accelerator, action.allowWhenRegistered === true)) {
action.run();
return true;
}
}
return false;
}

View File

@@ -0,0 +1,198 @@
import { globalShortcut } from 'electron';
import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-shortcut-service');
export interface OverlayShortcutHandlers {
copySubtitle: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => void;
triggerFieldGrouping: () => void;
triggerSubsync: () => void;
mineSentence: () => void;
mineSentenceMultiple: (timeoutMs: number) => void;
toggleSecondarySub: () => void;
markAudioCard: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
}
export interface OverlayShortcutLifecycleDeps {
getConfiguredShortcuts: () => ConfiguredShortcuts;
getOverlayHandlers: () => OverlayShortcutHandlers;
cancelPendingMultiCopy: () => void;
cancelPendingMineSentenceMultiple: () => void;
}
export function registerOverlayShortcuts(
shortcuts: ConfiguredShortcuts,
handlers: OverlayShortcutHandlers,
): boolean {
let registeredAny = false;
const registerOverlayShortcut = (
accelerator: string,
handler: () => void,
label: string,
): void => {
if (isGlobalShortcutRegisteredSafe(accelerator)) {
registeredAny = true;
return;
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
return;
}
registeredAny = true;
};
if (shortcuts.copySubtitleMultiple) {
registerOverlayShortcut(
shortcuts.copySubtitleMultiple,
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
'copySubtitleMultiple',
);
}
if (shortcuts.copySubtitle) {
registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle');
}
if (shortcuts.triggerFieldGrouping) {
registerOverlayShortcut(
shortcuts.triggerFieldGrouping,
() => handlers.triggerFieldGrouping(),
'triggerFieldGrouping',
);
}
if (shortcuts.triggerSubsync) {
registerOverlayShortcut(
shortcuts.triggerSubsync,
() => handlers.triggerSubsync(),
'triggerSubsync',
);
}
if (shortcuts.mineSentence) {
registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence');
}
if (shortcuts.mineSentenceMultiple) {
registerOverlayShortcut(
shortcuts.mineSentenceMultiple,
() => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs),
'mineSentenceMultiple',
);
}
if (shortcuts.toggleSecondarySub) {
registerOverlayShortcut(
shortcuts.toggleSecondarySub,
() => handlers.toggleSecondarySub(),
'toggleSecondarySub',
);
}
if (shortcuts.updateLastCardFromClipboard) {
registerOverlayShortcut(
shortcuts.updateLastCardFromClipboard,
() => handlers.updateLastCardFromClipboard(),
'updateLastCardFromClipboard',
);
}
if (shortcuts.markAudioCard) {
registerOverlayShortcut(
shortcuts.markAudioCard,
() => handlers.markAudioCard(),
'markAudioCard',
);
}
if (shortcuts.openRuntimeOptions) {
registerOverlayShortcut(
shortcuts.openRuntimeOptions,
() => handlers.openRuntimeOptions(),
'openRuntimeOptions',
);
}
if (shortcuts.openJimaku) {
registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku');
}
return registeredAny;
}
export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void {
if (shortcuts.copySubtitle) {
globalShortcut.unregister(shortcuts.copySubtitle);
}
if (shortcuts.copySubtitleMultiple) {
globalShortcut.unregister(shortcuts.copySubtitleMultiple);
}
if (shortcuts.updateLastCardFromClipboard) {
globalShortcut.unregister(shortcuts.updateLastCardFromClipboard);
}
if (shortcuts.triggerFieldGrouping) {
globalShortcut.unregister(shortcuts.triggerFieldGrouping);
}
if (shortcuts.triggerSubsync) {
globalShortcut.unregister(shortcuts.triggerSubsync);
}
if (shortcuts.mineSentence) {
globalShortcut.unregister(shortcuts.mineSentence);
}
if (shortcuts.mineSentenceMultiple) {
globalShortcut.unregister(shortcuts.mineSentenceMultiple);
}
if (shortcuts.toggleSecondarySub) {
globalShortcut.unregister(shortcuts.toggleSecondarySub);
}
if (shortcuts.markAudioCard) {
globalShortcut.unregister(shortcuts.markAudioCard);
}
if (shortcuts.openRuntimeOptions) {
globalShortcut.unregister(shortcuts.openRuntimeOptions);
}
if (shortcuts.openJimaku) {
globalShortcut.unregister(shortcuts.openJimaku);
}
}
export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean {
return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers());
}
export function unregisterOverlayShortcutsRuntime(
shortcutsRegistered: boolean,
deps: OverlayShortcutLifecycleDeps,
): boolean {
if (!shortcutsRegistered) return shortcutsRegistered;
deps.cancelPendingMultiCopy();
deps.cancelPendingMineSentenceMultiple();
unregisterOverlayShortcuts(deps.getConfiguredShortcuts());
return false;
}
export function syncOverlayShortcutsRuntime(
shouldBeActive: boolean,
shortcutsRegistered: boolean,
deps: OverlayShortcutLifecycleDeps,
): boolean {
if (shouldBeActive) {
return registerOverlayShortcutsRuntime(deps);
}
return unregisterOverlayShortcutsRuntime(shortcutsRegistered, deps);
}
export function refreshOverlayShortcutsRuntime(
shouldBeActive: boolean,
shortcutsRegistered: boolean,
deps: OverlayShortcutLifecycleDeps,
): boolean {
const cleared = unregisterOverlayShortcutsRuntime(shortcutsRegistered, deps);
return syncOverlayShortcutsRuntime(shouldBeActive, cleared, deps);
}

View File

@@ -0,0 +1,176 @@
import { BrowserWindow, screen } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return;
}
if (!args.visibleOverlayVisible) {
args.mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
}
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
return;
}
if (!args.windowTracker) {
args.setTrackerNotReadyWarningShown(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
return;
}
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
}
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
args.updateVisibleOverlayBounds({
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
});
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
}
export function updateInvisibleOverlayVisibility(args: {
invisibleWindow: BrowserWindow | null;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
windowTracker: BaseWindowTracker | null;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}): void {
if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) {
return;
}
if (args.visibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showInvisibleWithoutFocus = (): void => {
args.ensureOverlayWindowLevel(args.invisibleWindow!);
if (typeof args.invisibleWindow!.showInactive === 'function') {
args.invisibleWindow!.showInactive();
} else {
args.invisibleWindow!.show();
}
args.enforceOverlayLayerOrder();
};
if (!args.invisibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateInvisibleOverlayBounds(geometry);
}
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
if (!args.windowTracker) {
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
args.updateInvisibleOverlayBounds({
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
});
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
}
export function syncInvisibleOverlayMousePassthrough(options: {
hasInvisibleWindow: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
}): void {
if (!options.hasInvisibleWindow()) return;
if (options.visibleOverlayVisible) {
options.setIgnoreMouseEvents(true, { forward: true });
} else if (options.invisibleOverlayVisible) {
options.setIgnoreMouseEvents(false);
}
}
export function setVisibleOverlayVisible(options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}): void {
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
options.setMpvSubVisibility(!options.visible);
}
}
export function setInvisibleOverlayVisible(options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}): void {
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
}

View File

@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m);
});

View File

@@ -0,0 +1,41 @@
import type { WindowGeometry } from '../../types';
export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2;
function toInteger(value: number): number {
return Number.isFinite(value) ? Math.round(value) : 0;
}
function clampPositive(value: number): number {
return Math.max(1, toInteger(value));
}
export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): {
secondary: WindowGeometry;
primary: WindowGeometry;
} {
const x = toInteger(geometry.x);
const y = toInteger(geometry.y);
const width = clampPositive(geometry.width);
const totalHeight = clampPositive(geometry.height);
const secondaryHeight = clampPositive(
Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)),
);
const primaryHeight = clampPositive(totalHeight - secondaryHeight);
return {
secondary: {
x,
y,
width,
height: secondaryHeight,
},
primary: {
x,
y: y + secondaryHeight,
width,
height: primaryHeight,
},
};
}

View File

@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry';
test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 100,
y: 50,
width: 1200,
height: 900,
});
assert.deepEqual(regions.secondary, {
x: 100,
y: 50,
width: 1200,
height: 180,
});
assert.deepEqual(regions.primary, {
x: 100,
y: 230,
width: 1200,
height: 720,
});
});
test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 0,
y: 0,
width: 300,
height: 1,
});
assert.ok(regions.secondary.height >= 1);
assert.ok(regions.primary.height >= 1);
});

View File

@@ -0,0 +1,140 @@
import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window');
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary';
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
window: BrowserWindow | null,
): void {
if (!geometry || !window || window.isDestroyed()) return;
window.setBounds({
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
});
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
window.setFullScreenable(false);
return;
}
window.setAlwaysOnTop(true);
}
export function enforceOverlayLayerOrder(options: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: BrowserWindow | null;
invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
options.ensureOverlayWindowLevel(options.mainWindow);
options.mainWindow.moveTop();
}
export function createOverlayWindow(
kind: OverlayWindowKind,
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (kind: OverlayWindowKind) => void;
},
): BrowserWindow {
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
additionalArguments: [`--overlay-layer=${kind}`],
},
});
options.ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
window
.loadFile(htmlPath, {
query: { layer: kind },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
});
window.webContents.on('did-finish-load', () => {
options.onRuntimeOptionsChanged();
window.webContents.send(
'overlay-debug-visualization:set',
options.overlayDebugVisualizationEnabled,
);
});
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {
options.setOverlayDebugVisualizationEnabled(true);
});
window.webContents.on('devtools-closed', () => {
options.setOverlayDebugVisualizationEnabled(false);
});
}
window.webContents.on('before-input-event', (event, input) => {
if (!options.isOverlayVisible(kind)) return;
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
});
window.hide();
window.on('closed', () => {
options.onWindowClosed(kind);
});
window.on('blur', () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
}
});
if (options.isDev && kind === 'visible') {
window.webContents.openDevTools({ mode: 'detach' });
}
return window;
}

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility,
} from './startup';
const BASE_CONFIG = {
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default' as const,
},
ankiConnect: {
behavior: {
autoUpdateNewCards: true,
},
},
};
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => {
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } },
'linux',
),
true,
);
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } },
'darwin',
),
false,
);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'linux'), false);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'darwin'), true);
});
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => {
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
auto_start_overlay: true,
}),
true,
);
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
invisibleOverlay: { startupVisibility: 'visible' },
}),
true,
);
});
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {
assert.equal(shouldBindVisibleOverlayToMpvSubVisibility(BASE_CONFIG), true);
assert.equal(
shouldBindVisibleOverlayToMpvSubVisibility({
...BASE_CONFIG,
bind_visible_overlay_to_mpv_sub_visibility: false,
}),
false,
);
});
test('isAutoUpdateEnabledRuntime prefers runtime option and falls back to config', () => {
assert.equal(
isAutoUpdateEnabledRuntime(BASE_CONFIG, {
getOptionValue: () => false,
}),
false,
);
assert.equal(
isAutoUpdateEnabledRuntime(
{
...BASE_CONFIG,
ankiConnect: { behavior: { autoUpdateNewCards: false } },
},
null,
),
false,
);
assert.equal(isAutoUpdateEnabledRuntime(BASE_CONFIG, null), true);
});

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyRuntimeOptionResultRuntime,
cycleRuntimeOptionFromIpcRuntime,
setRuntimeOptionFromIpcRuntime,
} from './runtime-options-ipc';
test('applyRuntimeOptionResultRuntime emits success OSD message', () => {
const osd: string[] = [];
const result = applyRuntimeOptionResultRuntime({ ok: true, osdMessage: 'Updated' }, (text) => {
osd.push(text);
});
assert.equal(result.ok, true);
assert.deepEqual(osd, ['Updated']);
});
test('setRuntimeOptionFromIpcRuntime returns unavailable when manager missing', () => {
const osd: string[] = [];
const result = setRuntimeOptionFromIpcRuntime(null, 'anki.autoUpdateNewCards', true, (text) => {
osd.push(text);
});
assert.equal(result.ok, false);
assert.equal(result.error, 'Runtime options manager unavailable');
assert.deepEqual(osd, []);
});
test('cycleRuntimeOptionFromIpcRuntime reports errors once', () => {
const osd: string[] = [];
const result = cycleRuntimeOptionFromIpcRuntime(
{
setOptionValue: () => ({ ok: true }),
cycleOption: () => ({ ok: false, error: 'bad option' }),
},
'anki.kikuFieldGrouping',
1,
(text) => {
osd.push(text);
},
);
assert.equal(result.ok, false);
assert.equal(result.error, 'bad option');
assert.deepEqual(osd, ['bad option']);
});

View File

@@ -0,0 +1,48 @@
import { RuntimeOptionApplyResult, RuntimeOptionId, RuntimeOptionValue } from '../../types';
export interface RuntimeOptionsManagerLike {
setOptionValue: (id: RuntimeOptionId, value: RuntimeOptionValue) => RuntimeOptionApplyResult;
cycleOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
}
export function applyRuntimeOptionResultRuntime(
result: RuntimeOptionApplyResult,
showMpvOsd: (text: string) => void,
): RuntimeOptionApplyResult {
if (result.ok && result.osdMessage) {
showMpvOsd(result.osdMessage);
}
return result;
}
export function setRuntimeOptionFromIpcRuntime(
manager: RuntimeOptionsManagerLike | null,
id: RuntimeOptionId,
value: RuntimeOptionValue,
showMpvOsd: (text: string) => void,
): RuntimeOptionApplyResult {
if (!manager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
const result = applyRuntimeOptionResultRuntime(manager.setOptionValue(id, value), showMpvOsd);
if (!result.ok && result.error) {
showMpvOsd(result.error);
}
return result;
}
export function cycleRuntimeOptionFromIpcRuntime(
manager: RuntimeOptionsManagerLike | null,
id: RuntimeOptionId,
direction: 1 | -1,
showMpvOsd: (text: string) => void,
): RuntimeOptionApplyResult {
if (!manager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
const result = applyRuntimeOptionResultRuntime(manager.cycleOption(id, direction), showMpvOsd);
if (!result.ok && result.error) {
showMpvOsd(result.error);
}
return result;
}

View File

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SecondarySubMode } from '../../types';
import { cycleSecondarySubMode } from './subtitle-position';
test('cycleSecondarySubMode cycles and emits broadcast + OSD', () => {
let mode: SecondarySubMode = 'hover';
let lastToggleAt = 0;
const broadcasts: SecondarySubMode[] = [];
const osd: string[] = [];
cycleSecondarySubMode({
getSecondarySubMode: () => mode,
setSecondarySubMode: (next) => {
mode = next;
},
getLastSecondarySubToggleAtMs: () => lastToggleAt,
setLastSecondarySubToggleAtMs: (value) => {
lastToggleAt = value;
},
broadcastSecondarySubMode: (next) => {
broadcasts.push(next);
},
showMpvOsd: (text) => {
osd.push(text);
},
now: () => 1000,
});
assert.equal(mode, 'hidden');
assert.deepEqual(broadcasts, ['hidden']);
assert.deepEqual(osd, ['Secondary subtitle: hidden']);
assert.equal(lastToggleAt, 1000);
});
test('cycleSecondarySubMode obeys debounce window', () => {
let mode: SecondarySubMode = 'visible';
let lastToggleAt = 950;
let broadcasted = false;
let osdShown = false;
cycleSecondarySubMode({
getSecondarySubMode: () => mode,
setSecondarySubMode: (next) => {
mode = next;
},
getLastSecondarySubToggleAtMs: () => lastToggleAt,
setLastSecondarySubToggleAtMs: (value) => {
lastToggleAt = value;
},
broadcastSecondarySubMode: () => {
broadcasted = true;
},
showMpvOsd: () => {
osdShown = true;
},
now: () => 1000,
});
assert.equal(mode, 'visible');
assert.equal(lastToggleAt, 950);
assert.equal(broadcasted, false);
assert.equal(osdShown, false);
});

View File

@@ -0,0 +1,77 @@
import { globalShortcut } from 'electron';
export function isGlobalShortcutRegisteredSafe(accelerator: string): boolean {
try {
return globalShortcut.isRegistered(accelerator);
} catch {
return false;
}
}
export function shortcutMatchesInputForLocalFallback(
input: Electron.Input,
accelerator: string,
allowWhenRegistered = false,
): boolean {
if (input.type !== 'keyDown' || input.isAutoRepeat) return false;
if (!accelerator) return false;
if (!allowWhenRegistered && isGlobalShortcutRegisteredSafe(accelerator)) {
return false;
}
const normalized = accelerator
.replace(/\s+/g, '')
.replace(/cmdorctrl/gi, 'CommandOrControl')
.toLowerCase();
const parts = normalized.split('+').filter(Boolean);
if (parts.length === 0) return false;
const keyToken = parts[parts.length - 1]!;
const modifierTokens = new Set(parts.slice(0, -1));
const allowedModifiers = new Set(['shift', 'alt', 'meta', 'control', 'commandorcontrol']);
for (const token of modifierTokens) {
if (!allowedModifiers.has(token)) return false;
}
const inputKey = (input.key || '').toLowerCase();
if (keyToken.length === 1) {
if (inputKey !== keyToken) return false;
} else if (keyToken.startsWith('key') && keyToken.length === 4) {
if (inputKey !== keyToken.slice(3)) return false;
} else {
return false;
}
const expectedShift = modifierTokens.has('shift');
const expectedAlt = modifierTokens.has('alt');
const expectedMeta = modifierTokens.has('meta');
const expectedControl = modifierTokens.has('control');
const expectedCommandOrControl = modifierTokens.has('commandorcontrol');
if (Boolean(input.shift) !== expectedShift) return false;
if (Boolean(input.alt) !== expectedAlt) return false;
if (expectedCommandOrControl) {
const hasCmdOrCtrl =
process.platform === 'darwin' ? Boolean(input.meta || input.control) : Boolean(input.control);
if (!hasCmdOrCtrl) return false;
} else {
if (process.platform === 'darwin') {
if (input.meta || input.control) return false;
} else if (!expectedControl && input.control) {
return false;
}
}
if (expectedMeta && !input.meta) return false;
if (!expectedMeta && modifierTokens.has('meta') === false && input.meta) {
if (!expectedCommandOrControl) return false;
}
if (expectedControl && !input.control) return false;
if (!expectedControl && modifierTokens.has('control') === false && input.control) {
if (!expectedCommandOrControl) return false;
}
return true;
}

View File

@@ -0,0 +1,100 @@
import { BrowserWindow, globalShortcut } from 'electron';
import { createLogger } from '../../logger';
const logger = createLogger('main:shortcut');
export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
openJimaku?: string | null | undefined;
}
export interface RegisterGlobalShortcutsServiceOptions {
shortcuts: GlobalShortcutConfig;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
onOpenJimaku?: () => void;
isDev: boolean;
getMainWindow: () => BrowserWindow | null;
}
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y';
if (visibleShortcut) {
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
options.onToggleVisibleOverlay();
});
if (!toggleVisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
);
}
}
if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) {
const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => {
options.onToggleInvisibleOverlay();
});
if (!toggleInvisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
);
}
} else if (
invisibleShortcut &&
normalizedInvisible &&
normalizedInvisible === normalizedVisible
) {
logger.warn(
'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal',
);
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible ||
normalizedJimaku === normalizedInvisible ||
normalizedJimaku === normalizedSettings)
) {
logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut',
);
} else {
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
options.onOpenJimaku?.();
});
if (!openJimakuRegistered) {
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
}
}
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
options.onOpenYomitanSettings();
});
if (!settingsRegistered) {
logger.warn('Failed to register global shortcut: Alt+Shift+Y');
}
if (options.isDev) {
const devtoolsRegistered = globalShortcut.register('F12', () => {
const mainWindow = options.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.toggleDevTools();
}
});
if (!devtoolsRegistered) {
logger.warn('Failed to register global shortcut: F12');
}
}
}

View File

@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runStartupBootstrapRuntime } from './startup';
import { CliArgs } from '../../cli/args';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownWords: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
debug: false,
...overrides,
};
}
test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => {
const calls: string[] = [];
const args = makeArgs({
logLevel: 'debug',
socketPath: '/tmp/custom.sock',
texthookerPort: 9001,
backend: 'x11',
autoStartOverlay: true,
texthooker: true,
});
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--log-level', 'debug'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.initialArgs, args);
assert.equal(result.mpvSocketPath, '/tmp/custom.sock');
assert.equal(result.texthookerPort, 9001);
assert.equal(result.backendOverride, 'x11');
assert.equal(result.autoStartOverlay, true);
assert.equal(result.texthookerOnlyMode, true);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => {
const calls: string[] = [];
const args = makeArgs({
logLevel: 'warn',
});
runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--log-level', 'warn'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
});
test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => {
const calls: string[] = [];
const args = makeArgs({
jellyfin: true,
jellyfinLibraries: true,
socketPath: '/tmp/stable.sock',
texthookerPort: 8888,
});
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--jellyfin', '--jellyfin-libraries'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.mpvSocketPath, '/tmp/stable.sock');
assert.equal(result.texthookerPort, 8888);
assert.equal(result.backendOverride, null);
assert.equal(result.autoStartOverlay, false);
assert.equal(result.texthookerOnlyMode, false);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => {
const calls: string[] = [];
const args = makeArgs({
debug: true,
});
runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--debug'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
const calls: string[] = [];
const args = makeArgs({ generateConfig: true, logLevel: 'warn' });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--generate-config'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => true,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
});
test('runStartupBootstrapRuntime enables quiet background mode by default', () => {
const calls: string[] = [];
const args = makeArgs({ background: true });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--background'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});

View File

@@ -0,0 +1,248 @@
import { CliArgs } from '../../cli/args';
import type { LogLevelSource } from '../../logger';
import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
export interface StartupBootstrapRuntimeState {
initialArgs: CliArgs;
mpvSocketPath: string;
texthookerPort: number;
backendOverride: string | null;
autoStartOverlay: boolean;
texthookerOnlyMode: boolean;
backgroundMode: boolean;
}
interface RuntimeAutoUpdateOptionManagerLike {
getOptionValue: (id: 'anki.autoUpdateNewCards') => unknown;
}
export interface RuntimeConfigLike {
auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean;
invisibleOverlay: {
startupVisibility: 'visible' | 'hidden' | 'platform-default';
};
ankiConnect?: {
behavior?: {
autoUpdateNewCards?: boolean;
};
};
}
export interface StartupBootstrapRuntimeDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPath: () => string;
defaultTexthookerPort: number;
runGenerateConfigFlow: (args: CliArgs) => boolean;
startAppLifecycle: (args: CliArgs) => void;
}
export function runStartupBootstrapRuntime(
deps: StartupBootstrapRuntimeDeps,
): StartupBootstrapRuntimeState {
const initialArgs = deps.parseArgs(deps.argv);
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background) {
deps.setLogLevel('warn', 'cli');
}
deps.forceX11Backend(initialArgs);
deps.enforceUnsupportedWaylandMode(initialArgs);
const state: StartupBootstrapRuntimeState = {
initialArgs,
mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(),
texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort,
backendOverride: initialArgs.backend ?? null,
autoStartOverlay: initialArgs.autoStartOverlay,
texthookerOnlyMode: initialArgs.texthooker,
backgroundMode: initialArgs.background,
};
if (!deps.runGenerateConfigFlow(initialArgs)) {
deps.startAppLifecycle(initialArgs);
}
return state;
}
interface AppReadyConfigLike {
secondarySub?: {
defaultMode?: SecondarySubMode;
};
ankiConnect?: {
enabled?: boolean;
fields?: {
audio?: string;
image?: string;
sentence?: string;
miscInfo?: string;
translation?: string;
};
};
websocket?: {
enabled?: boolean | 'auto';
port?: number;
};
logging?: {
level?: 'debug' | 'info' | 'warn' | 'error';
};
}
export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void;
resolveKeybindings: () => void;
createMpvClient: () => void;
reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
initRuntimeOptionsManager: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
defaultWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
startSubtitleWebsocket: (port: number) => void;
log: (message: string) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
createSubtitleTimingTracker: () => void;
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
initializeOverlayRuntime: () => void;
handleInitialArgs: () => void;
logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number;
}
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
'audio',
'image',
'sentence',
'miscInfo',
'translation',
] as const;
function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
if (!config.ankiConnect?.enabled) {
return [];
}
const errors: string[] = [];
const fields = config.ankiConnect.fields ?? {};
for (const key of REQUIRED_ANKI_FIELD_MAPPING_KEYS) {
const value = fields[key];
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(
`ankiConnect.fields.${key} must be a non-empty string when ankiConnect is enabled.`,
);
}
}
return errors;
}
export function getInitialInvisibleOverlayVisibility(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === 'visible') return true;
if (visibility === 'hidden') return false;
if (platform === 'linux') return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
if (config.auto_start_overlay === true) return true;
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
return false;
}
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {
return config.bind_visible_overlay_to_mpv_sub_visibility;
}
export function isAutoUpdateEnabledRuntime(
config: ResolvedConfig | RuntimeConfigLike,
runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null,
): boolean {
const value = runtimeOptionsManager?.getOptionValue('anki.autoUpdateNewCards');
if (typeof value === 'boolean') return value;
return (config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !== false;
}
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.');
deps.reloadConfig();
const config = deps.getResolvedConfig();
const criticalConfigErrors = getStartupCriticalConfigErrors(config);
if (criticalConfigErrors.length > 0) {
deps.onCriticalConfigErrors?.(criticalConfigErrors);
deps.logDebug?.(
`App-ready critical path aborted after config validation in ${now() - startupStartedAtMs}ms.`,
);
return;
}
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || deps.defaultWebsocketPort;
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort);
} else if (wsEnabled === 'auto') {
deps.log('mpv_websocket detected, skipping built-in WebSocket server');
}
deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) {
deps.log('Runtime ready: invoking createImmersionTracker.');
try {
deps.createImmersionTracker();
} catch (error) {
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`);
}
} else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
}
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
}
deps.handleInitialArgs();
deps.startBackgroundWarmups();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}

View File

@@ -0,0 +1,75 @@
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
import { SubsyncResolvedConfig } from '../../subsync/utils';
import { runSubsyncManualFromIpc } from './ipc-command';
import {
TriggerSubsyncFromConfigDeps,
runSubsyncManual,
triggerSubsyncFromConfig,
} from './subsync';
const AUTOSUBSYNC_SPINNER_FRAMES = ['|', '/', '-', '\\'];
interface MpvClientLike {
connected: boolean;
currentAudioStreamIndex: number | null;
send: (payload: { command: (string | number)[] }) => void;
requestProperty: (name: string) => Promise<unknown>;
}
export interface SubsyncRuntimeDeps {
getMpvClient: () => MpvClientLike | null;
getResolvedSubsyncConfig: () => SubsyncResolvedConfig;
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
openManualPicker: (payload: SubsyncManualPayload) => void;
}
async function runWithSubsyncSpinnerService<T>(
task: () => Promise<T>,
showMpvOsd: (text: string) => void,
label = 'Subsync: syncing',
): Promise<T> {
let frame = 0;
showMpvOsd(`${label} ${AUTOSUBSYNC_SPINNER_FRAMES[0]}`);
const timer = setInterval(() => {
frame = (frame + 1) % AUTOSUBSYNC_SPINNER_FRAMES.length;
showMpvOsd(`${label} ${AUTOSUBSYNC_SPINNER_FRAMES[frame]}`);
}, 150);
try {
return await task();
} finally {
clearInterval(timer);
}
}
function buildTriggerSubsyncDeps(deps: SubsyncRuntimeDeps): TriggerSubsyncFromConfigDeps {
return {
getMpvClient: deps.getMpvClient,
getResolvedConfig: deps.getResolvedSubsyncConfig,
isSubsyncInProgress: deps.isSubsyncInProgress,
setSubsyncInProgress: deps.setSubsyncInProgress,
showMpvOsd: deps.showMpvOsd,
runWithSubsyncSpinner: <T>(task: () => Promise<T>) =>
runWithSubsyncSpinnerService(task, deps.showMpvOsd),
openManualPicker: deps.openManualPicker,
};
}
export async function triggerSubsyncFromConfigRuntime(deps: SubsyncRuntimeDeps): Promise<void> {
await triggerSubsyncFromConfig(buildTriggerSubsyncDeps(deps));
}
export async function runSubsyncManualFromIpcRuntime(
request: SubsyncManualRunRequest,
deps: SubsyncRuntimeDeps,
): Promise<SubsyncResult> {
const triggerDeps = buildTriggerSubsyncDeps(deps);
return runSubsyncManualFromIpc(request, {
isSubsyncInProgress: triggerDeps.isSubsyncInProgress,
setSubsyncInProgress: triggerDeps.setSubsyncInProgress,
showMpvOsd: triggerDeps.showMpvOsd,
runWithSpinner: (task) => triggerDeps.runWithSubsyncSpinner(() => task()),
runSubsyncManual: (subsyncRequest) => runSubsyncManual(subsyncRequest, triggerDeps),
});
}

View File

@@ -0,0 +1,344 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
TriggerSubsyncFromConfigDeps,
runSubsyncManual,
triggerSubsyncFromConfig,
} from './subsync';
function makeDeps(
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
): TriggerSubsyncFromConfigDeps {
const mpvClient = {
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
{ id: 3, type: 'audio', selected: true, 'ff-index': 1 },
];
}
return null;
},
};
return {
getMpvClient: () => mpvClient,
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg',
}),
isSubsyncInProgress: () => false,
setSubsyncInProgress: () => {},
showMpvOsd: () => {},
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => task(),
openManualPicker: () => {},
...overrides,
};
}
test('triggerSubsyncFromConfig returns early when already in progress', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(
makeDeps({
isSubsyncInProgress: () => true,
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.deepEqual(osd, ['Subsync already running']);
});
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let inProgressState: boolean | null = null;
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
setSubsyncInProgress: (value) => {
inProgressState = value;
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.ok(osd.includes('Subsync: choose engine and source'));
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => null,
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
});
test('runSubsyncManual requires a source track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps());
assert.deepEqual(result, {
ok: false,
message: 'Select a subtitle source track for alass',
});
});
test('triggerSubsyncFromConfig reports path validation failures', async () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
await triggerSubsyncFromConfig(
makeDeps({
getResolvedConfig: () => ({
defaultMode: 'auto',
alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg',
}),
setSubsyncInProgress: (value) => {
inProgress.push(value);
},
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.deepEqual(inProgress, [true, false]);
assert.ok(
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
);
});
function writeExecutableScript(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
fs.chmodSync(filePath, 0o755);
}
test('runSubsyncManual constructs ffsubsync command and returns success', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-'));
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const alassPath = path.join(tmpDir, 'alass.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'primary.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
);
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: 2,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
assert.equal(ffArgs[0], videoPath);
assert.ok(ffArgs.includes('-i'));
assert.ok(ffArgs.includes(primaryPath));
assert.ok(ffArgs.includes('--reference-stream'));
assert.ok(ffArgs.includes('0:2'));
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
});
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'primary.srt');
const sourcePath = path.join(tmpDir, 'source.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
fs.writeFileSync(sourcePath, 'sub2');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`,
);
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
{
id: 2,
type: 'sub',
selected: false,
external: true,
'external-filename': sourcePath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
assert.equal(result.ok, false);
assert.equal(typeof result.message, 'string');
assert.equal(result.message.startsWith('alass synchronization failed'), true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], sourcePath);
assert.equal(alassArgs[1], primaryPath);
});
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const alassPath = path.join(tmpDir, 'alass.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'primary.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'subtitle');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\nmkdir -p "${tmpDir}"\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`,
);
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return '1';
if (name === 'secondary-sid') return '2';
if (name === 'track-list') {
return [
{
id: '1',
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
const syncOutputIndex = ffArgs.indexOf('-o');
assert.equal(syncOutputIndex >= 0, true);
const outputPath = ffArgs[syncOutputIndex + 1];
assert.equal(typeof outputPath, 'string');
assert.ok(outputPath!.length > 0);
assert.equal(fs.readFileSync(outputPath!, 'utf8'), '');
});

View File

@@ -0,0 +1,436 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
import {
CommandResult,
codecToExtension,
fileExists,
formatTrackLabel,
getTrackById,
hasPathSeparators,
MpvTrack,
runCommand,
SubsyncContext,
SubsyncResolvedConfig,
} from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger('main:subsync');
interface FileExtractionResult {
path: string;
temporary: boolean;
}
function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? 'n/a'}`,
result.stderr ? `stderr: ${result.stderr}` : '',
result.stdout ? `stdout: ${result.stdout}` : '',
result.error ? `error: ${result.error}` : '',
]
.map((value) => value.trim())
.filter(Boolean);
if (parts.length === 0) return `command failed (${command})`;
return `command failed (${command}) ${parts.join(' | ')}`;
}
interface MpvClientLike {
connected: boolean;
currentAudioStreamIndex: number | null;
send: (payload: { command: (string | number)[] }) => void;
requestProperty: (name: string) => Promise<unknown>;
}
interface SubsyncCoreDeps {
getMpvClient: () => MpvClientLike | null;
getResolvedConfig: () => SubsyncResolvedConfig;
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
const parsed = Number(trimmed);
return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null;
}
return null;
}
function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
return tracks.map((track) => {
if (!track || typeof track !== 'object') return track as MpvTrack;
const typed = track as MpvTrack & { id?: unknown };
const parsedId = parseTrackId(typed.id);
if (parsedId === null) {
const { id: _ignored, ...rest } = typed;
return rest as MpvTrack;
}
return { ...typed, id: parsedId };
});
}
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
runWithSubsyncSpinner: <T>(task: () => Promise<T>) => Promise<T>;
openManualPicker: (payload: SubsyncManualPayload) => void;
}
function getMpvClientForSubsync(deps: SubsyncCoreDeps): MpvClientLike {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error('MPV not connected');
}
return client;
}
async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncContext> {
const [videoPathRaw, sidRaw, secondarySidRaw, trackListRaw] = await Promise.all([
client.requestProperty('path'),
client.requestProperty('sid'),
client.requestProperty('secondary-sid'),
client.requestProperty('track-list'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
throw new Error('No video is currently loaded');
}
const tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : [];
const subtitleTracks = tracks.filter((track) => track.type === 'sub');
const sid = parseTrackId(sidRaw);
const secondarySid = parseTrackId(secondarySidRaw);
const primaryTrack = subtitleTracks.find((track) => track.id === sid);
if (!primaryTrack) {
throw new Error('No active subtitle track found');
}
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
const sourceTracks = subtitleTracks
.filter((track) => track.id !== sid)
.filter((track) => {
if (!track.external) return true;
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
return {
videoPath,
primaryTrack,
secondaryTrack,
sourceTracks,
audioStreamIndex: client.currentAudioStreamIndex,
};
}
function ensureExecutablePath(pathOrName: string, name: string): string {
if (!pathOrName) {
throw new Error(`Missing ${name} path in config`);
}
if (hasPathSeparators(pathOrName) && !fileExists(pathOrName)) {
throw new Error(`Configured ${name} executable not found: ${pathOrName}`);
}
return pathOrName;
}
async function extractSubtitleTrackToFile(
ffmpegPath: string,
videoPath: string,
track: MpvTrack,
): Promise<FileExtractionResult> {
if (track.external) {
const externalPath = track['external-filename'];
if (typeof externalPath !== 'string' || externalPath.length === 0) {
throw new Error('External subtitle track has no file path');
}
if (!fileExists(externalPath)) {
throw new Error(`Subtitle file not found: ${externalPath}`);
}
return { path: externalPath, temporary: false };
}
const ffIndex = track['ff-index'];
const extension = codecToExtension(track.codec);
if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) {
throw new Error('Internal subtitle track has no valid ff-index');
}
if (!extension) {
throw new Error(`Unsupported subtitle codec: ${track.codec ?? 'unknown'}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
const extraction = await runCommand(ffmpegPath, [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
extension,
outputPath,
]);
if (!extraction.ok || !fileExists(outputPath)) {
throw new Error(
`Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure(
'ffmpeg',
extraction,
)}`,
);
}
return { path: outputPath, temporary: true };
}
function cleanupTemporaryFile(extraction: FileExtractionResult): void {
if (!extraction.temporary) return;
try {
if (fileExists(extraction.path)) {
fs.unlinkSync(extraction.path);
}
} catch {}
try {
const dir = path.dirname(extraction.path);
if (fs.existsSync(dir)) {
fs.rmdirSync(dir);
}
} catch {}
}
function buildRetimedPath(subPath: string): string {
const parsed = path.parse(subPath);
const suffix = `_retimed_${Date.now()}`;
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
}
async function runAlassSync(
alassPath: string,
referenceFile: string,
inputSubtitlePath: string,
outputPath: string,
): Promise<CommandResult> {
return runCommand(alassPath, [referenceFile, inputSubtitlePath, outputPath]);
}
async function runFfsubsyncSync(
ffsubsyncPath: string,
videoPath: string,
inputSubtitlePath: string,
outputPath: string,
audioStreamIndex: number | null,
): Promise<CommandResult> {
const args = [videoPath, '-i', inputSubtitlePath, '-o', outputPath];
if (audioStreamIndex !== null) {
args.push('--reference-stream', `0:${audioStreamIndex}`);
}
return runCommand(ffsubsyncPath, args);
}
function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void {
if (!client.connected) {
throw new Error('MPV disconnected while loading subtitle');
}
client.send({ command: ['sub_add', pathToLoad] });
client.send({ command: ['set_property', 'sub-delay', 0] });
}
async function subsyncToReference(
engine: 'alass' | 'ffsubsync',
referenceFilePath: string,
context: SubsyncContext,
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.primaryTrack,
);
const outputPath = buildRetimedPath(primaryExtraction.path);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
primaryExtraction.path,
outputPath,
context.audioStreamIndex,
);
}
if (!result.ok || !fileExists(outputPath)) {
const details = summarizeCommandFailure(engine, result);
return {
ok: false,
message: `${engine} synchronization failed: ${details}`,
};
}
loadSyncedSubtitle(client, outputPath);
return {
ok: true,
message: `Subtitle synchronized with ${engine}`,
};
} finally {
cleanupTemporaryFile(primaryExtraction);
}
}
function validateFfsubsyncReference(videoPath: string): void {
if (isRemoteMediaPath(videoPath)) {
throw new Error(
'FFsubsync cannot reliably sync stream URLs because it needs direct reference media access. Use Alass with a secondary subtitle source or play a local file.',
);
}
}
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
try {
secondaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
'alass',
secondaryExtraction.path,
context,
resolved,
client,
);
if (alassResult.ok) {
return alassResult;
}
} catch (error) {
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
}
}
}
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
if (request.engine === 'ffsubsync') {
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
if (!sourceTrack) {
return { ok: false, message: 'Select a subtitle source track for alass' };
}
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
let sourceExtraction: FileExtractionResult | null = null;
try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
} finally {
if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction);
}
}
}
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const payload: SubsyncManualPayload = {
sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
label: formatTrackLabel(track),
})),
};
deps.openManualPicker(payload);
}
export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
if (deps.isSubsyncInProgress()) {
deps.showMpvOsd('Subsync already running');
return;
}
const resolved = deps.getResolvedConfig();
try {
if (resolved.defaultMode === 'manual') {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
deps.setSubsyncInProgress(false);
}
}

View File

@@ -0,0 +1,185 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { SecondarySubMode, SubtitlePosition } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('main:subtitle-position');
export interface CycleSecondarySubModeDeps {
getSecondarySubMode: () => SecondarySubMode;
setSecondarySubMode: (mode: SecondarySubMode) => void;
getLastSecondarySubToggleAtMs: () => number;
setLastSecondarySubToggleAtMs: (timestampMs: number) => void;
broadcastSecondarySubMode: (mode: SecondarySubMode) => void;
showMpvOsd: (text: string) => void;
now?: () => number;
}
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ['hidden', 'visible', 'hover'];
const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120;
export function cycleSecondarySubMode(deps: CycleSecondarySubModeDeps): void {
const now = deps.now ? deps.now() : Date.now();
if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) {
return;
}
deps.setLastSecondarySubToggleAtMs(now);
const currentMode = deps.getSecondarySubMode();
const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextMode = SECONDARY_SUB_CYCLE[(safeIndex + 1) % SECONDARY_SUB_CYCLE.length]!;
deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
}
function getSubtitlePositionFilePath(mediaPath: string, subtitlePositionsDir: string): string {
const key = normalizeMediaPathForSubtitlePosition(mediaPath);
const hash = crypto.createHash('sha256').update(key).digest('hex');
return path.join(subtitlePositionsDir, `${hash}.json`);
}
function normalizeMediaPathForSubtitlePosition(mediaPath: string): string {
const trimmed = mediaPath.trim();
if (!trimmed) return trimmed;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) || /^ytsearch:/.test(trimmed)) {
return trimmed;
}
const resolved = path.resolve(trimmed);
let normalized = resolved;
try {
if (fs.existsSync(resolved)) {
normalized = fs.realpathSync(resolved);
}
} catch {
normalized = resolved;
}
if (process.platform === 'win32') {
normalized = normalized.toLowerCase();
}
return normalized;
}
function persistSubtitlePosition(
position: SubtitlePosition,
currentMediaPath: string | null,
subtitlePositionsDir: string,
): void {
if (!currentMediaPath) return;
if (!fs.existsSync(subtitlePositionsDir)) {
fs.mkdirSync(subtitlePositionsDir, { recursive: true });
}
const positionPath = getSubtitlePositionFilePath(currentMediaPath, subtitlePositionsDir);
fs.writeFileSync(positionPath, JSON.stringify(position, null, 2));
}
export function loadSubtitlePosition(
options: {
currentMediaPath: string | null;
fallbackPosition: SubtitlePosition;
} & { subtitlePositionsDir: string },
): SubtitlePosition | null {
if (!options.currentMediaPath) {
return options.fallbackPosition;
}
try {
const positionPath = getSubtitlePositionFilePath(
options.currentMediaPath,
options.subtitlePositionsDir,
);
if (!fs.existsSync(positionPath)) {
return options.fallbackPosition;
}
const data = fs.readFileSync(positionPath, 'utf-8');
const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
const position: SubtitlePosition = { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
}
return position;
}
return options.fallbackPosition;
} catch (err) {
logger.error('Failed to load subtitle position:', (err as Error).message);
return options.fallbackPosition;
}
}
export function saveSubtitlePosition(options: {
position: SubtitlePosition;
currentMediaPath: string | null;
subtitlePositionsDir: string;
onQueuePending: (position: SubtitlePosition) => void;
onPersisted: () => void;
}): void {
if (!options.currentMediaPath) {
options.onQueuePending(options.position);
logger.warn('Queued subtitle position save - no media path yet');
return;
}
try {
persistSubtitlePosition(
options.position,
options.currentMediaPath,
options.subtitlePositionsDir,
);
options.onPersisted();
} catch (err) {
logger.error('Failed to save subtitle position:', (err as Error).message);
}
}
export function updateCurrentMediaPath(options: {
mediaPath: unknown;
currentMediaPath: string | null;
pendingSubtitlePosition: SubtitlePosition | null;
subtitlePositionsDir: string;
loadSubtitlePosition: () => SubtitlePosition | null;
setCurrentMediaPath: (mediaPath: string | null) => void;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
}): void {
const nextPath =
typeof options.mediaPath === 'string' && options.mediaPath.trim().length > 0
? options.mediaPath
: null;
if (nextPath === options.currentMediaPath) return;
options.setCurrentMediaPath(nextPath);
if (nextPath && options.pendingSubtitlePosition) {
try {
persistSubtitlePosition(
options.pendingSubtitlePosition,
nextPath,
options.subtitlePositionsDir,
);
options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition();
} catch (err) {
logger.error('Failed to persist queued subtitle position:', (err as Error).message);
}
}
const position = options.loadSubtitlePosition();
options.broadcastSubtitlePosition(position);
}

View File

@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubtitleProcessingController } from './subtitle-processing-controller';
import type { SubtitleData } from '../../types';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('subtitle processing emits tokenized payload when tokenization succeeds', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('字幕');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
});
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
const emitted: SubtitleData[] = [];
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
if (text === 'first') {
return await new Promise<SubtitleData | null>((resolve) => {
firstResolve = resolve;
});
}
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('first');
controller.onSubtitleChange('second');
assert.ok(firstResolve);
firstResolve({ text: 'first', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]);
});
test('subtitle processing skips duplicate subtitle emission', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.onSubtitleChange('same');
await flushMicrotasks();
assert.equal(emitted.length, 1);
assert.equal(tokenizeCalls, 1);
});
test('subtitle processing falls back to plain subtitle when tokenization returns null', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async () => null,
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('fallback');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
});
test('subtitle processing can refresh current subtitle without text change', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [] },
]);
});
test('subtitle processing refresh can use explicit text override', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.refreshCurrentSubtitle('initial');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]);
});

View File

@@ -0,0 +1,101 @@
import type { SubtitleData } from '../../types';
export interface SubtitleProcessingControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
emitSubtitle: (payload: SubtitleData) => void;
logDebug?: (message: string) => void;
now?: () => number;
}
export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (textOverride?: string) => void;
}
export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController {
let latestText = '';
let lastEmittedText = '';
let processing = false;
let staleDropCount = 0;
let refreshRequested = false;
const now = deps.now ?? (() => Date.now());
const processLatest = (): void => {
if (processing) {
return;
}
processing = true;
void (async () => {
while (true) {
const text = latestText;
const forceRefresh = refreshRequested;
refreshRequested = false;
const startedAtMs = now();
if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null });
lastEmittedText = text;
break;
}
let output: SubtitleData = { text, tokens: null };
try {
const tokenized = await deps.tokenizeSubtitle(text);
if (tokenized) {
output = tokenized;
}
} catch (error) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
}
if (latestText !== text) {
staleDropCount += 1;
deps.logDebug?.(
`Dropped stale subtitle tokenization result; dropped=${staleDropCount}, elapsed=${now() - startedAtMs}ms`,
);
continue;
}
deps.emitSubtitle(output);
lastEmittedText = text;
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
break;
}
})()
.catch((error) => {
deps.logDebug?.(`Subtitle processing loop failed: ${(error as Error).message}`);
})
.finally(() => {
processing = false;
if (refreshRequested || latestText !== lastEmittedText) {
processLatest();
}
});
};
return {
onSubtitleChange: (text: string) => {
if (text === latestText) {
return;
}
latestText = text;
processLatest();
},
refreshCurrentSubtitle: (textOverride?: string) => {
if (typeof textOverride === 'string') {
latestText = textOverride;
}
if (!latestText.trim()) {
return;
}
refreshRequested = true;
processLatest();
},
};
}

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws';
import { PartOfSpeech, type SubtitleData } from '../../types';
const frequencyOptions = {
enabled: true,
topX: 1000,
mode: 'banded' as const,
};
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
const payload: SubtitleData = {
text: 'a < b\nx & y',
tokens: null,
};
assert.equal(serializeSubtitleMarkup(payload, frequencyOptions), 'a &lt; b<br>x &amp; y');
});
test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes', () => {
const payload: SubtitleData = {
text: 'ignored',
tokens: [
{
surface: '既知',
reading: '',
headword: '',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
},
{
surface: '新語',
reading: '',
headword: '',
startPos: 2,
endPos: 4,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: true,
},
{
surface: '級',
reading: '',
headword: '',
startPos: 4,
endPos: 5,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
jlptLevel: 'N3',
},
{
surface: '頻度',
reading: '',
headword: '',
startPos: 5,
endPos: 7,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: 10,
},
],
};
const markup = serializeSubtitleMarkup(payload, frequencyOptions);
assert.match(markup, /word word-known/);
assert.match(markup, /word word-n-plus-one/);
assert.match(markup, /word word-jlpt-n3/);
assert.match(markup, /word word-frequency-band-1/);
});
test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
const payload: SubtitleData = {
text: '字幕',
tokens: null,
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
assert.deepEqual(JSON.parse(raw), { sentence: '字幕' });
});

View File

@@ -0,0 +1,158 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import WebSocket from 'ws';
import { createLogger } from '../../logger';
import type { MergedToken, SubtitleData } from '../../types';
const logger = createLogger('main:subtitle-ws');
export function hasMpvWebsocketPlugin(): boolean {
const mpvWebsocketPath = path.join(os.homedir(), '.config', 'mpv', 'mpv_websocket');
return fs.existsSync(mpvWebsocketPath);
}
export type SubtitleWebsocketFrequencyOptions = {
enabled: boolean;
topX: number;
mode: 'single' | 'banded';
};
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function computeFrequencyClass(
token: MergedToken,
options: SubtitleWebsocketFrequencyOptions,
): string | null {
if (!options.enabled) return null;
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null;
const rank = Math.max(1, Math.floor(token.frequencyRank));
const topX = Math.max(1, Math.floor(options.topX));
if (rank > topX) return null;
if (options.mode === 'banded') {
const band = Math.min(5, Math.max(1, Math.ceil((rank / topX) * 5)));
return `word-frequency-band-${band}`;
}
return 'word-frequency-single';
}
function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string {
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (token.isKnown) {
classes.push('word-known');
}
if (token.jlptLevel) {
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
if (!token.isKnown && !token.isNPlusOneTarget) {
const frequencyClass = computeFrequencyClass(token, options);
if (frequencyClass) {
classes.push(frequencyClass);
}
}
return classes.join(' ');
}
export function serializeSubtitleMarkup(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
): string {
if (!payload.tokens || payload.tokens.length === 0) {
return escapeHtml(payload.text).replaceAll('\n', '<br>');
}
const chunks: string[] = [];
for (const token of payload.tokens) {
const klass = computeWordClass(token, options);
const parts = token.surface.split('\n');
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
if (part) {
chunks.push(`<span class="${klass}">${escapeHtml(part)}</span>`);
}
if (index < parts.length - 1) {
chunks.push('<br>');
}
}
}
return chunks.join('');
}
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
): string {
return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) });
}
export class SubtitleWebSocket {
private server: WebSocket.Server | null = null;
private latestMessage = '';
public isRunning(): boolean {
return this.server !== null;
}
public hasClients(): boolean {
return (this.server?.clients.size ?? 0) > 0;
}
public start(port: number, getCurrentSubtitleText: () => string): void {
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
this.server.on('connection', (ws: WebSocket) => {
logger.info('WebSocket client connected');
if (this.latestMessage) {
ws.send(this.latestMessage);
return;
}
const currentText = getCurrentSubtitleText();
if (currentText) {
ws.send(JSON.stringify({ sentence: currentText }));
}
});
this.server.on('error', (err: Error) => {
logger.error('WebSocket server error:', err.message);
});
logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
}
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return;
const message = serializeSubtitleWebsocketMessage(payload, options);
this.latestMessage = message;
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
public stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
this.latestMessage = '';
}
}

View File

@@ -0,0 +1,76 @@
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import { createLogger } from '../../logger';
const logger = createLogger('main:texthooker');
export class Texthooker {
private server: http.Server | null = null;
public isRunning(): boolean {
return this.server !== null;
}
public start(port: number): http.Server | null {
const texthookerPath = this.getTexthookerPath();
if (!texthookerPath) {
logger.error('texthooker-ui not found');
return null;
}
this.server = http.createServer((req, res) => {
const urlPath = (req.url || '/').split('?')[0] ?? '/';
const filePath = path.join(texthookerPath, urlPath === '/' ? 'index.html' : urlPath);
const ext = path.extname(filePath);
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
res.end(data);
});
});
this.server.listen(port, '127.0.0.1', () => {
logger.info(`Texthooker server running at http://127.0.0.1:${port}`);
});
return this.server;
}
public stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
private getTexthookerPath(): string | null {
const searchPaths = [
path.join(__dirname, '..', '..', '..', 'vendor', 'texthooker-ui', 'docs'),
path.join(process.resourcesPath, 'app', 'vendor', 'texthooker-ui', 'docs'),
];
for (const candidate of searchPaths) {
if (fs.existsSync(path.join(candidate, 'index.html'))) {
return candidate;
}
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import type { BrowserWindow, Extension } from 'electron';
import { mergeTokens } from '../../token-merger';
import { createLogger } from '../../logger';
import {
MergedToken,
NPlusOneMatchMode,
SubtitleData,
Token,
FrequencyDictionaryLookup,
JlptLevel,
} from '../../types';
import { annotateTokens } from './tokenizer/annotation-stage';
import { enrichTokensWithMecabPos1 } from './tokenizer/parser-enrichment-stage';
import { selectYomitanParseTokens } from './tokenizer/parser-selection-stage';
import { requestYomitanParseResults } from './tokenizer/yomitan-parser-runtime';
const logger = createLogger('main:tokenizer');
export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
getJlptEnabled?: () => boolean;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyRank?: FrequencyDictionaryLookup;
getMinSentenceWordsForNPlusOne?: () => number;
getYomitanGroupDebugEnabled?: () => boolean;
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
}
interface MecabTokenizerLike {
tokenize: (text: string) => Promise<Token[] | null>;
checkAvailability?: () => Promise<boolean>;
getStatus?: () => { available: boolean };
}
export interface TokenizerDepsRuntimeOptions {
getYomitanExt: () => Extension | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
getJlptEnabled?: () => boolean;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyRank?: FrequencyDictionaryLookup;
getMinSentenceWordsForNPlusOne?: () => number;
getYomitanGroupDebugEnabled?: () => boolean;
getMecabTokenizer: () => MecabTokenizerLike | null;
}
export function createTokenizerDepsRuntime(
options: TokenizerDepsRuntimeOptions,
): TokenizerServiceDeps {
const checkedMecabTokenizers = new WeakSet<object>();
return {
getYomitanExt: options.getYomitanExt,
getYomitanParserWindow: options.getYomitanParserWindow,
setYomitanParserWindow: options.setYomitanParserWindow,
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,
setYomitanParserReadyPromise: options.setYomitanParserReadyPromise,
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordMatchMode: options.getKnownWordMatchMode,
getJlptLevel: options.getJlptLevel,
getJlptEnabled: options.getJlptEnabled,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyRank: options.getFrequencyRank,
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
tokenizeWithMecab: async (text) => {
const mecabTokenizer = options.getMecabTokenizer();
if (!mecabTokenizer) {
return null;
}
if (
typeof mecabTokenizer.checkAvailability === 'function' &&
typeof mecabTokenizer.getStatus === 'function' &&
!checkedMecabTokenizers.has(mecabTokenizer as object)
) {
const status = mecabTokenizer.getStatus();
if (!status.available) {
await mecabTokenizer.checkAvailability();
}
checkedMecabTokenizers.add(mecabTokenizer as object);
}
const rawTokens = await mecabTokenizer.tokenize(text);
if (!rawTokens || rawTokens.length === 0) {
return null;
}
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode());
},
};
}
function logSelectedYomitanGroups(text: string, tokens: MergedToken[]): void {
if (tokens.length === 0) {
return;
}
logger.info('Selected Yomitan token groups', {
text,
tokenCount: tokens.length,
groups: tokens.map((token, index) => ({
index,
surface: token.surface,
headword: token.headword,
reading: token.reading,
startPos: token.startPos,
endPos: token.endPos,
})),
});
}
function getAnnotationOptions(deps: TokenizerServiceDeps): {
jlptEnabled: boolean;
frequencyEnabled: boolean;
minSentenceWordsForNPlusOne: number | undefined;
} {
return {
jlptEnabled: deps.getJlptEnabled?.() !== false,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
};
}
function applyAnnotationStage(tokens: MergedToken[], deps: TokenizerServiceDeps): MergedToken[] {
const options = getAnnotationOptions(deps);
return annotateTokens(
tokens,
{
isKnownWord: deps.isKnownWord,
knownWordMatchMode: deps.getKnownWordMatchMode(),
getJlptLevel: deps.getJlptLevel,
getFrequencyRank: deps.getFrequencyRank,
},
options,
);
}
async function parseWithYomitanInternalParser(
text: string,
deps: TokenizerServiceDeps,
): Promise<MergedToken[] | null> {
const parseResults = await requestYomitanParseResults(text, deps, logger);
if (!parseResults) {
return null;
}
const selectedTokens = selectYomitanParseTokens(
parseResults,
deps.isKnownWord,
deps.getKnownWordMatchMode(),
);
if (!selectedTokens || selectedTokens.length === 0) {
return null;
}
if (deps.getYomitanGroupDebugEnabled?.() === true) {
logSelectedYomitanGroups(text, selectedTokens);
}
try {
const mecabTokens = await deps.tokenizeWithMecab(text);
return enrichTokensWithMecabPos1(selectedTokens, mecabTokens);
} catch (err) {
const error = err as Error;
logger.warn(
'Failed to enrich Yomitan tokens with MeCab POS:',
error.message,
`tokenCount=${selectedTokens.length}`,
`textLength=${text.length}`,
);
return selectedTokens;
}
}
export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
): Promise<SubtitleData> {
const displayText = text
.replace(/\r\n/g, '\n')
.replace(/\\N/g, '\n')
.replace(/\\n/g, '\n')
.trim();
if (!displayText) {
return { text, tokens: null };
}
const tokenizeText = displayText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
if (yomitanTokens && yomitanTokens.length > 0) {
return {
text: displayText,
tokens: applyAnnotationStage(yomitanTokens, deps),
};
}
return { text: displayText, tokens: null };
}

View File

@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MergedToken, PartOfSpeech } from '../../../types';
import { annotateTokens, AnnotationStageDeps } from './annotation-stage';
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
return {
surface: '猫',
reading: 'ネコ',
headword: '猫',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): AnnotationStageDeps {
return {
isKnownWord: () => false,
knownWordMatchMode: 'headword',
getJlptLevel: () => null,
...overrides,
};
}
test('annotateTokens known-word match mode uses headword vs surface', () => {
const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })];
const isKnownWord = (text: string): boolean => text === '食べる';
const headwordResult = annotateTokens(
tokens,
makeDeps({
isKnownWord,
knownWordMatchMode: 'headword',
}),
);
const surfaceResult = annotateTokens(
tokens,
makeDeps({
isKnownWord,
knownWordMatchMode: 'surface',
}),
);
assert.equal(headwordResult[0]?.isKnown, true);
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => {
const lookupCalls: string[] = [];
const tokens = [
makeToken({ surface: 'は', headword: 'は', partOfSpeech: PartOfSpeech.particle }),
makeToken({
surface: 'です',
headword: 'です',
partOfSpeech: PartOfSpeech.bound_auxiliary,
startPos: 1,
endPos: 3,
}),
makeToken({
surface: 'の',
headword: 'の',
partOfSpeech: PartOfSpeech.other,
pos1: '助詞',
startPos: 3,
endPos: 4,
}),
makeToken({
surface: '猫',
headword: '猫',
partOfSpeech: PartOfSpeech.noun,
startPos: 4,
endPos: 5,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
getFrequencyRank: (text) => {
lookupCalls.push(text);
return text === '猫' ? 11 : 999;
},
}),
);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[1]?.frequencyRank, undefined);
assert.equal(result[2]?.frequencyRank, undefined);
assert.equal(result[3]?.frequencyRank, 11);
assert.deepEqual(lookupCalls, ['猫']);
});
test('annotateTokens handles JLPT disabled and eligibility exclusion paths', () => {
let disabledLookupCalls = 0;
const disabledResult = annotateTokens(
[makeToken({ surface: '猫', headword: '猫' })],
makeDeps({
getJlptLevel: () => {
disabledLookupCalls += 1;
return 'N5';
},
}),
{ jlptEnabled: false },
);
assert.equal(disabledResult[0]?.jlptLevel, undefined);
assert.equal(disabledLookupCalls, 0);
let excludedLookupCalls = 0;
const excludedResult = annotateTokens(
[
makeToken({
surface: '',
headword: '',
reading: '',
pos1: '記号',
partOfSpeech: PartOfSpeech.symbol,
}),
],
makeDeps({
getJlptLevel: () => {
excludedLookupCalls += 1;
return 'N5';
},
}),
);
assert.equal(excludedResult[0]?.jlptLevel, undefined);
assert.equal(excludedLookupCalls, 0);
});
test('annotateTokens N+1 handoff marks expected target when threshold is satisfied', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({
surface: '見る',
headword: '見る',
partOfSpeech: PartOfSpeech.verb,
startPos: 2,
endPos: 4,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '見る',
}),
{ minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isNPlusOneTarget, true);
assert.equal(result[2]?.isNPlusOneTarget, false);
});

View File

@@ -0,0 +1,375 @@
import { markNPlusOneTargets } from '../../../token-merger';
import {
FrequencyDictionaryLookup,
JlptLevel,
MergedToken,
NPlusOneMatchMode,
PartOfSpeech,
} from '../../../types';
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const JLPT_LEVEL_LOOKUP_CACHE_LIMIT = 2048;
const FREQUENCY_RANK_LOOKUP_CACHE_LIMIT = 2048;
const jlptLevelLookupCaches = new WeakMap<
(text: string) => JlptLevel | null,
Map<string, JlptLevel | null>
>();
const frequencyRankLookupCaches = new WeakMap<
FrequencyDictionaryLookup,
Map<string, number | null>
>();
export interface AnnotationStageDeps {
isKnownWord: (text: string) => boolean;
knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
getFrequencyRank?: FrequencyDictionaryLookup;
}
export interface AnnotationStageOptions {
jlptEnabled?: boolean;
frequencyEnabled?: boolean;
minSentenceWordsForNPlusOne?: number;
}
function resolveKnownWordText(
surface: string,
headword: string,
matchMode: NPlusOneMatchMode,
): string {
return matchMode === 'surface' ? surface : headword;
}
function applyKnownWordMarking(
tokens: MergedToken[],
isKnownWord: (text: string) => boolean,
knownWordMatchMode: NPlusOneMatchMode,
): MergedToken[] {
return tokens.map((token) => {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
return {
...token,
isKnown: token.isKnown || (matchText ? isKnownWord(matchText) : false),
};
});
}
function normalizeFrequencyLookupText(rawText: string): string {
return rawText.trim().toLowerCase();
}
function getCachedFrequencyRank(
lookupText: string,
getFrequencyRank: FrequencyDictionaryLookup,
): number | null {
const normalizedText = normalizeFrequencyLookupText(lookupText);
if (!normalizedText) {
return null;
}
let cache = frequencyRankLookupCaches.get(getFrequencyRank);
if (!cache) {
cache = new Map<string, number | null>();
frequencyRankLookupCaches.set(getFrequencyRank, cache);
}
if (cache.has(normalizedText)) {
return cache.get(normalizedText) ?? null;
}
let rank: number | null;
try {
rank = getFrequencyRank(normalizedText);
} catch {
rank = null;
}
if (rank !== null) {
if (!Number.isFinite(rank) || rank <= 0) {
rank = null;
}
}
cache.set(normalizedText, rank);
while (cache.size > FREQUENCY_RANK_LOOKUP_CACHE_LIMIT) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) {
cache.delete(firstKey);
}
}
return rank;
}
function resolveFrequencyLookupText(token: MergedToken): string {
if (token.headword && token.headword.length > 0) {
return token.headword;
}
if (token.reading && token.reading.length > 0) {
return token.reading;
}
return token.surface;
}
function getFrequencyLookupTextCandidates(token: MergedToken): string[] {
const lookupText = resolveFrequencyLookupText(token).trim();
return lookupText ? [lookupText] : [];
}
function isFrequencyExcludedByPos(token: MergedToken): boolean {
if (
token.partOfSpeech === PartOfSpeech.particle ||
token.partOfSpeech === PartOfSpeech.bound_auxiliary
) {
return true;
}
return token.pos1 === '助詞' || token.pos1 === '助動詞';
}
function applyFrequencyMarking(
tokens: MergedToken[],
getFrequencyRank: FrequencyDictionaryLookup,
): MergedToken[] {
return tokens.map((token) => {
if (isFrequencyExcludedByPos(token)) {
return { ...token, frequencyRank: undefined };
}
const lookupTexts = getFrequencyLookupTextCandidates(token);
if (lookupTexts.length === 0) {
return { ...token, frequencyRank: undefined };
}
let bestRank: number | null = null;
for (const lookupText of lookupTexts) {
const rank = getCachedFrequencyRank(lookupText, getFrequencyRank);
if (rank === null) {
continue;
}
if (bestRank === null || rank < bestRank) {
bestRank = rank;
}
}
return {
...token,
frequencyRank: bestRank ?? undefined,
};
});
}
function getCachedJlptLevel(
lookupText: string,
getJlptLevel: (text: string) => JlptLevel | null,
): JlptLevel | null {
const normalizedText = lookupText.trim();
if (!normalizedText) {
return null;
}
let cache = jlptLevelLookupCaches.get(getJlptLevel);
if (!cache) {
cache = new Map<string, JlptLevel | null>();
jlptLevelLookupCaches.set(getJlptLevel, cache);
}
if (cache.has(normalizedText)) {
return cache.get(normalizedText) ?? null;
}
let level: JlptLevel | null;
try {
level = getJlptLevel(normalizedText);
} catch {
level = null;
}
cache.set(normalizedText, level);
while (cache.size > JLPT_LEVEL_LOOKUP_CACHE_LIMIT) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) {
cache.delete(firstKey);
}
}
return level;
}
function resolveJlptLookupText(token: MergedToken): string {
if (token.headword && token.headword.length > 0) {
return token.headword;
}
if (token.reading && token.reading.length > 0) {
return token.reading;
}
return token.surface;
}
function normalizeJlptTextForExclusion(text: string): string {
const raw = text.trim();
if (!raw) {
return '';
}
let normalized = '';
for (const char of raw) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
normalized += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
continue;
}
normalized += char;
}
return normalized;
}
function isKanaChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (
(code >= 0x3041 && code <= 0x3096) ||
(code >= 0x309b && code <= 0x309f) ||
(code >= 0x30a0 && code <= 0x30fa) ||
(code >= 0x30fd && code <= 0x30ff)
);
}
function isRepeatedKanaSfx(text: string): boolean {
const normalized = text.trim();
if (!normalized) {
return false;
}
const chars = [...normalized];
if (!chars.every(isKanaChar)) {
return false;
}
const counts = new Map<string, number>();
let hasAdjacentRepeat = false;
for (let i = 0; i < chars.length; i += 1) {
const char = chars[i]!;
counts.set(char, (counts.get(char) ?? 0) + 1);
if (i > 0 && chars[i] === chars[i - 1]) {
hasAdjacentRepeat = true;
}
}
const topCount = Math.max(...counts.values());
if (chars.length <= 2) {
return hasAdjacentRepeat || topCount >= 2;
}
if (hasAdjacentRepeat) {
return true;
}
return topCount >= Math.ceil(chars.length / 2);
}
function isJlptEligibleToken(token: MergedToken): boolean {
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
return false;
}
const candidates = [
resolveJlptLookupText(token),
token.surface,
token.reading,
token.headword,
].filter(
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
);
for (const candidate of candidates) {
const normalizedCandidate = normalizeJlptTextForExclusion(candidate);
if (!normalizedCandidate) {
continue;
}
const trimmedCandidate = candidate.trim();
if (shouldIgnoreJlptByTerm(trimmedCandidate) || shouldIgnoreJlptByTerm(normalizedCandidate)) {
return false;
}
if (isRepeatedKanaSfx(candidate) || isRepeatedKanaSfx(normalizedCandidate)) {
return false;
}
}
return true;
}
function applyJlptMarking(
tokens: MergedToken[],
getJlptLevel: (text: string) => JlptLevel | null,
): MergedToken[] {
return tokens.map((token) => {
if (!isJlptEligibleToken(token)) {
return { ...token, jlptLevel: undefined };
}
const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel);
const fallbackLevel =
primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null;
return {
...token,
jlptLevel: primaryLevel ?? fallbackLevel ?? token.jlptLevel,
};
});
}
export function annotateTokens(
tokens: MergedToken[],
deps: AnnotationStageDeps,
options: AnnotationStageOptions = {},
): MergedToken[] {
const knownMarkedTokens = applyKnownWordMarking(
tokens,
deps.isKnownWord,
deps.knownWordMatchMode,
);
const frequencyEnabled = options.frequencyEnabled !== false;
const frequencyMarkedTokens =
frequencyEnabled && deps.getFrequencyRank
? applyFrequencyMarking(knownMarkedTokens, deps.getFrequencyRank)
: knownMarkedTokens.map((token) => ({
...token,
frequencyRank: undefined,
}));
const jlptEnabled = options.jlptEnabled !== false;
const jlptMarkedTokens = jlptEnabled
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
: frequencyMarkedTokens.map((token) => ({
...token,
jlptLevel: undefined,
}));
const minSentenceWordsForNPlusOne = options.minSentenceWordsForNPlusOne;
const sanitizedMinSentenceWordsForNPlusOne =
minSentenceWordsForNPlusOne !== undefined &&
Number.isInteger(minSentenceWordsForNPlusOne) &&
minSentenceWordsForNPlusOne > 0
? minSentenceWordsForNPlusOne
: 3;
return markNPlusOneTargets(jlptMarkedTokens, sanitizedMinSentenceWordsForNPlusOne);
}

Some files were not shown because too many files have changed in this diff Show More