This commit is contained in:
2026-02-17 22:50:57 -08:00
parent ffeef9c136
commit f20d019c11
315 changed files with 9876 additions and 12537 deletions

View File

@@ -1,15 +1,15 @@
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 { safeStorage } from "electron";
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 { safeStorage } from 'electron';
import { createAnilistTokenStore } from "./anilist-token-store";
import { createAnilistTokenStore } from './anilist-token-store';
function createTempTokenFile(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-anilist-token-"));
return path.join(dir, "token.json");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-token-'));
return path.join(dir, 'token.json');
}
function createLogger() {
@@ -28,14 +28,13 @@ type SafeStorageLike = {
const safeStorageApi = safeStorage as unknown as Partial<SafeStorageLike>;
const hasSafeStorage =
typeof safeStorageApi?.isEncryptionAvailable === "function" &&
typeof safeStorageApi?.encryptString === "function" &&
typeof safeStorageApi?.decryptString === "function";
typeof safeStorageApi?.isEncryptionAvailable === 'function' &&
typeof safeStorageApi?.encryptString === 'function' &&
typeof safeStorageApi?.decryptString === 'function';
const originalSafeStorage: SafeStorageLike | null = hasSafeStorage
? {
isEncryptionAvailable:
safeStorageApi.isEncryptionAvailable as () => boolean,
isEncryptionAvailable: safeStorageApi.isEncryptionAvailable as () => boolean,
encryptString: safeStorageApi.encryptString as (value: string) => Buffer,
decryptString: safeStorageApi.decryptString as (value: Buffer) => string,
}
@@ -55,14 +54,14 @@ function mockSafeStorage(encryptionAvailable: boolean): void {
encryptString: typeof safeStorage.encryptString;
decryptString: typeof safeStorage.decryptString;
}
).encryptString = (value: string) => Buffer.from(`enc:${value}`, "utf-8");
).encryptString = (value: string) => Buffer.from(`enc:${value}`, 'utf-8');
(
safeStorage as unknown as {
decryptString: typeof safeStorage.decryptString;
}
).decryptString = (value: Buffer) => {
const raw = value.toString("utf-8");
return raw.startsWith("enc:") ? raw.slice(4) : raw;
const raw = value.toString('utf-8');
return raw.startsWith('enc:') ? raw.slice(4) : raw;
};
}
@@ -88,44 +87,40 @@ function restoreSafeStorage(): void {
).decryptString = originalSafeStorage.decryptString;
}
test(
"anilist token store saves and loads encrypted token",
{ skip: !hasSafeStorage },
() => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken(" demo-token ");
test('anilist token store saves and loads encrypted token', { skip: !hasSafeStorage }, () => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
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");
} finally {
restoreSafeStorage();
}
},
);
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');
} finally {
restoreSafeStorage();
}
});
test(
"anilist token store falls back to plaintext when encryption unavailable",
'anilist token store falls back to plaintext when encryption unavailable',
{ skip: !hasSafeStorage },
() => {
mockSafeStorage(false);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken("plain-token");
store.saveToken('plain-token');
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
plaintextToken?: string;
};
assert.equal(payload.plaintextToken, "plain-token");
assert.equal(store.loadToken(), "plain-token");
assert.equal(payload.plaintextToken, 'plain-token');
assert.equal(store.loadToken(), 'plain-token');
} finally {
restoreSafeStorage();
}
@@ -133,26 +128,26 @@ test(
);
test(
"anilist token store migrates legacy plaintext to encrypted",
'anilist token store migrates legacy plaintext to encrypted',
{ skip: !hasSafeStorage },
() => {
const filePath = createTempTokenFile();
fs.writeFileSync(
filePath,
JSON.stringify({ plaintextToken: "legacy-token", updatedAt: Date.now() }),
"utf-8",
JSON.stringify({ plaintextToken: 'legacy-token', updatedAt: Date.now() }),
'utf-8',
);
mockSafeStorage(true);
try {
const store = createAnilistTokenStore(filePath, createLogger());
assert.equal(store.loadToken(), "legacy-token");
assert.equal(store.loadToken(), 'legacy-token');
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, "string");
assert.equal(typeof payload.encryptedToken, 'string');
assert.equal(payload.plaintextToken, undefined);
} finally {
restoreSafeStorage();
@@ -160,20 +155,16 @@ test(
},
);
test(
"anilist token store clears persisted token file",
{ skip: !hasSafeStorage },
() => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken("to-clear");
assert.equal(fs.existsSync(filePath), true);
store.clearToken();
assert.equal(fs.existsSync(filePath), false);
} finally {
restoreSafeStorage();
}
},
);
test('anilist token store clears persisted token file', { skip: !hasSafeStorage }, () => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken('to-clear');
assert.equal(fs.existsSync(filePath), true);
store.clearToken();
assert.equal(fs.existsSync(filePath), false);
} finally {
restoreSafeStorage();
}
});

View File

@@ -1,6 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import { safeStorage } from "electron";
import * as fs from 'fs';
import * as path from 'path';
import { safeStorage } from 'electron';
interface PersistedTokenPayload {
encryptedToken?: string;
@@ -23,7 +23,7 @@ function ensureDirectory(filePath: string): void {
function writePayload(filePath: string, payload: PersistedTokenPayload): void {
ensureDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
}
export function createAnilistTokenStore(
@@ -40,33 +40,25 @@ export function createAnilistTokenStore(
return null;
}
try {
const raw = fs.readFileSync(filePath, "utf-8");
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 (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
if (!safeStorage.isEncryptionAvailable()) {
logger.warn(
"AniList token encryption is not available on this system.",
);
logger.warn('AniList token encryption is not available on this system.');
return null;
}
const decrypted = safeStorage.decryptString(encrypted).trim();
return decrypted.length > 0 ? decrypted : null;
}
if (
typeof parsed.plaintextToken === "string" &&
parsed.plaintextToken.trim().length > 0
) {
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);
logger.error('Failed to read AniList token store.', error);
}
return null;
},
@@ -79,9 +71,7 @@ export function createAnilistTokenStore(
}
try {
if (!safeStorage.isEncryptionAvailable()) {
logger.warn(
"AniList token encryption unavailable; storing token in plaintext fallback.",
);
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
writePayload(filePath, {
plaintextToken: trimmed,
updatedAt: Date.now(),
@@ -90,11 +80,11 @@ export function createAnilistTokenStore(
}
const encrypted = safeStorage.encryptString(trimmed);
writePayload(filePath, {
encryptedToken: encrypted.toString("base64"),
encryptedToken: encrypted.toString('base64'),
updatedAt: Date.now(),
});
} catch (error) {
logger.error("Failed to persist AniList token.", error);
logger.error('Failed to persist AniList token.', error);
}
},
@@ -102,9 +92,9 @@ export function createAnilistTokenStore(
if (!fs.existsSync(filePath)) return;
try {
fs.unlinkSync(filePath);
logger.info("Cleared stored AniList token.");
logger.info('Cleared stored AniList token.');
} catch (error) {
logger.error("Failed to clear stored AniList token.", error);
logger.error('Failed to clear stored AniList token.', error);
}
},
};

View File

@@ -1,14 +1,14 @@
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 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";
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");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-queue-'));
return path.join(dir, 'queue.json');
}
function createLogger() {
@@ -27,65 +27,61 @@ function createLogger() {
};
}
test("anilist update queue enqueues, snapshots, and dequeues success", () => {
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);
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");
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
queue.markSuccess("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"),
),
);
assert.ok(loggerState.info.some((message) => message.includes('Queued AniList retry')));
});
test("anilist update queue applies retry backoff and dead-letter", () => {
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.enqueue('k2', 'Backoff Demo', 2);
queue.markFailure("k2", "fail-1", now);
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 {
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);
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."),
message.includes('AniList retry moved to dead-letter queue.'),
),
);
});
test("anilist update queue persists and reloads from disk", () => {
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);
queueA.enqueue('k3', 'Persist Demo', 3);
const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -93,8 +89,5 @@ test("anilist update queue persists and reloads from disk", () => {
ready: 1,
deadLetter: 0,
});
assert.equal(
queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title,
"Persist Demo",
);
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
});

View File

@@ -1,5 +1,5 @@
import * as fs from "fs";
import * as path from "path";
import * as fs from 'fs';
import * as path from 'path';
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -43,8 +43,7 @@ function ensureDir(filePath: string): void {
}
function clampBackoffMs(attemptCount: number): number {
const computed =
INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed);
}
@@ -63,9 +62,9 @@ export function createAnilistUpdateQueue(
try {
ensureDir(filePath);
const payload: AnilistRetryQueuePayload = { pending, deadLetter };
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8");
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
logger.error("Failed to persist AniList retry queue.", error);
logger.error('Failed to persist AniList retry queue.', error);
}
};
@@ -74,42 +73,40 @@ export function createAnilistUpdateQueue(
return;
}
try {
const raw = fs.readFileSync(filePath, "utf-8");
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
: [];
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" &&
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),
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" &&
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),
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);
logger.error('Failed to load AniList retry queue.', error);
}
};
@@ -166,7 +163,7 @@ export function createAnilistUpdateQueue(
...item,
nextAttemptAt: nowMs,
});
logger.warn("AniList retry moved to dead-letter queue.", {
logger.warn('AniList retry moved to dead-letter queue.', {
key,
reason,
attempts: item.attemptCount,
@@ -176,7 +173,7 @@ export function createAnilistUpdateQueue(
}
item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount);
persist();
logger.warn("AniList retry scheduled with backoff.", {
logger.warn('AniList retry scheduled with backoff.', {
key,
attemptCount: item.attemptCount,
nextAttemptAt: item.nextAttemptAt,
@@ -185,9 +182,7 @@ export function createAnilistUpdateQueue(
},
getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot {
const ready = pending.filter(
(item) => item.nextAttemptAt <= nowMs,
).length;
const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length;
return {
pending: pending.length,
ready,

View File

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

@@ -1,17 +1,17 @@
import * as childProcess from "child_process";
import * as childProcess from 'child_process';
import { parseMediaInfo } from "../../../jimaku/utils";
import { parseMediaInfo } from '../../../jimaku/utils';
const ANILIST_GRAPHQL_URL = "https://graphql.anilist.co";
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export interface AnilistMediaGuess {
title: string;
episode: number | null;
source: "guessit" | "fallback";
source: 'guessit' | 'fallback';
}
export interface AnilistPostWatchUpdateResult {
status: "updated" | "skipped" | "error";
status: 'updated' | 'skipped' | 'error';
message: string;
}
@@ -58,8 +58,8 @@ interface AnilistSaveEntryData {
function runGuessit(target: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.execFile(
"guessit",
[target, "--json"],
'guessit',
[target, '--json'],
{ timeout: 5000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
@@ -73,7 +73,7 @@ function runGuessit(target: string): Promise<string> {
}
function firstString(value: unknown): string | null {
if (typeof value === "string") {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
@@ -87,10 +87,10 @@ function firstString(value: unknown): string | null {
}
function firstPositiveInteger(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
return value;
}
if (typeof value === "string") {
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
@@ -104,7 +104,7 @@ function firstPositiveInteger(value: unknown): number | null {
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, " ");
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
async function anilistGraphQl<T>(
@@ -114,9 +114,9 @@ async function anilistGraphQl<T>(
): Promise<AnilistGraphQlResponse<T>> {
try {
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
@@ -135,9 +135,7 @@ async function anilistGraphQl<T>(
}
}
function firstErrorMessage<T>(
response: AnilistGraphQlResponse<T>,
): string | null {
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | null {
const firstError = response.errors?.find((item) => Boolean(item?.message));
return firstError?.message ?? null;
}
@@ -165,17 +163,14 @@ function pickBestSearchResult(
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")
.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;
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle };
}
@@ -192,7 +187,7 @@ export async function guessAnilistMediaInfo(
const title = firstString(parsed.title);
const episode = firstPositiveInteger(parsed.episode);
if (title) {
return { title, episode, source: "guessit" };
return { title, episode, source: 'guessit' };
}
} catch {
// Ignore guessit failures and fall back to internal parser.
@@ -207,7 +202,7 @@ export async function guessAnilistMediaInfo(
return {
title: parsed.title.trim(),
episode: parsed.episode,
source: "fallback",
source: 'fallback',
};
}
@@ -238,7 +233,7 @@ export async function updateAnilistPostWatchProgress(
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
return {
status: "error",
status: 'error',
message: `AniList search failed: ${searchError}`,
};
}
@@ -246,7 +241,7 @@ export async function updateAnilistPostWatchProgress(
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
if (!picked) {
return { status: "error", message: "AniList search returned no matches." };
return { status: 'error', message: 'AniList search returned no matches.' };
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
@@ -267,16 +262,15 @@ export async function updateAnilistPostWatchProgress(
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
return {
status: "error",
status: 'error',
message: `AniList entry lookup failed: ${entryError}`,
};
}
const currentProgress =
entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === "number" && currentProgress >= episode) {
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: "skipped",
status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
};
}
@@ -295,11 +289,11 @@ export async function updateAnilistPostWatchProgress(
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
return { status: "error", message: `AniList update failed: ${saveError}` };
return { status: 'error', message: `AniList update failed: ${saveError}` };
}
return {
status: "updated",
status: 'updated',
message: `AniList updated "${picked.title}" to episode ${episode}.`,
};
}

View File

@@ -1,8 +1,8 @@
import { ipcMain, IpcMainEvent } from "electron";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { createLogger } from "../../logger";
import { ipcMain, IpcMainEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '../../logger';
import {
JimakuApiResponse,
JimakuDownloadQuery,
@@ -15,25 +15,19 @@ import {
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
} from "../../types";
} from '../../types';
const logger = createLogger("main:anki-jimaku-ipc");
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>;
buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
getJimakuMediaInfo: () => JimakuMediaInfo;
searchJimakuEntries: (
query: JimakuSearchQuery,
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listJimakuFiles: (
query: JimakuFilesQuery,
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
searchJimakuEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listJimakuFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
resolveJimakuApiKey: () => Promise<string | null>;
getCurrentMediaPath: () => string | null;
isRemoteMediaPath: (mediaPath: string) => boolean;
@@ -46,75 +40,59 @@ export interface AnkiJimakuIpcDeps {
}
export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
ipcMain.on(
"set-anki-connect-enabled",
(_event: IpcMainEvent, enabled: boolean) => {
deps.setAnkiConnectEnabled(enabled);
},
);
ipcMain.on('set-anki-connect-enabled', (_event: IpcMainEvent, enabled: boolean) => {
deps.setAnkiConnectEnabled(enabled);
});
ipcMain.on("clear-anki-connect-history", () => {
ipcMain.on('clear-anki-connect-history', () => {
deps.clearAnkiHistory();
});
ipcMain.on("anki:refresh-known-words", async () => {
ipcMain.on('anki:refresh-known-words', async () => {
await deps.refreshKnownWords();
});
ipcMain.on(
"kiku:field-grouping-respond",
'kiku:field-grouping-respond',
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {
deps.respondFieldGrouping(choice);
},
);
ipcMain.handle(
"kiku:build-merge-preview",
async (
_event,
request: KikuMergePreviewRequest,
): Promise<KikuMergePreviewResponse> => {
'kiku:build-merge-preview',
async (_event, request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> => {
return deps.buildKikuMergePreview(request);
},
);
ipcMain.handle("jimaku:get-media-info", (): JimakuMediaInfo => {
ipcMain.handle('jimaku:get-media-info', (): JimakuMediaInfo => {
return deps.getJimakuMediaInfo();
});
ipcMain.handle(
"jimaku:search-entries",
async (
_event,
query: JimakuSearchQuery,
): Promise<JimakuApiResponse<JimakuEntry[]>> => {
'jimaku:search-entries',
async (_event, query: JimakuSearchQuery): Promise<JimakuApiResponse<JimakuEntry[]>> => {
return deps.searchJimakuEntries(query);
},
);
ipcMain.handle(
"jimaku:list-files",
async (
_event,
query: JimakuFilesQuery,
): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
'jimaku:list-files',
async (_event, query: JimakuFilesQuery): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
return deps.listJimakuFiles(query);
},
);
ipcMain.handle(
"jimaku:download-file",
async (
_event,
query: JimakuDownloadQuery,
): Promise<JimakuDownloadResult> => {
'jimaku:download-file',
async (_event, query: JimakuDownloadQuery): Promise<JimakuDownloadResult> => {
const apiKey = await deps.resolveJimakuApiKey();
if (!apiKey) {
return {
ok: false,
error: {
error:
"Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.",
error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.',
code: 401,
},
};
@@ -122,25 +100,22 @@ export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
const currentMediaPath = deps.getCurrentMediaPath();
if (!currentMediaPath) {
return { ok: false, error: { error: "No media file loaded in MPV." } };
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), "subminer-jimaku-"))
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(query.name);
if (!safeName) {
return { ok: false, error: { error: "Invalid subtitle filename." } };
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-${query.entryId})${ext}`,
);
targetPath = path.join(mediaDir, `${baseName} (jimaku-${query.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
@@ -151,21 +126,17 @@ export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
}
}
logger.info(
`[jimaku] download-file name="${query.name}" entryId=${query.entryId}`,
);
logger.info(`[jimaku] download-file name="${query.name}" entryId=${query.entryId}`);
const result = await deps.downloadToFile(query.url, targetPath, {
Authorization: apiKey,
"User-Agent": "SubMiner",
'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"}`,
);
logger.error(`[jimaku] download-file failed: ${result.error?.error ?? 'unknown error'}`);
}
return result;

View File

@@ -1,9 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
AnkiJimakuIpcRuntimeOptions,
registerAnkiJimakuIpcRuntime,
} from "./anki-jimaku";
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnkiJimakuIpcRuntimeOptions, registerAnkiJimakuIpcRuntime } from './anki-jimaku';
interface RuntimeHarness {
options: AnkiJimakuIpcRuntimeOptions;
@@ -48,7 +45,7 @@ function createHarness(): RuntimeHarness {
setAnkiIntegration: (integration) => {
state.ankiIntegration = integration;
},
getKnownWordCacheStatePath: () => "/tmp/subminer-known-words-cache.json",
getKnownWordCacheStatePath: () => '/tmp/subminer-known-words-cache.json',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
@@ -64,14 +61,14 @@ function createHarness(): RuntimeHarness {
state.fieldGroupingResolver = resolver as never;
},
parseMediaInfo: () => ({
title: "video",
confidence: "high",
rawTitle: "video",
filename: "video.mkv",
title: 'video',
confidence: 'high',
rawTitle: 'video',
filename: 'video.mkv',
season: null,
episode: null,
}),
getCurrentMediaPath: () => "/tmp/video.mkv",
getCurrentMediaPath: () => '/tmp/video.mkv',
jimakuFetchJson: async (endpoint, query) => {
state.fetchCalls.push({
endpoint,
@@ -80,15 +77,15 @@ function createHarness(): RuntimeHarness {
return {
ok: true,
data: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
{ id: 3, name: "c" },
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
] as never,
};
},
getJimakuMaxEntryResults: () => 2,
getJimakuLanguagePreference: () => "ja",
resolveJimakuApiKey: async () => "token",
getJimakuLanguagePreference: () => 'ja',
resolveJimakuApiKey: async () => 'token',
isRemoteMediaPath: () => false,
downloadToFile: async (url, destPath) => ({
ok: true,
@@ -98,50 +95,47 @@ function createHarness(): RuntimeHarness {
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntime(options, (deps) => {
registered = deps as unknown as Record<
string,
(...args: unknown[]) => unknown
>;
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
});
return { options, registered, state };
}
test("registerAnkiJimakuIpcRuntime provides full handler surface", () => {
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
const { registered } = createHarness();
const expected = [
"setAnkiConnectEnabled",
"clearAnkiHistory",
"refreshKnownWords",
"respondFieldGrouping",
"buildKikuMergePreview",
"getJimakuMediaInfo",
"searchJimakuEntries",
"listJimakuFiles",
"resolveJimakuApiKey",
"getCurrentMediaPath",
"isRemoteMediaPath",
"downloadToFile",
"onDownloadedSubtitle",
'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}`);
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
}
});
test("refreshKnownWords throws when integration is unavailable", async () => {
test('refreshKnownWords throws when integration is unavailable', async () => {
const { registered } = createHarness();
await assert.rejects(
async () => {
await registered.refreshKnownWords();
},
{ message: "AnkiConnect integration not enabled" },
{ message: 'AnkiConnect integration not enabled' },
);
});
test("refreshKnownWords delegates to integration", async () => {
test('refreshKnownWords delegates to integration', async () => {
const { registered, state } = createHarness();
let refreshed = 0;
state.ankiIntegration = {
@@ -155,7 +149,7 @@ test("refreshKnownWords delegates to integration", async () => {
assert.equal(refreshed, 1);
});
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
const { registered, state } = createHarness();
let destroyed = 0;
state.ankiIntegration = {
@@ -172,7 +166,7 @@ test("setAnkiConnectEnabled disables active integration and broadcasts changes",
assert.equal(state.broadcasts, 1);
});
test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => {
test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () => {
const { registered, state, options } = createHarness();
let cleaned = 0;
let resolvedChoice: unknown = null;
@@ -204,7 +198,7 @@ test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () =
assert.equal(state.fieldGroupingResolver, null);
});
test("buildKikuMergePreview returns guard error when integration is missing", async () => {
test('buildKikuMergePreview returns guard error when integration is missing', async () => {
const { registered } = createHarness();
const result = await registered.buildKikuMergePreview({
@@ -215,11 +209,11 @@ test("buildKikuMergePreview returns guard error when integration is missing", as
assert.deepEqual(result, {
ok: false,
error: "AnkiConnect integration not enabled",
error: 'AnkiConnect integration not enabled',
});
});
test("buildKikuMergePreview delegates to integration when available", async () => {
test('buildKikuMergePreview delegates to integration when available', async () => {
const { registered, state } = createHarness();
const calls: unknown[] = [];
state.ankiIntegration = {
@@ -243,21 +237,19 @@ test("buildKikuMergePreview delegates to integration when available", async () =
assert.deepEqual(result, { ok: true });
});
test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => {
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchJimakuEntries({ query: "test" });
const searchResult = await registered.searchJimakuEntries({ query: 'test' });
assert.deepEqual(state.fetchCalls, [
{
endpoint: "/api/entries/search",
query: { anime: true, query: "test" },
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"] },
]);
registered.onDownloadedSubtitle('/tmp/subtitle.ass');
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
});

View File

@@ -1,4 +1,4 @@
import { AnkiIntegration } from "../../anki-integration";
import { AnkiIntegration } from '../../anki-integration';
import {
AnkiConnectConfig,
JimakuApiResponse,
@@ -8,14 +8,12 @@ import {
JimakuMediaInfo,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
import { sortJimakuFiles } from "../../jimaku/utils";
import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc";
import { createLogger } from "../../logger";
} from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger';
export type RegisterAnkiJimakuIpcRuntimeHandler = (
deps: AnkiJimakuIpcDeps,
) => void;
export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => void;
interface MpvClientLike {
connected: boolean;
@@ -23,9 +21,7 @@ interface MpvClientLike {
}
interface RuntimeOptionsManagerLike {
getEffectiveAnkiConnectConfig: (
config?: AnkiConnectConfig,
) => AnkiConnectConfig;
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
}
interface SubtitleTimingTrackerLike {
@@ -41,20 +37,13 @@ export interface AnkiJimakuIpcRuntimeOptions {
getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string;
showDesktopNotification: (
title: string,
options: { body?: string; icon?: string },
) => void;
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;
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
getCurrentMediaPath: () => string | null;
jimakuFetchJson: <T>(
@@ -78,7 +67,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
>;
}
const logger = createLogger("main:anki-jimaku");
const logger = createLogger('main:anki-jimaku');
export function registerAnkiJimakuIpcRuntime(
options: AnkiJimakuIpcRuntimeOptions,
@@ -95,9 +84,7 @@ export function registerAnkiJimakuIpcRuntime(
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
const runtimeOptionsManager = options.getRuntimeOptionsManager();
const effectiveAnkiConfig = runtimeOptionsManager
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(
config.ankiConnect,
)
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect)
: config.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig as never,
@@ -106,7 +93,7 @@ export function registerAnkiJimakuIpcRuntime(
(text: string) => {
if (mpvClient) {
mpvClient.send({
command: ["show-text", text, "3000"],
command: ['show-text', text, '3000'],
});
}
},
@@ -116,11 +103,11 @@ export function registerAnkiJimakuIpcRuntime(
);
integration.start();
options.setAnkiIntegration(integration);
logger.info("AnkiConnect integration enabled");
logger.info('AnkiConnect integration enabled');
} else if (!enabled && ankiIntegration) {
ankiIntegration.destroy();
options.setAnkiIntegration(null);
logger.info("AnkiConnect integration disabled");
logger.info('AnkiConnect integration disabled');
}
options.broadcastRuntimeOptionsChanged();
@@ -129,13 +116,13 @@ export function registerAnkiJimakuIpcRuntime(
const subtitleTimingTracker = options.getSubtitleTimingTracker();
if (subtitleTimingTracker) {
subtitleTimingTracker.cleanup();
logger.info("AnkiConnect subtitle timing history cleared");
logger.info('AnkiConnect subtitle timing history cleared');
}
},
refreshKnownWords: async () => {
const integration = options.getAnkiIntegration();
if (!integration) {
throw new Error("AnkiConnect integration not enabled");
throw new Error('AnkiConnect integration not enabled');
}
await integration.refreshKnownWordCache();
},
@@ -149,7 +136,7 @@ export function registerAnkiJimakuIpcRuntime(
buildKikuMergePreview: async (request) => {
const integration = options.getAnkiIntegration();
if (!integration) {
return { ok: false, error: "AnkiConnect integration not enabled" };
return { ok: false, error: 'AnkiConnect integration not enabled' };
}
return integration.buildFieldGroupingPreview(
request.keepNoteId,
@@ -157,17 +144,13 @@ export function registerAnkiJimakuIpcRuntime(
request.deleteDuplicate,
);
},
getJimakuMediaInfo: () =>
options.parseMediaInfo(options.getCurrentMediaPath()),
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,
},
);
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(
@@ -176,9 +159,7 @@ export function registerAnkiJimakuIpcRuntime(
return { ok: true, data: response.data.slice(0, maxResults) };
},
listJimakuFiles: async (query) => {
logger.info(
`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`,
);
logger.info(`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? 'all'}`);
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
`/api/entries/${query.entryId}/files`,
{
@@ -186,22 +167,18 @@ export function registerAnkiJimakuIpcRuntime(
},
);
if (!response.ok) return response;
const sorted = sortJimakuFiles(
response.data,
options.getJimakuLanguagePreference(),
);
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),
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"] });
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
}
},
});

View File

@@ -1,16 +1,14 @@
import { CliArgs, CliCommandSource } from "../../cli/args";
import { createLogger } from "../../logger";
import { CliArgs, CliCommandSource } from '../../cli/args';
import { createLogger } from '../../logger';
const logger = createLogger("main:app-lifecycle");
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;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
@@ -55,10 +53,7 @@ export function createAppLifecycleDepsRuntime(
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
onSecondInstance: (handler) => {
options.app.on(
"second-instance",
handler as (...args: unknown[]) => void,
);
options.app.on('second-instance', handler as (...args: unknown[]) => void);
},
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
@@ -68,22 +63,19 @@ export function createAppLifecycleDepsRuntime(
.whenReady()
.then(handler)
.catch((error) => {
logger.error("App ready handler failed:", error);
logger.error('App ready handler failed:', error);
});
},
onWindowAllClosed: (handler) => {
options.app.on(
"window-all-closed",
handler as (...args: unknown[]) => void,
);
options.app.on('window-all-closed', handler as (...args: unknown[]) => void);
},
onWillQuit: (handler) => {
options.app.on("will-quit", handler as (...args: unknown[]) => void);
options.app.on('will-quit', handler as (...args: unknown[]) => void);
},
onActivate: (handler) => {
options.app.on("activate", handler as (...args: unknown[]) => void);
options.app.on('activate', handler as (...args: unknown[]) => void);
},
isDarwinPlatform: () => options.platform === "darwin",
isDarwinPlatform: () => options.platform === 'darwin',
onReady: options.onReady,
onWillQuitCleanup: options.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate,
@@ -91,10 +83,7 @@ export function createAppLifecycleDepsRuntime(
};
}
export function startAppLifecycle(
initialArgs: CliArgs,
deps: AppLifecycleServiceDeps,
): void {
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
const gotTheLock = deps.requestSingleInstanceLock();
if (!gotTheLock) {
deps.quitApp();
@@ -103,9 +92,9 @@ export function startAppLifecycle(
deps.onSecondInstance((_event, argv) => {
try {
deps.handleCliCommand(deps.parseArgs(argv), "second-instance");
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
} catch (error) {
logger.error("Failed to handle second-instance CLI command:", error);
logger.error('Failed to handle second-instance CLI command:', error);
}
});

View File

@@ -1,135 +1,118 @@
import test from "node:test";
import assert from "node:assert/strict";
import { AppReadyRuntimeDeps, runAppReadyRuntime } from "./startup";
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"),
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({
websocket: { enabled: "auto" },
websocket: { enabled: 'auto' },
secondarySub: {},
}),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push("logConfigWarning"),
setLogLevel: (level, source) =>
calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"),
logConfigWarning: () => calls.push('logConfigWarning'),
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: "hover",
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 9001,
hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: (port) =>
calls.push(`startSubtitleWebsocket:${port}`),
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`),
log: (message) => calls.push(`log:${message}`),
createMecabTokenizerAndCheck: async () => {
calls.push("createMecabTokenizerAndCheck");
calls.push('createMecabTokenizerAndCheck');
},
createSubtitleTimingTracker: () =>
calls.push("createSubtitleTimingTracker"),
createImmersionTracker: () => calls.push("createImmersionTracker"),
createSubtitleTimingTracker: () => calls.push('createSubtitleTimingTracker'),
createImmersionTracker: () => calls.push('createImmersionTracker'),
startJellyfinRemoteSession: async () => {
calls.push("startJellyfinRemoteSession");
calls.push('startJellyfinRemoteSession');
},
loadYomitanExtension: async () => {
calls.push("loadYomitanExtension");
calls.push('loadYomitanExtension');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push("initializeOverlayRuntime"),
handleInitialArgs: () => calls.push("handleInitialArgs"),
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
...overrides,
};
return { deps, calls };
}
test("runAppReadyRuntime starts websocket in auto mode when plugin missing", async () => {
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("startJellyfinRemoteSession"));
assert.ok(
calls.includes("log:Runtime ready: invoking createImmersionTracker."),
);
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startJellyfinRemoteSession'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
});
test("runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired", async () => {
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("createMecabTokenizerAndCheck"));
assert.ok(calls.includes("createMpvClient"));
assert.ok(calls.includes("createSubtitleTimingTracker"));
assert.ok(calls.includes("handleInitialArgs"));
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
assert.ok(calls.includes('createMecabTokenizerAndCheck'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(
calls.includes("initializeOverlayRuntime") ||
calls.includes(
"log:Overlay runtime deferred: waiting for explicit overlay command.",
),
calls.includes('initializeOverlayRuntime') ||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
);
});
test("runAppReadyRuntime logs when createImmersionTracker dependency is missing", async () => {
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.",
),
);
assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.'));
});
test("runAppReadyRuntime logs and continues when createImmersionTracker throws", async () => {
test('runAppReadyRuntime logs and continues when createImmersionTracker throws', async () => {
const { deps, calls } = makeDeps({
createImmersionTracker: () => {
calls.push("createImmersionTracker");
throw new Error("immersion init failed");
calls.push('createImmersionTracker');
throw new Error('immersion init failed');
},
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes("createImmersionTracker"));
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(
calls.includes(
"log:Runtime ready: createImmersionTracker failed: immersion init failed",
),
calls.includes('log:Runtime ready: createImmersionTracker failed: immersion init failed'),
);
assert.ok(calls.includes("initializeOverlayRuntime"));
assert.ok(calls.includes("handleInitialArgs"));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('handleInitialArgs'));
});
test("runAppReadyRuntime logs defer message when overlay not auto-started", async () => {
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.",
),
);
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
});
test("runAppReadyRuntime applies config logging level during app-ready", async () => {
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: "auto" },
websocket: { enabled: 'auto' },
secondarySub: {},
logging: { level: "warn" },
logging: { level: 'warn' },
}),
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes("setLogLevel:warn:config"));
assert.ok(calls.includes('setLogLevel:warn:config'));
});

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CliArgs } from "../../cli/args";
import { CliCommandServiceDeps, handleCliCommand } from "./cli-command";
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 {
@@ -53,7 +53,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
const calls: string[] = [];
let mpvSocketPath = "/tmp/subminer.sock";
let mpvSocketPath = '/tmp/subminer.sock';
let texthookerPort = 5174;
const osd: string[] = [];
@@ -68,7 +68,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
},
hasMpvClient: () => true,
connectMpvClient: () => {
calls.push("connectMpvClient");
calls.push('connectMpvClient');
},
isTexthookerRunning: () => false,
setTexthookerPort: (port) => {
@@ -84,17 +84,17 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push(`openTexthookerInBrowser:${url}`);
},
stopApp: () => {
calls.push("stopApp");
calls.push('stopApp');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push("initializeOverlayRuntime");
calls.push('initializeOverlayRuntime');
},
toggleVisibleOverlay: () => {
calls.push("toggleVisibleOverlay");
calls.push('toggleVisibleOverlay');
},
toggleInvisibleOverlay: () => {
calls.push("toggleInvisibleOverlay");
calls.push('toggleInvisibleOverlay');
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
@@ -106,41 +106,41 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push(`setInvisibleOverlayVisible:${visible}`);
},
copyCurrentSubtitle: () => {
calls.push("copyCurrentSubtitle");
calls.push('copyCurrentSubtitle');
},
startPendingMultiCopy: (timeoutMs) => {
calls.push(`startPendingMultiCopy:${timeoutMs}`);
},
mineSentenceCard: async () => {
calls.push("mineSentenceCard");
calls.push('mineSentenceCard');
},
startPendingMineSentenceMultiple: (timeoutMs) => {
calls.push(`startPendingMineSentenceMultiple:${timeoutMs}`);
},
updateLastCardFromClipboard: async () => {
calls.push("updateLastCardFromClipboard");
calls.push('updateLastCardFromClipboard');
},
refreshKnownWords: async () => {
calls.push("refreshKnownWords");
calls.push('refreshKnownWords');
},
cycleSecondarySubMode: () => {
calls.push("cycleSecondarySubMode");
calls.push('cycleSecondarySubMode');
},
triggerFieldGrouping: async () => {
calls.push("triggerFieldGrouping");
calls.push('triggerFieldGrouping');
},
triggerSubsyncFromConfig: async () => {
calls.push("triggerSubsyncFromConfig");
calls.push('triggerSubsyncFromConfig');
},
markLastCardAsAudioCard: async () => {
calls.push("markLastCardAsAudioCard");
calls.push('markLastCardAsAudioCard');
},
openRuntimeOptionsPalette: () => {
calls.push("openRuntimeOptionsPalette");
calls.push('openRuntimeOptionsPalette');
},
getAnilistStatus: () => ({
tokenStatus: "resolved",
tokenSource: "stored",
tokenStatus: 'resolved',
tokenSource: 'stored',
tokenMessage: null,
tokenResolvedAt: 1,
tokenErrorAt: null,
@@ -151,13 +151,13 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
queueLastError: null,
}),
clearAnilistToken: () => {
calls.push("clearAnilistToken");
calls.push('clearAnilistToken');
},
openAnilistSetup: () => {
calls.push("openAnilistSetup");
calls.push('openAnilistSetup');
},
openJellyfinSetup: () => {
calls.push("openJellyfinSetup");
calls.push('openJellyfinSetup');
},
getAnilistQueueStatus: () => ({
pending: 2,
@@ -167,14 +167,14 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
lastError: null,
}),
retryAnilistQueue: async () => {
calls.push("retryAnilistQueue");
return { ok: true, message: "AniList retry processed." };
calls.push('retryAnilistQueue');
return { ok: true, message: 'AniList retry processed.' };
},
runJellyfinCommand: async () => {
calls.push("runJellyfinCommand");
calls.push('runJellyfinCommand');
},
printHelp: () => {
calls.push("printHelp");
calls.push('printHelp');
},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 2500,
@@ -196,183 +196,161 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
return { deps, calls, osd };
}
test("handleCliCommand ignores --start for second-instance without actions", () => {
test('handleCliCommand ignores --start for second-instance without actions', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ start: true });
handleCliCommand(args, "second-instance", deps);
handleCliCommand(args, 'second-instance', deps);
assert.ok(
calls.includes("log:Ignoring --start because SubMiner is already running."),
);
assert.ok(calls.includes('log:Ignoring --start because SubMiner is already running.'));
assert.equal(
calls.some((value) => value.includes("connectMpvClient")),
calls.some((value) => value.includes('connectMpvClient')),
false,
);
});
test("handleCliCommand runs texthooker flow with browser open", () => {
test('handleCliCommand runs texthooker flow with browser open', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ texthooker: true });
handleCliCommand(args, "initial", deps);
handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes("ensureTexthookerRunning:5174"));
assert.ok(calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"));
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 () => {
test('handleCliCommand reports async mine errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
mineSentenceCard: async () => {
throw new Error("boom");
throw new Error('boom');
},
});
handleCliCommand(makeArgs({ mineSentence: true }), "initial", deps);
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")));
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", () => {
test('handleCliCommand applies socket path and connects on start', () => {
const { deps, calls } = createDeps();
handleCliCommand(
makeArgs({ start: true, socketPath: "/tmp/custom.sock" }),
"initial",
deps,
);
handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);
assert.ok(calls.includes("setMpvSocketPath:/tmp/custom.sock"));
assert.ok(calls.includes("setMpvClientSocketPath:/tmp/custom.sock"));
assert.ok(calls.includes("connectMpvClient"));
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", () => {
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
});
handleCliCommand(
makeArgs({ texthookerPort: 9999, texthooker: true }),
"initial",
deps,
);
handleCliCommand(makeArgs({ texthookerPort: 9999, texthooker: true }), 'initial', deps);
assert.ok(
calls.includes(
"warn:Ignoring --port override because the texthooker server is already running.",
'warn:Ignoring --port override because the texthooker server is already running.',
),
);
assert.equal(
calls.some((value) => value === "setTexthookerPort:9999"),
calls.some((value) => value === 'setTexthookerPort:9999'),
false,
);
});
test("handleCliCommand prints help and stops app when no window exists", () => {
test('handleCliCommand prints help and stops app when no window exists', () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(makeArgs({ help: true }), "initial", deps);
handleCliCommand(makeArgs({ help: true }), 'initial', deps);
assert.ok(calls.includes("printHelp"));
assert.ok(calls.includes("stopApp"));
assert.ok(calls.includes('printHelp'));
assert.ok(calls.includes('stopApp'));
});
test("handleCliCommand reports async trigger-subsync errors to OSD", async () => {
test('handleCliCommand reports async trigger-subsync errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
triggerSubsyncFromConfig: async () => {
throw new Error("subsync boom");
throw new Error('subsync boom');
},
});
handleCliCommand(makeArgs({ triggerSubsync: true }), "initial", deps);
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")),
);
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", () => {
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"));
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", () => {
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"));
handleCliCommand(makeArgs({ start: true, toggleVisibleOverlay: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === "connectMpvClient"),
calls.some((value) => value === 'connectMpvClient'),
true,
);
});
test("handleCliCommand handles visibility and utility command dispatches", () => {
test('handleCliCommand handles visibility and utility command dispatches', () => {
const cases: Array<{
args: Partial<CliArgs>;
expected: string;
}> = [
{
args: { toggleInvisibleOverlay: true },
expected: "toggleInvisibleOverlay",
expected: 'toggleInvisibleOverlay',
},
{ args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" },
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{
args: { showVisibleOverlay: true },
expected: "setVisibleOverlayVisible:true",
expected: 'setVisibleOverlayVisible:true',
},
{
args: { hideVisibleOverlay: true },
expected: "setVisibleOverlayVisible:false",
expected: 'setVisibleOverlayVisible:false',
},
{
args: { showInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:true",
expected: 'setInvisibleOverlayVisible:true',
},
{
args: { hideInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:false",
expected: 'setInvisibleOverlayVisible:false',
},
{ args: { copySubtitle: true }, expected: "copyCurrentSubtitle" },
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
{
args: { copySubtitleMultiple: true },
expected: "startPendingMultiCopy:2500",
expected: 'startPendingMultiCopy:2500',
},
{
args: { mineSentenceMultiple: true },
expected: "startPendingMineSentenceMultiple:2500",
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{
args: { openRuntimeOptions: true },
expected: "openRuntimeOptionsPalette",
expected: 'openRuntimeOptionsPalette',
},
{ args: { anilistLogout: true }, expected: "clearAnilistToken" },
{ args: { anilistSetup: true }, expected: "openAnilistSetup" },
{ args: { jellyfin: true }, expected: "openJellyfinSetup" },
{ 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);
handleCliCommand(makeArgs(entry.args), 'initial', deps);
assert.ok(
calls.includes(entry.expected),
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
@@ -380,24 +358,22 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
}
});
test("handleCliCommand logs AniList status details", () => {
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:")));
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 () => {
test('handleCliCommand runs AniList retry command', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistRetryQueue: true }), "initial", deps);
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."));
assert.ok(calls.includes('retryAnilistQueue'));
assert.ok(calls.includes('log:AniList retry processed.'));
});
test("handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands", () => {
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },
{ copySubtitle: true },
@@ -406,10 +382,8 @@ test("handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin com
for (const args of nonJellyfinArgs) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(args), "initial", deps);
const runJellyfinCallCount = calls.filter(
(value) => value === "runJellyfinCommand",
).length;
handleCliCommand(makeArgs(args), 'initial', deps);
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
assert.equal(
runJellyfinCallCount,
0,
@@ -418,59 +392,47 @@ test("handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin com
}
});
test("handleCliCommand runs jellyfin command dispatcher", async () => {
test('handleCliCommand runs jellyfin command dispatcher', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), "initial", deps);
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;
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
assert.equal(runJellyfinCallCount, 2);
});
test("handleCliCommand reports jellyfin command errors to OSD", async () => {
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
runJellyfinCommand: async () => {
throw new Error("server offline");
throw new Error('server offline');
},
});
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
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")),
);
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", () => {
test('handleCliCommand runs refresh-known-words command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ refreshKnownWords: true }), "initial", deps);
handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
assert.ok(calls.includes("refreshKnownWords"));
assert.ok(calls.includes('refreshKnownWords'));
});
test("handleCliCommand reports async refresh-known-words errors to OSD", async () => {
test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
refreshKnownWords: async () => {
throw new Error("refresh boom");
throw new Error('refresh boom');
},
});
handleCliCommand(makeArgs({ refreshKnownWords: true }), "initial", deps);
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"),
),
);
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

@@ -1,8 +1,4 @@
import {
CliArgs,
CliCommandSource,
commandNeedsOverlayRuntime,
} from "../../cli/args";
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
export interface CliCommandServiceDeps {
getMpvSocketPath: () => string;
@@ -36,8 +32,8 @@ export interface CliCommandServiceDeps {
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
getAnilistStatus: () => {
tokenStatus: "not_checked" | "resolved" | "error";
tokenSource: "none" | "literal" | "stored";
tokenStatus: 'not_checked' | 'resolved' | 'error';
tokenSource: 'none' | 'literal' | 'stored';
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
@@ -122,11 +118,11 @@ interface UiCliRuntime {
}
interface AnilistCliRuntime {
getStatus: CliCommandServiceDeps["getAnilistStatus"];
clearToken: CliCommandServiceDeps["clearAnilistToken"];
openSetup: CliCommandServiceDeps["openAnilistSetup"];
getQueueStatus: CliCommandServiceDeps["getAnilistQueueStatus"];
retryQueueNow: CliCommandServiceDeps["retryAnilistQueue"];
getStatus: CliCommandServiceDeps['getAnilistStatus'];
clearToken: CliCommandServiceDeps['clearAnilistToken'];
openSetup: CliCommandServiceDeps['openAnilistSetup'];
getQueueStatus: CliCommandServiceDeps['getAnilistQueueStatus'];
retryQueueNow: CliCommandServiceDeps['retryAnilistQueue'];
}
interface AppCliRuntime {
@@ -195,8 +191,7 @@ export function createCliCommandDepsRuntime(
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
mineSentenceCard: options.mining.mineSentenceCard,
startPendingMineSentenceMultiple:
options.mining.startPendingMineSentenceMultiple,
startPendingMineSentenceMultiple: options.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard,
refreshKnownWords: options.mining.refreshKnownWords,
cycleSecondarySubMode: options.ui.cycleSecondarySubMode,
@@ -222,7 +217,7 @@ export function createCliCommandDepsRuntime(
}
function formatTimestamp(value: number | null): string {
if (!value) return "never";
if (!value) return 'never';
return new Date(value).toISOString();
}
@@ -240,7 +235,7 @@ function runAsyncWithOsd(
export function handleCliCommand(
args: CliArgs,
source: CliCommandSource = "initial",
source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps,
): void {
const hasNonStartAction =
@@ -280,19 +275,16 @@ export function handleCliCommand(
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly =
source === "second-instance" && args.start && !hasNonStartAction;
const ignoreStartOnly = source === 'second-instance' && args.start && !hasNonStartAction;
if (ignoreStartOnly) {
deps.log("Ignoring --start because SubMiner is already running.");
deps.log('Ignoring --start because SubMiner is already running.');
return;
}
const shouldStart =
args.start ||
(source === "initial" &&
(args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay));
(source === 'initial' &&
(args.toggle || args.toggleVisibleOverlay || args.toggleInvisibleOverlay));
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
if (args.socketPath !== undefined) {
@@ -302,16 +294,14 @@ export function handleCliCommand(
if (args.texthookerPort !== undefined) {
if (deps.isTexthookerRunning()) {
deps.warn(
"Ignoring --port override because the texthooker server is already running.",
);
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.log('Stopping SubMiner...');
deps.stopApp();
return;
}
@@ -349,8 +339,8 @@ export function handleCliCommand(
runAsyncWithOsd(
() => deps.mineSentenceCard(),
deps,
"mineSentenceCard",
"Mine sentence failed",
'mineSentenceCard',
'Mine sentence failed',
);
} else if (args.mineSentenceMultiple) {
deps.startPendingMineSentenceMultiple(deps.getMultiCopyTimeoutMs());
@@ -358,15 +348,15 @@ export function handleCliCommand(
runAsyncWithOsd(
() => deps.updateLastCardFromClipboard(),
deps,
"updateLastCardFromClipboard",
"Update failed",
'updateLastCardFromClipboard',
'Update failed',
);
} else if (args.refreshKnownWords) {
runAsyncWithOsd(
() => deps.refreshKnownWords(),
deps,
"refreshKnownWords",
"Refresh known words failed",
'refreshKnownWords',
'Refresh known words failed',
);
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
@@ -374,30 +364,28 @@ export function handleCliCommand(
runAsyncWithOsd(
() => deps.triggerFieldGrouping(),
deps,
"triggerFieldGrouping",
"Field grouping failed",
'triggerFieldGrouping',
'Field grouping failed',
);
} else if (args.triggerSubsync) {
runAsyncWithOsd(
() => deps.triggerSubsyncFromConfig(),
deps,
"triggerSubsyncFromConfig",
"Subsync failed",
'triggerSubsyncFromConfig',
'Subsync failed',
);
} else if (args.markAudioCard) {
runAsyncWithOsd(
() => deps.markLastCardAsAudioCard(),
deps,
"markLastCardAsAudioCard",
"Audio card failed",
'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})`,
);
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);
if (status.tokenMessage) {
deps.log(`AniList token message: ${status.tokenMessage}`);
}
@@ -407,21 +395,19 @@ export function handleCliCommand(
deps.log(
`AniList queue: pending=${status.queuePending}, ready=${status.queueReady}, deadLetter=${status.queueDeadLetter}`,
);
deps.log(
`AniList queue timestamps: lastAttempt=${formatTimestamp(status.queueLastAttemptAt)}`,
);
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.");
deps.log('Cleared stored AniList token.');
} else if (args.anilistSetup) {
deps.openAnilistSetup();
deps.log("Opened AniList setup flow.");
deps.log('Opened AniList setup flow.');
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log("Opened Jellyfin setup flow.");
deps.log('Opened Jellyfin setup flow.');
} else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus();
deps.log(
@@ -434,8 +420,8 @@ export function handleCliCommand(
else deps.warn(result.message);
},
deps,
"retryAnilistQueue",
"AniList retry failed",
'retryAnilistQueue',
'AniList retry failed',
);
} else if (
args.jellyfinLogin ||
@@ -449,8 +435,8 @@ export function handleCliCommand(
runAsyncWithOsd(
() => deps.runJellyfinCommand(args),
deps,
"runJellyfinCommand",
"Jellyfin command failed",
'runJellyfinCommand',
'Jellyfin command failed',
);
} else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort();

View File

@@ -1,16 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { KikuFieldGroupingChoice } from "../../types";
import { createFieldGroupingOverlayRuntime } from "./field-grouping-overlay";
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", () => {
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
const sent: unknown[][] = [];
let visible = false;
const restore = new Set<"runtime-options" | "subsync">();
const restore = new Set<'runtime-options' | 'subsync'>();
const runtime = createFieldGroupingOverlayRuntime<
"runtime-options" | "subsync"
>({
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: {
@@ -31,21 +29,19 @@ test("createFieldGroupingOverlayRuntime sends overlay messages and sets restore
getRestoreVisibleOverlayOnModalClose: () => restore,
});
const ok = runtime.sendToVisibleOverlay("runtime-options:open", undefined, {
restoreOnModalClose: "runtime-options",
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"]]);
assert.equal(restore.has('runtime-options'), true);
assert.deepEqual(sent, [['runtime-options:open']]);
});
test("createFieldGroupingOverlayRuntime callback cancels when send fails", async () => {
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const runtime = createFieldGroupingOverlayRuntime<
"runtime-options" | "subsync"
>({
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
@@ -55,24 +51,23 @@ test("createFieldGroupingOverlayRuntime callback cancels when send fails", async
setResolver: (next) => {
resolver = next;
},
getRestoreVisibleOverlayOnModalClose: () =>
new Set<"runtime-options" | "subsync">(),
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
});
const callback = runtime.createFieldGroupingCallback();
const result = await callback({
original: {
noteId: 1,
expression: "a",
sentencePreview: "a",
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: "b",
sentencePreview: "b",
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,

View File

@@ -1,11 +1,5 @@
import {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
import {
createFieldGroupingCallbackRuntime,
sendToVisibleOverlayRuntime,
} from "./overlay-bridge";
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
interface WindowLike {
isDestroyed: () => boolean;
@@ -21,9 +15,7 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
sendToVisibleOverlay?: (
channel: string,
@@ -59,8 +51,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
channel,
payload,
restoreOnModalClose: runtimeOptions?.restoreOnModalClose,
restoreVisibleOverlayOnModalClose:
options.getRestoreVisibleOverlayOnModalClose(),
restoreVisibleOverlayOnModalClose: options.getRestoreVisibleOverlayOnModalClose(),
});
};

View File

@@ -1,7 +1,4 @@
import {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
@@ -9,14 +6,10 @@ export function createFieldGroupingCallback(options: {
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async (
data: KikuFieldGroupingRequestData,
): Promise<KikuFieldGroupingChoice> => {
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
if (options.getResolver()) {
resolve({

View File

@@ -1,18 +1,16 @@
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 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";
import { createFrequencyDictionaryLookup } from './frequency-dictionary';
test("createFrequencyDictionaryLookup logs parse errors and returns no-op for invalid dictionaries", async () => {
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 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],
@@ -21,25 +19,22 @@ test("createFrequencyDictionaryLookup logs parse errors and returns no-op for in
},
});
const rank = lookup("猫");
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"),
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 () => {
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 missingPath = path.join(os.tmpdir(), 'subminer-frequency-dict-missing-dir');
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [missingPath],
log: (message) => {
@@ -47,7 +42,7 @@ test("createFrequencyDictionaryLookup continues with no-op lookup when search pa
},
});
assert.equal(lookup("猫"), null);
assert.equal(lookup('猫'), null);
assert.equal(
logs.some((entry) => entry.includes(`Frequency dictionary not found.`)),
true,

View File

@@ -1,5 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface FrequencyDictionaryLookupOptions {
searchPaths: string[];
@@ -19,16 +19,16 @@ function normalizeFrequencyTerm(value: string): string {
}
function extractFrequencyDisplayValue(meta: unknown): number | null {
if (!meta || typeof meta !== "object") return null;
if (!meta || typeof meta !== 'object') return null;
const frequency = (meta as { frequency?: unknown }).frequency;
if (!frequency || typeof frequency !== "object") return null;
if (!frequency || typeof frequency !== 'object') return null;
const displayValue = (frequency as { displayValue?: unknown }).displayValue;
if (typeof displayValue === "number") {
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, "");
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;
@@ -37,15 +37,13 @@ function extractFrequencyDisplayValue(meta: unknown): number | null {
return null;
}
function asFrequencyDictionaryEntry(
entry: unknown,
): FrequencyDictionaryEntry | 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") {
if (typeof term !== 'string') {
return null;
}
@@ -97,15 +95,11 @@ function collectDictionaryFromPath(
try {
fileNames = fs.readdirSync(dictionaryPath);
} catch (error) {
log(
`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(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();
const bankFiles = fileNames.filter((name) => FREQUENCY_BANK_FILE_GLOB.test(name)).sort();
if (bankFiles.length === 0) {
return terms;
@@ -115,7 +109,7 @@ function collectDictionaryFromPath(
const bankPath = path.join(dictionaryPath, bankFile);
let rawText: string;
try {
rawText = fs.readFileSync(bankPath, "utf-8");
rawText = fs.readFileSync(bankPath, 'utf-8');
} catch {
log(`Failed to read frequency dictionary file ${bankPath}`);
continue;
@@ -132,9 +126,7 @@ function collectDictionaryFromPath(
const beforeSize = terms.size;
addEntriesToMap(rawEntries, terms, log);
if (terms.size === beforeSize) {
log(
`Frequency dictionary file contained no extractable entries: ${bankPath}`,
);
log(`Frequency dictionary file contained no extractable entries: ${bankPath}`);
}
}
@@ -170,9 +162,7 @@ export async function createFrequencyDictionaryLookup(
foundDictionaryPathCount += 1;
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
if (terms.size > 0) {
options.log(
`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`,
);
options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
return (term: string): number | null => {
const normalized = normalizeFrequencyTerm(term);
if (!normalized) return null;
@@ -186,11 +176,11 @@ export async function createFrequencyDictionaryLookup(
}
options.log(
`Frequency dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(", ")}`,
`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.",
'Frequency dictionary directories found, but no usable term_meta_bank_*.json files were loaded.',
);
}

View File

@@ -1,19 +1,18 @@
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 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';
type ImmersionTrackerService = import("./immersion-tracker-service").ImmersionTrackerService;
type ImmersionTrackerServiceCtor = typeof import("./immersion-tracker-service").ImmersionTrackerService;
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;
return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null;
} catch {
return null;
}
@@ -24,16 +23,14 @@ let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
if (trackerCtor) return trackerCtor;
const mod = await import("./immersion-tracker-service");
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");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-immersion-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
@@ -43,14 +40,14 @@ function cleanupDbPath(dbPath: string): void {
}
}
testIfSqlite("startSession generates UUID-like session identifiers", async () => {
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");
tracker.handleMediaChange('/tmp/episode.mkv', 'Episode');
const privateApi = tracker as unknown as {
flushTelemetry: (force?: boolean) => void;
@@ -60,21 +57,21 @@ testIfSqlite("startSession generates UUID-like session identifiers", async () =>
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;
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 || ""));
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 () => {
testIfSqlite('destroy finalizes active session and persists final telemetry', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -82,16 +79,16 @@ testIfSqlite("destroy finalizes active session and persists final telemetry", as
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2");
tracker.recordSubtitleLine("Hello immersion", 0, 1);
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 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")
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
.get() as { total: number };
db.close();
@@ -104,7 +101,7 @@ testIfSqlite("destroy finalizes active session and persists final telemetry", as
}
});
testIfSqlite("persists and retrieves minimum immersion tracking fields", async () => {
testIfSqlite('persists and retrieves minimum immersion tracking fields', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -112,8 +109,8 @@ testIfSqlite("persists and retrieves minimum immersion tracking fields", async (
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode-3.mkv", "Episode 3");
tracker.recordSubtitleLine("alpha beta", 0, 1.2);
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);
@@ -134,9 +131,7 @@ testIfSqlite("persists and retrieves minimum immersion tracking fields", async (
const db = new DatabaseSync!(dbPath);
const videoRow = db
.prepare(
"SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1",
)
.prepare('SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1')
.get() as {
canonical_title: string;
source_path: string | null;
@@ -158,8 +153,8 @@ testIfSqlite("persists and retrieves minimum immersion tracking fields", async (
db.close();
assert.ok(videoRow);
assert.equal(videoRow?.canonical_title, "Episode 3");
assert.equal(videoRow?.source_path, "/tmp/episode-3.mkv");
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);
@@ -173,7 +168,7 @@ testIfSqlite("persists and retrieves minimum immersion tracking fields", async (
}
});
testIfSqlite("applies configurable queue, flush, and retention policy", async () => {
testIfSqlite('applies configurable queue, flush, and retention policy', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -226,7 +221,7 @@ testIfSqlite("applies configurable queue, flush, and retention policy", async ()
}
});
testIfSqlite("monthly rollups are grouped by calendar month", async () => {
testIfSqlite('monthly rollups are grouped by calendar month', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -389,10 +384,10 @@ testIfSqlite("monthly rollups are grouped by calendar month", async () => {
}
});
testIfSqlite("flushSingle reuses cached prepared statements", async () => {
testIfSqlite('flushSingle reuses cached prepared statements', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let originalPrepare: NodeDatabaseSync["prepare"] | null = null;
let originalPrepare: NodeDatabaseSync['prepare'] | null = null;
try {
const Ctor = await loadTrackerCtor();
@@ -400,7 +395,7 @@ testIfSqlite("flushSingle reuses cached prepared statements", async () => {
const privateApi = tracker as unknown as {
db: NodeDatabaseSync;
flushSingle: (write: {
kind: "telemetry" | "event";
kind: 'telemetry' | 'event';
sessionId: number;
sampleMs: number;
eventType?: number;
@@ -428,7 +423,7 @@ testIfSqlite("flushSingle reuses cached prepared statements", async () => {
originalPrepare = privateApi.db.prepare;
let prepareCalls = 0;
privateApi.db.prepare = (...args: Parameters<NodeDatabaseSync["prepare"]>) => {
privateApi.db.prepare = (...args: Parameters<NodeDatabaseSync['prepare']>) => {
prepareCalls += 1;
return originalPrepare!.apply(privateApi.db, args);
};
@@ -477,7 +472,7 @@ testIfSqlite("flushSingle reuses cached prepared statements", async () => {
`);
privateApi.flushSingle({
kind: "telemetry",
kind: 'telemetry',
sessionId: 1,
sampleMs: 1500,
totalWatchedMs: 1000,
@@ -496,7 +491,7 @@ testIfSqlite("flushSingle reuses cached prepared statements", async () => {
});
privateApi.flushSingle({
kind: "event",
kind: 'event',
sessionId: 1,
sampleMs: 1600,
eventType: 1,

View File

@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import path from "node:path";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import * as fs from "node:fs";
import { createLogger } from "../../logger";
import crypto from 'node:crypto';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
const SCHEMA_VERSION = 1;
const DEFAULT_QUEUE_CAP = 1_000;
@@ -82,7 +82,7 @@ interface SessionState extends TelemetryAccumulator {
}
interface QueuedWrite {
kind: "telemetry" | "event";
kind: 'telemetry' | 'event';
sessionId: number;
sampleMs?: number;
totalWatchedMs?: number;
@@ -163,7 +163,7 @@ export interface ImmersionSessionRollupRow {
}
export class ImmersionTrackerService {
private readonly logger = createLogger("main:immersion-tracker");
private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync;
private readonly queue: QueuedWrite[] = [];
private readonly queueCap: number;
@@ -186,11 +186,11 @@ export class ImmersionTrackerService {
private lastVacuumMs = 0;
private isDestroyed = false;
private sessionState: SessionState | null = null;
private currentVideoKey = "";
private currentMediaPathOrUrl = "";
private currentVideoKey = '';
private currentMediaPathOrUrl = '';
private lastQueueWriteAtMs = 0;
private readonly telemetryInsertStmt: ReturnType<DatabaseSync["prepare"]>;
private readonly eventInsertStmt: ReturnType<DatabaseSync["prepare"]>;
private readonly telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
private readonly eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
constructor(options: ImmersionTrackerOptions) {
this.dbPath = options.dbPath;
@@ -200,18 +200,8 @@ export class ImmersionTrackerService {
}
const policy = options.policy ?? {};
this.queueCap = this.resolveBoundedInt(
policy.queueCap,
DEFAULT_QUEUE_CAP,
100,
100_000,
);
this.batchSize = this.resolveBoundedInt(
policy.batchSize,
DEFAULT_BATCH_SIZE,
1,
10_000,
);
this.queueCap = this.resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000);
this.batchSize = this.resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000);
this.flushIntervalMs = this.resolveBoundedInt(
policy.flushIntervalMs,
DEFAULT_FLUSH_INTERVAL_MS,
@@ -232,36 +222,41 @@ export class ImmersionTrackerService {
);
const retention = policy.retention ?? {};
this.eventsRetentionMs = this.resolveBoundedInt(
retention.eventsDays,
Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.telemetryRetentionMs = this.resolveBoundedInt(
retention.telemetryDays,
Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.dailyRollupRetentionMs = this.resolveBoundedInt(
retention.dailyRollupsDays,
Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.monthlyRollupRetentionMs = this.resolveBoundedInt(
retention.monthlyRollupsDays,
Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.vacuumIntervalMs = this.resolveBoundedInt(
retention.vacuumIntervalDays,
Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.eventsRetentionMs =
this.resolveBoundedInt(
retention.eventsDays,
Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.telemetryRetentionMs =
this.resolveBoundedInt(
retention.telemetryDays,
Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.dailyRollupRetentionMs =
this.resolveBoundedInt(
retention.dailyRollupsDays,
Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.monthlyRollupRetentionMs =
this.resolveBoundedInt(
retention.monthlyRollupsDays,
Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.vacuumIntervalMs =
this.resolveBoundedInt(
retention.vacuumIntervalDays,
Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.lastMaintenanceMs = Date.now();
this.db = new DatabaseSync(this.dbPath);
@@ -327,10 +322,7 @@ export class ImmersionTrackerService {
return prepared.all(limit) as unknown as SessionSummaryQueryRow[];
}
async getSessionTimeline(
sessionId: number,
limit = 200,
): Promise<SessionTimelineRow[]> {
async getSessionTimeline(sessionId: number, limit = 200): Promise<SessionTimelineRow[]> {
const prepared = this.db.prepare(`
SELECT
sample_ms AS sampleMs,
@@ -352,11 +344,9 @@ export class ImmersionTrackerService {
totalSessions: number;
activeSessions: number;
}> {
const sessions = this.db.prepare(
"SELECT COUNT(*) AS total FROM imm_sessions",
);
const sessions = this.db.prepare('SELECT COUNT(*) AS total FROM imm_sessions');
const active = this.db.prepare(
"SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL",
'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);
@@ -409,15 +399,15 @@ export class ImmersionTrackerService {
const normalizedPath = this.normalizeMediaPath(mediaPath);
const normalizedTitle = this.normalizeText(mediaTitle);
this.logger.info(
`handleMediaChange called with path=${normalizedPath || "<empty>"} title=${normalizedTitle || "<empty>"}`,
`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");
this.logger.debug('Media title updated for existing session');
} else {
this.logger.debug("Media change ignored; path unchanged");
this.logger.debug('Media change ignored; path unchanged');
}
return;
}
@@ -425,16 +415,13 @@ export class ImmersionTrackerService {
this.currentMediaPathOrUrl = normalizedPath;
this.currentVideoKey = normalizedTitle;
if (!normalizedPath) {
this.logger.info("Media path cleared; immersion session tracking paused");
this.logger.info('Media path cleared; immersion session tracking paused');
return;
}
const sourceType = this.isRemoteSource(normalizedPath)
? SOURCE_TYPE_REMOTE
: SOURCE_TYPE_LOCAL;
const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL;
const videoKey = this.buildVideoKey(normalizedPath, sourceType);
const canonicalTitle =
normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
const canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null;
const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null;
@@ -452,11 +439,7 @@ export class ImmersionTrackerService {
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
);
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
this.captureVideoMetadataAsync(
sessionInfo.videoId,
sourceType,
normalizedPath,
);
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
}
handleMediaTitleUpdate(mediaTitle: string | null): void {
@@ -480,7 +463,7 @@ export class ImmersionTrackerService {
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
lineIndex: this.sessionState.currentLineIndex,
@@ -490,7 +473,7 @@ export class ImmersionTrackerService {
cardsDelta: 0,
eventType: EVENT_SUBTITLE_LINE,
payloadJson: this.sanitizePayload({
event: "subtitle-line",
event: 'subtitle-line',
text: cleaned,
words: metrics.words,
}),
@@ -498,11 +481,7 @@ export class ImmersionTrackerService {
}
recordPlaybackPosition(mediaTimeSec: number | null): void {
if (
!this.sessionState ||
mediaTimeSec === null ||
!Number.isFinite(mediaTimeSec)
) {
if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) {
return;
}
const nowMs = Date.now();
@@ -528,7 +507,7 @@ export class ImmersionTrackerService {
this.sessionState.seekForwardCount += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_SEEK_FORWARD,
@@ -545,7 +524,7 @@ export class ImmersionTrackerService {
this.sessionState.seekBackwardCount += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_SEEK_BACKWARD,
@@ -577,7 +556,7 @@ export class ImmersionTrackerService {
this.sessionState.lastPauseStartMs = nowMs;
this.sessionState.pauseCount += 1;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_PAUSE_START,
@@ -592,7 +571,7 @@ export class ImmersionTrackerService {
this.sessionState.lastPauseStartMs = null;
}
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: nowMs,
eventType: EVENT_PAUSE_END,
@@ -613,7 +592,7 @@ export class ImmersionTrackerService {
}
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_LOOKUP,
@@ -630,7 +609,7 @@ export class ImmersionTrackerService {
this.sessionState.cardsMined += count;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_CARD_MINED,
@@ -645,7 +624,7 @@ export class ImmersionTrackerService {
this.sessionState.mediaBufferEvents += 1;
this.sessionState.pendingTelemetry = true;
this.recordWrite({
kind: "event",
kind: 'event',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
eventType: EVENT_MEDIA_BUFFER,
@@ -663,13 +642,11 @@ export class ImmersionTrackerService {
const overflow = this.queue.length - this.queueCap + 1;
this.queue.splice(0, overflow);
this.droppedWriteCount += overflow;
this.logger.warn(
`Immersion tracker queue overflow; dropped ${overflow} oldest writes`,
);
this.logger.warn(`Immersion tracker queue overflow; dropped ${overflow} oldest writes`);
}
this.queue.push(write);
this.lastQueueWriteAtMs = Date.now();
if (write.kind === "event" || this.queue.length >= this.batchSize) {
if (write.kind === 'event' || this.queue.length >= this.batchSize) {
this.scheduleFlush(0);
}
}
@@ -679,7 +656,7 @@ export class ImmersionTrackerService {
return;
}
this.recordWrite({
kind: "telemetry",
kind: 'telemetry',
sessionId: this.sessionState.sessionId,
sampleMs: Date.now(),
totalWatchedMs: this.sessionState.totalWatchedMs,
@@ -721,24 +698,18 @@ export class ImmersionTrackerService {
return;
}
const batch = this.queue.splice(
0,
Math.min(this.batchSize, this.queue.length),
);
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length));
this.writeLock.locked = true;
try {
this.db.exec("BEGIN IMMEDIATE");
this.db.exec('BEGIN IMMEDIATE');
for (const write of batch) {
this.flushSingle(write);
}
this.db.exec("COMMIT");
this.db.exec('COMMIT');
} catch (error) {
this.db.exec("ROLLBACK");
this.db.exec('ROLLBACK');
this.queue.unshift(...batch);
this.logger.warn(
"Immersion tracker flush failed, retrying later",
error as Error,
);
this.logger.warn('Immersion tracker flush failed, retrying later', error as Error);
} finally {
this.writeLock.locked = false;
this.flushScheduled = false;
@@ -749,7 +720,7 @@ export class ImmersionTrackerService {
}
private flushSingle(write: QueuedWrite): void {
if (write.kind === "telemetry") {
if (write.kind === 'telemetry') {
this.telemetryInsertStmt.run(
write.sessionId,
write.sampleMs!,
@@ -784,10 +755,10 @@ export class ImmersionTrackerService {
}
private applyPragmas(): void {
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA synchronous = NORMAL");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec("PRAGMA busy_timeout = 2500");
this.db.exec('PRAGMA journal_mode = WAL');
this.db.exec('PRAGMA synchronous = NORMAL');
this.db.exec('PRAGMA foreign_keys = ON');
this.db.exec('PRAGMA busy_timeout = 2500');
}
private ensureSchema(): void {
@@ -799,9 +770,7 @@ export class ImmersionTrackerService {
`);
const currentVersion = this.db
.prepare(
"SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1",
)
.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;
@@ -972,33 +941,23 @@ export class ImmersionTrackerService {
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
const monthCutoff = this.toMonthKey(monthlyCutoff);
this.db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
this.db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
this.db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
this.db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
this.db
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
.run(eventCutoff);
this.db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
.run(telemetryCutoff);
this.db
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
.run(dayCutoff);
this.db
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
.run(monthCutoff);
this.db
.prepare(
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`,
)
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(telemetryCutoff);
this.runRollupMaintenance();
if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
this.db.exec("VACUUM");
this.db.exec('VACUUM');
this.lastVacuumMs = nowMs;
}
this.lastMaintenanceMs = nowMs;
} catch (error) {
this.logger.warn(
"Immersion tracker maintenance failed, will retry later",
'Immersion tracker maintenance failed, will retry later',
(error as Error).message,
);
}
@@ -1096,7 +1055,7 @@ export class ImmersionTrackerService {
pendingTelemetry: true,
};
this.recordWrite({
kind: "telemetry",
kind: 'telemetry',
sessionId,
sampleMs: nowMs,
totalWatchedMs: 0,
@@ -1131,24 +1090,14 @@ export class ImmersionTrackerService {
) VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(
sessionUuid,
videoId,
startedAtMs,
SESSION_STATUS_ACTIVE,
startedAtMs,
startedAtMs,
);
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs);
}
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.pauseMs += Math.max(0, endedAt - this.sessionState.lastPauseStartMs);
this.sessionState.lastPauseStartMs = null;
}
const finalWallNow = endedAt;
@@ -1167,14 +1116,9 @@ export class ImmersionTrackerService {
this.db
.prepare(
"UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?",
'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?',
)
.run(
endedAt,
SESSION_STATUS_ENDED,
Date.now(),
this.sessionState.sessionId,
);
.run(endedAt, SESSION_STATUS_ENDED, Date.now(), this.sessionState.sessionId);
this.sessionState = null;
}
@@ -1188,18 +1132,12 @@ export class ImmersionTrackerService {
},
): number {
const existing = this.db
.prepare("SELECT video_id FROM imm_videos WHERE video_key = ?")
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
.get(videoKey) as { video_id: number } | null;
if (existing?.video_id) {
this.db
.prepare(
"UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?",
)
.run(
details.canonicalTitle || "unknown",
Date.now(),
existing.video_id,
);
.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;
}
@@ -1214,7 +1152,7 @@ export class ImmersionTrackerService {
`);
const result = insert.run(
videoKey,
details.canonicalTitle || "unknown",
details.canonicalTitle || 'unknown',
details.sourceType,
details.sourcePath,
details.sourceUrl,
@@ -1276,28 +1214,19 @@ export class ImmersionTrackerService {
);
}
private captureVideoMetadataAsync(
videoId: number,
sourceType: number,
mediaPath: string,
): void {
private captureVideoMetadataAsync(videoId: number, sourceType: number, mediaPath: string): void {
if (sourceType !== SOURCE_TYPE_LOCAL) return;
void (async () => {
try {
const metadata = await this.getLocalVideoMetadata(mediaPath);
this.updateVideoMetadata(videoId, metadata);
} catch (error) {
this.logger.warn(
"Unable to capture local video metadata",
(error as Error).message,
);
this.logger.warn('Unable to capture local video metadata', (error as Error).message);
}
})();
}
private async getLocalVideoMetadata(
mediaPath: string,
): Promise<VideoMetadata> {
private async getLocalVideoMetadata(mediaPath: string): Promise<VideoMetadata> {
const hash = await this.computeSha256(mediaPath);
const info = await this.runFfprobe(mediaPath);
const stat = await fs.promises.stat(mediaPath);
@@ -1322,10 +1251,10 @@ export class ImmersionTrackerService {
private async computeSha256(mediaPath: string): Promise<string | null> {
return new Promise((resolve) => {
const file = fs.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));
const digest = crypto.createHash('sha256');
file.on('data', (chunk) => digest.update(chunk));
file.on('end', () => resolve(digest.digest('hex')));
file.on('error', () => resolve(null));
});
}
@@ -1340,28 +1269,28 @@ export class ImmersionTrackerService {
audioCodecId: number | null;
}> {
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",
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");
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.stderr.on('data', (chunk) => {
errorOutput += chunk.toString('utf-8');
});
child.on("error", () => resolve(this.emptyMetadata()));
child.on("close", () => {
child.on('error', () => resolve(this.emptyMetadata()));
child.on('close', () => {
if (errorOutput && output.length === 0) {
resolve(this.emptyMetadata());
return;
@@ -1382,12 +1311,8 @@ export class ImmersionTrackerService {
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;
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;
@@ -1397,14 +1322,14 @@ export class ImmersionTrackerService {
let audioCodecId: number | null = null;
for (const stream of parsed.streams ?? []) {
if (stream.codec_type === "video") {
if (stream.codec_type === 'video') {
widthPx = this.toNullableInt(stream.width);
heightPx = this.toNullableInt(stream.height);
fpsX100 = this.parseFps(stream.avg_frame_rate);
codecId = this.hashToCode(stream.codec_tag_string);
containerId = 0;
}
if (stream.codec_type === "audio") {
if (stream.codec_type === 'audio') {
audioCodecId = this.hashToCode(stream.codec_tag_string);
if (audioCodecId && audioCodecId > 0) {
break;
@@ -1452,8 +1377,8 @@ export class ImmersionTrackerService {
}
private parseFps(value?: string): number | null {
if (!value || typeof value !== "string") return null;
const [num, den] = value.split("/");
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;
@@ -1472,9 +1397,7 @@ export class ImmersionTrackerService {
private sanitizePayload(payload: Record<string, unknown>): string {
const json = JSON.stringify(payload);
return json.length <= this.maxPayloadBytes
? json
: JSON.stringify({ truncated: true });
return json.length <= this.maxPayloadBytes ? json : JSON.stringify({ truncated: true });
}
private calculateTextMetrics(value: string): {
@@ -1494,13 +1417,13 @@ export class ImmersionTrackerService {
}
private normalizeMediaPath(mediaPath: string | null): string {
if (!mediaPath || !mediaPath.trim()) return "";
if (!mediaPath || !mediaPath.trim()) return '';
return mediaPath.trim();
}
private normalizeText(value: string | null | undefined): string {
if (!value) return "";
return value.trim().replace(/\s+/g, " ");
if (!value) return '';
return value.trim().replace(/\s+/g, ' ');
}
private buildVideoKey(mediaPath: string, sourceType: number): string {
@@ -1518,33 +1441,30 @@ export class ImmersionTrackerService {
if (this.isRemoteSource(mediaPath)) {
try {
const parsed = new URL(mediaPath);
const parts = parsed.pathname.split("/").filter(Boolean);
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length > 0) {
const leaf = decodeURIComponent(parts[parts.length - 1]);
return this.normalizeText(leaf.replace(/\.[^/.]+$/, ""));
return this.normalizeText(leaf.replace(/\.[^/.]+$/, ''));
}
return this.normalizeText(parsed.hostname) || "unknown";
return this.normalizeText(parsed.hostname) || 'unknown';
} catch {
return this.normalizeText(mediaPath);
}
}
const filename = path.basename(mediaPath);
return this.normalizeText(filename.replace(/\.[^/.]+$/, ""));
return this.normalizeText(filename.replace(/\.[^/.]+$/, ''));
}
private toNullableInt(value: number | null | undefined): number | null {
if (value === null || value === undefined || !Number.isFinite(value))
return null;
if (value === null || value === undefined || !Number.isFinite(value)) return null;
return value;
}
private updateVideoTitleForActiveSession(canonicalTitle: string): void {
if (!this.sessionState) return;
this.db
.prepare(
"UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?",
)
.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?')
.run(canonicalTitle, Date.now(), this.sessionState.videoId);
}
}

View File

@@ -1,16 +1,16 @@
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 { 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";
} from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export {
copyCurrentSubtitle,
handleMineSentenceDigit,
@@ -19,22 +19,19 @@ export {
mineSentenceCard,
triggerFieldGrouping,
updateLastCardFromClipboard,
} from "./mining";
export {
createAppLifecycleDepsRuntime,
startAppLifecycle,
} from "./app-lifecycle";
export { cycleSecondarySubMode } from "./subtitle-position";
} 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 { createFrequencyDictionaryLookup } from "./frequency-dictionary";
export { createJlptVocabularyLookup } from "./jlpt-vocab";
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
getIgnoredPos1Entries,
JlptIgnoredPos1Entry,
@@ -44,33 +41,33 @@ export {
JLPT_IGNORED_MECAB_POS1_LIST,
shouldIgnoreJlptByTerm,
shouldIgnoreJlptForMecabPos1,
} from "./jlpt-token-filter";
export { loadYomitanExtension } from "./yomitan-extension-loader";
} from './jlpt-token-filter';
export { loadYomitanExtension } from './yomitan-extension-loader';
export {
getJimakuLanguagePreference,
getJimakuMaxEntryResults,
jimakuFetchJson,
resolveJimakuApiKey,
} from "./jimaku";
} from './jimaku';
export {
loadSubtitlePosition,
saveSubtitlePosition,
updateCurrentMediaPath,
} from "./subtitle-position";
} from './subtitle-position';
export {
createOverlayWindow,
enforceOverlayLayerOrder,
ensureOverlayWindowLevel,
updateOverlayWindowBounds,
} from "./overlay-window";
export { initializeOverlayRuntime } from "./overlay-runtime-init";
} from './overlay-window';
export { initializeOverlayRuntime } from './overlay-runtime-init';
export {
setInvisibleOverlayVisible,
setVisibleOverlayVisible,
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from "./overlay-visibility";
} from './overlay-visibility';
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MpvIpcClient,
@@ -82,23 +79,20 @@ export {
sendMpvCommandRuntime,
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from "./mpv";
} from './mpv';
export {
applyMpvSubtitleRenderMetricsPatch,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
sanitizeMpvSubtitleRenderMetrics,
} from "./mpv-render-metrics";
export { createOverlayContentMeasurementStore } from "./overlay-content-measurement";
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";
} from './mpv-render-metrics';
export { createOverlayContentMeasurementStore } from './overlay-content-measurement';
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,
@@ -106,13 +100,10 @@ export {
listSubtitleTracks as listJellyfinSubtitleTracksRuntime,
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from "./jellyfin";
export {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
} from './jellyfin';
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} from "./overlay-manager";
} from './overlay-manager';

View File

@@ -4,7 +4,7 @@ import {
RuntimeOptionValue,
SubsyncManualRunRequest,
SubsyncResult,
} from "../../types";
} from '../../types';
export interface HandleMpvCommandFromIpcOptions {
specialCommands: {
@@ -16,10 +16,7 @@ export interface HandleMpvCommandFromIpcOptions {
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
runtimeOptionsCycle: (
id: RuntimeOptionId,
direction: 1 | -1,
) => RuntimeOptionApplyResult;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void;
@@ -32,7 +29,7 @@ export function handleMpvCommandFromIpc(
command: (string | number)[],
options: HandleMpvCommandFromIpcOptions,
): void {
const first = typeof command[0] === "string" ? command[0] : "";
const first = typeof command[0] === 'string' ? command[0] : '';
if (first === options.specialCommands.SUBSYNC_TRIGGER) {
options.triggerSubsyncFromConfig();
return;
@@ -45,9 +42,9 @@ export function handleMpvCommandFromIpc(
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(":");
const [, idToken, directionToken] = first.split(':');
const id = idToken as RuntimeOptionId;
const direction: 1 | -1 = directionToken === "prev" ? -1 : 1;
const direction: 1 | -1 = directionToken === 'prev' ? -1 : 1;
const result = options.runtimeOptionsCycle(id, direction);
if (!result.ok && result.error) {
options.showMpvOsd(result.error);
@@ -72,24 +69,18 @@ export async function runSubsyncManualFromIpc(
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
runWithSpinner: (
task: () => Promise<SubsyncResult>,
) => Promise<SubsyncResult>;
runSubsyncManual: (
request: SubsyncManualRunRequest,
) => Promise<SubsyncResult>;
runWithSpinner: (task: () => Promise<SubsyncResult>) => Promise<SubsyncResult>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
},
): Promise<SubsyncResult> {
if (options.isSubsyncInProgress()) {
const busy = "Subsync already running";
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),
);
const result = await options.runWithSpinner(() => options.runSubsyncManual(request));
options.showMpvOsd(result.message);
return result;
} catch (error) {

View File

@@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import { createIpcDepsRuntime } from "./ipc";
import { createIpcDepsRuntime } from './ipc';
test("createIpcDepsRuntime wires AniList handlers", async () => {
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
getInvisibleWindow: () => null,
@@ -15,7 +15,7 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleAss: () => "",
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
@@ -24,7 +24,7 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getSecondarySubMode: () => "hover",
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
runSubsyncManual: async () => ({}),
@@ -33,21 +33,21 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({ tokenStatus: "resolved" }),
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => {
calls.push("clearAnilistToken");
calls.push('clearAnilistToken');
},
openAnilistSetup: () => {
calls.push("openAnilistSetup");
calls.push('openAnilistSetup');
},
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
retryAnilistQueueNow: async () => {
calls.push("retryAnilistQueueNow");
return { ok: true, message: "done" };
calls.push('retryAnilistQueueNow');
return { ok: true, message: 'done' };
},
});
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: "resolved" });
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
assert.deepEqual(deps.getAnilistQueueStatus(), {
@@ -57,11 +57,7 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
});
assert.deepEqual(await deps.retryAnilistQueueNow(), {
ok: true,
message: "done",
message: 'done',
});
assert.deepEqual(calls, [
"clearAnilistToken",
"openAnilistSetup",
"retryAnilistQueueNow",
]);
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
});

View File

@@ -1,12 +1,9 @@
import { BrowserWindow, ipcMain, IpcMainEvent } from "electron";
import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
export interface IpcServiceDeps {
getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (
ignore: boolean,
options?: { forward?: boolean },
) => void;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
onOverlayModalClosed: (modal: string) => void;
openYomitanSettings: () => void;
quitApp: () => void;
@@ -48,10 +45,7 @@ export interface IpcServiceDeps {
interface WindowLike {
isDestroyed: () => boolean;
focus: () => void;
setIgnoreMouseEvents: (
ignore: boolean,
options?: { forward?: boolean },
) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
webContents: {
toggleDevTools: () => void;
};
@@ -105,9 +99,7 @@ export interface IpcDepsRuntimeOptions {
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
}
export function createIpcDepsRuntime(
options: IpcDepsRuntimeOptions,
): IpcServiceDeps {
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
return {
getInvisibleWindow: () => options.getInvisibleWindow(),
isVisibleOverlayVisible: options.getVisibleOverlayVisibility,
@@ -148,8 +140,7 @@ export function createIpcDepsRuntime(
getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () =>
options.getMpvClient()?.currentSecondarySubText || "",
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -171,12 +162,8 @@ export function createIpcDepsRuntime(
export function registerIpcHandlers(deps: IpcServiceDeps): void {
ipcMain.on(
"set-ignore-mouse-events",
(
event: IpcMainEvent,
ignore: boolean,
options: { forward?: boolean } = {},
) => {
'set-ignore-mouse-events',
(event: IpcMainEvent, ignore: boolean, options: { forward?: boolean } = {}) => {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
if (senderWindow && !senderWindow.isDestroyed()) {
const invisibleWindow = deps.getInvisibleWindow();
@@ -194,152 +181,137 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
},
);
ipcMain.on("overlay:modal-closed", (_event: IpcMainEvent, modal: string) => {
ipcMain.on('overlay:modal-closed', (_event: IpcMainEvent, modal: string) => {
deps.onOverlayModalClosed(modal);
});
ipcMain.on("open-yomitan-settings", () => {
ipcMain.on('open-yomitan-settings', () => {
deps.openYomitanSettings();
});
ipcMain.on("quit-app", () => {
ipcMain.on('quit-app', () => {
deps.quitApp();
});
ipcMain.on("toggle-dev-tools", () => {
ipcMain.on('toggle-dev-tools', () => {
deps.toggleDevTools();
});
ipcMain.handle("get-overlay-visibility", () => {
ipcMain.handle('get-overlay-visibility', () => {
return deps.getVisibleOverlayVisibility();
});
ipcMain.on("toggle-overlay", () => {
ipcMain.on('toggle-overlay', () => {
deps.toggleVisibleOverlay();
});
ipcMain.handle("get-visible-overlay-visibility", () => {
ipcMain.handle('get-visible-overlay-visibility', () => {
return deps.getVisibleOverlayVisibility();
});
ipcMain.handle("get-invisible-overlay-visibility", () => {
ipcMain.handle('get-invisible-overlay-visibility', () => {
return deps.getInvisibleOverlayVisibility();
});
ipcMain.handle("get-current-subtitle", async () => {
ipcMain.handle('get-current-subtitle', async () => {
return await deps.tokenizeCurrentSubtitle();
});
ipcMain.handle("get-current-subtitle-ass", () => {
ipcMain.handle('get-current-subtitle-ass', () => {
return deps.getCurrentSubtitleAss();
});
ipcMain.handle("get-mpv-subtitle-render-metrics", () => {
ipcMain.handle('get-mpv-subtitle-render-metrics', () => {
return deps.getMpvSubtitleRenderMetrics();
});
ipcMain.handle("get-subtitle-position", () => {
ipcMain.handle('get-subtitle-position', () => {
return deps.getSubtitlePosition();
});
ipcMain.handle("get-subtitle-style", () => {
ipcMain.handle('get-subtitle-style', () => {
return deps.getSubtitleStyle();
});
ipcMain.on(
"save-subtitle-position",
(_event: IpcMainEvent, position: unknown) => {
deps.saveSubtitlePosition(position);
},
);
ipcMain.on('save-subtitle-position', (_event: IpcMainEvent, position: unknown) => {
deps.saveSubtitlePosition(position);
});
ipcMain.handle("get-mecab-status", () => {
ipcMain.handle('get-mecab-status', () => {
return deps.getMecabStatus();
});
ipcMain.on("set-mecab-enabled", (_event: IpcMainEvent, enabled: boolean) => {
ipcMain.on('set-mecab-enabled', (_event: IpcMainEvent, enabled: boolean) => {
deps.setMecabEnabled(enabled);
});
ipcMain.on(
"mpv-command",
(_event: IpcMainEvent, command: (string | number)[]) => {
deps.handleMpvCommand(command);
},
);
ipcMain.on('mpv-command', (_event: IpcMainEvent, command: (string | number)[]) => {
deps.handleMpvCommand(command);
});
ipcMain.handle("get-keybindings", () => {
ipcMain.handle('get-keybindings', () => {
return deps.getKeybindings();
});
ipcMain.handle("get-config-shortcuts", () => {
ipcMain.handle('get-config-shortcuts', () => {
return deps.getConfiguredShortcuts();
});
ipcMain.handle("get-secondary-sub-mode", () => {
ipcMain.handle('get-secondary-sub-mode', () => {
return deps.getSecondarySubMode();
});
ipcMain.handle("get-current-secondary-sub", () => {
ipcMain.handle('get-current-secondary-sub', () => {
return deps.getCurrentSecondarySub();
});
ipcMain.handle("focus-main-window", () => {
ipcMain.handle('focus-main-window', () => {
deps.focusMainWindow();
});
ipcMain.handle("subsync:run-manual", async (_event, request: unknown) => {
ipcMain.handle('subsync:run-manual', async (_event, request: unknown) => {
return await deps.runSubsyncManual(request);
});
ipcMain.handle("get-anki-connect-status", () => {
ipcMain.handle('get-anki-connect-status', () => {
return deps.getAnkiConnectStatus();
});
ipcMain.handle("runtime-options:get", () => {
ipcMain.handle('runtime-options:get', () => {
return deps.getRuntimeOptions();
});
ipcMain.handle(
"runtime-options:set",
(_event, id: string, value: unknown) => {
return deps.setRuntimeOption(id, value);
},
);
ipcMain.handle('runtime-options:set', (_event, id: string, value: unknown) => {
return deps.setRuntimeOption(id, value);
});
ipcMain.handle(
"runtime-options:cycle",
(_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction);
},
);
ipcMain.handle('runtime-options:cycle', (_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction);
});
ipcMain.on(
"overlay-content-bounds:report",
(_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
},
);
ipcMain.on('overlay-content-bounds:report', (_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
});
ipcMain.handle("anilist:get-status", () => {
ipcMain.handle('anilist:get-status', () => {
return deps.getAnilistStatus();
});
ipcMain.handle("anilist:clear-token", () => {
ipcMain.handle('anilist:clear-token', () => {
deps.clearAnilistToken();
return { ok: true };
});
ipcMain.handle("anilist:open-setup", () => {
ipcMain.handle('anilist:open-setup', () => {
deps.openAnilistSetup();
return { ok: true };
});
ipcMain.handle("anilist:get-queue-status", () => {
ipcMain.handle('anilist:get-queue-status', () => {
return deps.getAnilistQueueStatus();
});
ipcMain.handle("anilist:retry-now", async () => {
ipcMain.handle('anilist:retry-now', async () => {
return await deps.retryAnilistQueueNow();
});
}

View File

@@ -1,9 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
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>> = {};
@@ -17,7 +14,7 @@ class FakeWebSocket {
}
close(): void {
this.emit("close");
this.emit('close');
}
emit(event: string, ...args: unknown[]): void {
@@ -27,14 +24,14 @@ class FakeWebSocket {
}
}
test("Jellyfin remote service has no traffic until started", async () => {
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",
serverUrl: 'http://jellyfin.local:8096',
accessToken: 'token-0',
deviceId: 'device-0',
webSocketFactory: () => {
socketCreateCount += 1;
return new FakeWebSocket() as unknown as any;
@@ -52,16 +49,16 @@ test("Jellyfin remote service has no traffic until started", async () => {
assert.equal(service.isConnected(), false);
});
test("start posts capabilities on socket connect", async () => {
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",
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");
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;
@@ -73,27 +70,24 @@ test("start posts capabilities on socket connect", async () => {
});
service.start();
sockets[0].emit("open");
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(fetchCalls[0].input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full');
assert.equal(service.isConnected(), true);
});
test("socket headers include jellyfin authorization metadata", () => {
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",
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;
@@ -105,19 +99,19 @@ test("socket headers include jellyfin authorization metadata", () => {
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"]);
assert.ok(seenHeaders[0]['X-Emby-Authorization']);
});
test("dispatches inbound Play, Playstate, and GeneralCommand messages", () => {
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'token-2',
deviceId: 'device-2',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
@@ -131,39 +125,36 @@ test("dispatches inbound Play, Playstate, and GeneralCommand messages", () => {
service.start();
const socket = sockets[0];
socket.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemId: 'movie-1' } }));
socket.emit(
"message",
JSON.stringify({ MessageType: "Play", Data: { ItemId: "movie-1" } }),
'message',
JSON.stringify({ MessageType: 'Playstate', Data: JSON.stringify({ Command: 'Pause' }) }),
);
socket.emit(
"message",
JSON.stringify({ MessageType: "Playstate", Data: JSON.stringify({ Command: "Pause" }) }),
);
socket.emit(
"message",
'message',
Buffer.from(
JSON.stringify({
MessageType: "GeneralCommand",
Data: { Name: "DisplayMessage" },
MessageType: 'GeneralCommand',
Data: { Name: 'DisplayMessage' },
}),
"utf8",
'utf8',
),
);
assert.deepEqual(playPayloads, [{ ItemId: "movie-1" }]);
assert.deepEqual(playstatePayloads, [{ Command: "Pause" }]);
assert.deepEqual(commandPayloads, [{ Name: "DisplayMessage" }]);
assert.deepEqual(playPayloads, [{ ItemId: 'movie-1' }]);
assert.deepEqual(playstatePayloads, [{ Command: 'Pause' }]);
assert.deepEqual(commandPayloads, [{ Name: 'DisplayMessage' }]);
});
test("schedules reconnect with bounded exponential backoff", () => {
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'token-3',
deviceId: 'device-3',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
@@ -183,28 +174,28 @@ test("schedules reconnect with bounded exponential backoff", () => {
});
service.start();
sockets[0].emit("close");
sockets[0].emit('close');
pendingTimers.shift()?.();
sockets[1].emit("close");
sockets[1].emit('close');
pendingTimers.shift()?.();
sockets[2].emit("close");
sockets[2].emit('close');
pendingTimers.shift()?.();
sockets[3].emit("close");
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", () => {
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop',
deviceId: 'device-stop',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
@@ -225,7 +216,7 @@ test("Jellyfin remote stop prevents further reconnect/network activity", () => {
service.start();
assert.equal(sockets.length, 1);
sockets[0].emit("close");
sockets[0].emit('close');
assert.equal(pendingTimers.length, 1);
service.stop();
@@ -237,15 +228,15 @@ test("Jellyfin remote stop prevents further reconnect/network activity", () => {
assert.equal(service.isConnected(), false);
});
test("reportProgress posts timeline payload and treats failure as non-fatal", async () => {
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'token-4',
deviceId: 'device-4',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
@@ -253,19 +244,19 @@ test("reportProgress posts timeline payload and treats failure as non-fatal", as
},
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 });
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");
sockets[0].emit('open');
await new Promise((resolve) => setTimeout(resolve, 0));
const expectedPayload = buildJellyfinTimelinePayload({
itemId: "movie-2",
itemId: 'movie-2',
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
@@ -275,7 +266,7 @@ test("reportProgress posts timeline payload and treats failure as non-fatal", as
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload));
const ok = await service.reportProgress({
itemId: "movie-2",
itemId: 'movie-2',
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
@@ -284,30 +275,25 @@ test("reportProgress posts timeline payload and treats failure as non-fatal", as
});
shouldFailTimeline = true;
const failed = await service.reportProgress({
itemId: "movie-2",
itemId: 'movie-2',
positionTicks: 999,
});
const timelineCall = fetchCalls.find((call) =>
call.input.endsWith("/Sessions/Playing/Progress"),
);
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,
);
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 () => {
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'token-5',
deviceId: 'device-5',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
@@ -316,19 +302,16 @@ test("advertiseNow validates server registration using Sessions endpoint", async
fetchImpl: (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/Sessions")) {
return new Response(
JSON.stringify([{ DeviceId: "device-5" }]),
{ status: 200 },
);
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");
sockets[0].emit('open');
const ok = await service.advertiseNow();
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith("/Sessions")));
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
});

View File

@@ -1,4 +1,4 @@
import WebSocket from "ws";
import WebSocket from 'ws';
export interface JellyfinRemoteSessionMessage {
MessageType?: string;
@@ -40,10 +40,10 @@ export interface JellyfinTimelinePayload {
}
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;
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;
}
@@ -79,21 +79,21 @@ export interface JellyfinRemoteSessionServiceOptions {
}
function normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, "");
return serverUrl.trim().replace(/\/+$/, '');
}
function clampVolume(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
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;
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;
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
try {
@@ -105,15 +105,15 @@ function parseMessageData(value: unknown): unknown {
function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | null {
const serialized =
typeof rawData === "string"
typeof rawData === 'string'
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString("utf8")
? rawData.toString('utf8')
: null;
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as JellyfinRemoteSessionMessage;
if (!parsed || typeof parsed !== "object") return null;
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
@@ -121,7 +121,7 @@ function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | n
}
function asNullableInteger(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isInteger(value)) return null;
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
return value;
}
@@ -131,9 +131,9 @@ function createDefaultCapabilities(): {
SupportsMediaControl: boolean;
} {
return {
PlayableMediaTypes: "Video,Audio",
PlayableMediaTypes: 'Video,Audio',
SupportedCommands:
"Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent",
'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent',
SupportsMediaControl: true,
};
}
@@ -161,14 +161,14 @@ export function buildJellyfinTimelinePayload(
CanSeek: state.canSeek !== false,
VolumeLevel: clampVolume(state.volumeLevel),
PlaybackRate:
typeof state.playbackRate === "number" && Number.isFinite(state.playbackRate)
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
? state.playbackRate
: 1,
PlayMethod: state.playMethod || "DirectPlay",
PlayMethod: state.playMethod || 'DirectPlay',
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId,
EventName: state.eventName || "timeupdate",
EventName: state.eventName || 'timeupdate',
};
}
@@ -220,8 +220,8 @@ export class JellyfinRemoteSessionService {
...createDefaultCapabilities(),
...(options.capabilities ?? {}),
};
const clientName = options.clientName || "SubMiner";
const clientVersion = options.clientVersion || "0.1.0";
const clientName = options.clientName || 'SubMiner';
const clientVersion = options.clientVersion || '0.1.0';
const deviceName = options.deviceName || clientName;
this.authHeader = buildAuthorizationHeader({
clientName,
@@ -268,30 +268,21 @@ export class JellyfinRemoteSessionService {
return this.isRegisteredOnServer();
}
public async reportPlaying(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline("/Sessions/Playing", {
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || "start",
EventName: state.eventName || 'start',
});
}
public async reportProgress(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline(
"/Sessions/Playing/Progress",
buildJellyfinTimelinePayload(state),
);
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", {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || "stop",
EventName: state.eventName || 'stop',
});
}
@@ -305,7 +296,7 @@ export class JellyfinRemoteSessionService {
this.socket = socket;
let disconnected = false;
socket.on("open", () => {
socket.on('open', () => {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
@@ -313,7 +304,7 @@ export class JellyfinRemoteSessionService {
void this.postCapabilities();
});
socket.on("message", (rawData) => {
socket.on('message', (rawData) => {
this.handleInboundMessage(rawData);
});
@@ -330,8 +321,8 @@ export class JellyfinRemoteSessionService {
}
};
socket.on("close", handleDisconnect);
socket.on("error", handleDisconnect);
socket.on('close', handleDisconnect);
socket.on('error', handleDisconnect);
}
private scheduleReconnect(): void {
@@ -351,18 +342,18 @@ export class JellyfinRemoteSessionService {
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);
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,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
};
if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers);
@@ -375,50 +366,42 @@ export class JellyfinRemoteSessionService {
private async postCapabilities(): Promise<void> {
const payload = this.capabilities;
const fullEndpointOk = await this.postJson(
"/Sessions/Capabilities/Full",
payload,
);
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
if (fullEndpointOk) return;
await this.postJson("/Sessions/Capabilities", payload);
await this.postJson('/Sessions/Capabilities', payload);
}
private async isRegisteredOnServer(): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}/Sessions`, {
method: "GET",
method: 'GET',
headers: {
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
'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,
);
return sessions.some((session) => String(session.DeviceId || '') === this.deviceId);
} catch {
return false;
}
}
private async postTimeline(
path: string,
payload: JellyfinTimelinePayload,
): Promise<boolean> {
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",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
'X-Emby-Authorization': this.authHeader,
'X-Emby-Token': this.accessToken,
},
body: JSON.stringify(payload),
});
@@ -433,15 +416,15 @@ export class JellyfinRemoteSessionService {
if (!message) return;
const messageType = message.MessageType;
const payload = parseMessageData(message.Data);
if (messageType === "Play") {
if (messageType === 'Play') {
this.onPlay?.(payload);
return;
}
if (messageType === "Playstate") {
if (messageType === 'Playstate') {
this.onPlaystate?.(payload);
return;
}
if (messageType === "GeneralCommand") {
if (messageType === 'GeneralCommand') {
this.onGeneralCommand?.(payload);
}
}

View File

@@ -1,5 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import {
authenticateWithPassword,
listItems,
@@ -7,22 +7,22 @@ import {
listSubtitleTracks,
resolvePlaybackPlan,
ticksToSeconds,
} from "./jellyfin";
} from './jellyfin';
const clientInfo = {
deviceId: "subminer-test",
clientName: "SubMiner",
clientVersion: "0.1.0-test",
deviceId: 'subminer-test',
clientName: 'SubMiner',
clientVersion: '0.1.0-test',
};
test("authenticateWithPassword returns token and user", async () => {
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" },
AccessToken: 'abc123',
User: { Id: 'user-1' },
}),
{ status: 200 },
);
@@ -30,30 +30,30 @@ test("authenticateWithPassword returns token and user", async () => {
try {
const session = await authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
'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");
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 () => {
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",
Id: 'lib-1',
Name: 'TV',
CollectionType: 'tvshows',
Type: 'CollectionFolder',
},
],
}),
@@ -63,19 +63,19 @@ test("listLibraries maps server response", async () => {
try {
const libraries = await listLibraries(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
);
assert.deepEqual(libraries, [
{
id: "lib-1",
name: "TV",
collectionType: "tvshows",
type: "CollectionFolder",
id: 'lib-1',
name: 'TV',
collectionType: 'tvshows',
type: 'CollectionFolder',
},
]);
} finally {
@@ -83,7 +83,7 @@ test("listLibraries maps server response", async () => {
}
});
test("listItems supports search and formats title", async () => {
test('listItems supports search and formats title', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /SearchTerm=planet/);
@@ -91,10 +91,10 @@ test("listItems supports search and formats title", async () => {
JSON.stringify({
Items: [
{
Id: "ep-1",
Name: "Pilot",
Type: "Episode",
SeriesName: "Space Show",
Id: 'ep-1',
Name: 'Pilot',
Type: 'Episode',
SeriesName: 'Space Show',
ParentIndexNumber: 1,
IndexNumber: 2,
},
@@ -107,36 +107,36 @@ test("listItems supports search and formats title", async () => {
try {
const items = await listItems(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
libraryId: "lib-1",
searchTerm: "planet",
libraryId: 'lib-1',
searchTerm: 'planet',
limit: 25,
},
);
assert.equal(items[0].title, "Space Show S01E02 Pilot");
assert.equal(items[0].title, 'Space Show S01E02 Pilot');
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan chooses direct play when allowed", async () => {
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",
Id: 'movie-1',
Name: 'Movie A',
UserData: { PlaybackPositionTicks: 20_000_000 },
MediaSources: [
{
Id: "ms-1",
Container: "mkv",
Id: 'ms-1',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 1,
@@ -150,21 +150,21 @@ test("resolvePlaybackPlan chooses direct play when allowed", async () => {
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
directPlayContainers: ['mkv'],
},
{ itemId: "movie-1" },
{ itemId: 'movie-1' },
);
assert.equal(plan.mode, "direct");
assert.equal(plan.mode, 'direct');
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
assert.equal(plan.subtitleStreamIndex, null);
@@ -174,18 +174,18 @@ test("resolvePlaybackPlan chooses direct play when allowed", async () => {
}
});
test("resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled", async () => {
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",
Id: 'movie-2',
Name: 'Movie B',
UserData: { PlaybackPositionTicks: 10_000_000 },
MediaSources: [
{
Id: "ms-2",
Container: "mkv",
Id: 'ms-2',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 4,
@@ -198,44 +198,44 @@ test("resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: false,
directPlayContainers: ["mkv"],
transcodeVideoCodec: "h264",
directPlayContainers: ['mkv'],
transcodeVideoCodec: 'h264',
},
{ itemId: "movie-2" },
{ itemId: 'movie-2' },
);
assert.equal(plan.mode, "transcode");
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");
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 () => {
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",
Id: 'movie-3',
Name: 'Movie C',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-3",
Container: "avi",
Id: 'ms-3',
Container: 'avi',
SupportsDirectStream: true,
SupportsTranscoding: true,
},
@@ -247,71 +247,71 @@ test("resolvePlaybackPlan falls back to transcode when direct container not allo
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
directPlayContainers: ['mkv', 'mp4'],
transcodeVideoCodec: 'h265',
},
{
itemId: "movie-3",
itemId: 'movie-3',
audioStreamIndex: 2,
subtitleStreamIndex: 5,
},
);
assert.equal(plan.mode, "transcode");
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");
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 () => {
test('listSubtitleTracks returns all subtitle streams with delivery urls', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
Id: 'movie-1',
MediaSources: [
{
Id: "ms-1",
Id: 'ms-1',
MediaStreams: [
{
Type: "Subtitle",
Type: 'Subtitle',
Index: 2,
Language: "eng",
DisplayTitle: "English Full",
Language: 'eng',
DisplayTitle: 'English Full',
IsDefault: true,
DeliveryMethod: "Embed",
DeliveryMethod: 'Embed',
},
{
Type: "Subtitle",
Type: 'Subtitle',
Index: 3,
Language: "jpn",
Title: "Japanese Signs",
Language: 'jpn',
Title: 'Japanese Signs',
IsForced: true,
IsExternal: true,
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-1/ms-1/Subtitles/3/Stream.srt",
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
IsExternalUrl: false,
},
{
Type: "Subtitle",
Type: 'Subtitle',
Index: 4,
Language: "spa",
Title: "Spanish External",
DeliveryMethod: "External",
DeliveryUrl: "https://cdn.example.com/subs.srt",
Language: 'spa',
Title: 'Spanish External',
DeliveryMethod: 'External',
DeliveryUrl: 'https://cdn.example.com/subs.srt',
IsExternalUrl: true,
},
],
@@ -324,13 +324,13 @@ test("listSubtitleTracks returns all subtitle streams with delivery urls", async
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
"movie-1",
'movie-1',
);
assert.equal(tracks.length, 3);
assert.deepEqual(
@@ -339,30 +339,30 @@ test("listSubtitleTracks returns all subtitle streams with delivery urls", async
);
assert.equal(
tracks[0].deliveryUrl,
"http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token",
'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",
'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");
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 () => {
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",
Id: 'movie-1',
Name: 'Movie A',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-1",
Container: "avi",
Id: 'ms-1',
Container: 'avi',
SupportsDirectStream: true,
SupportsTranscoding: true,
},
@@ -374,22 +374,22 @@ test("resolvePlaybackPlan falls back to transcode when direct play blocked", asy
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
directPlayContainers: ['mkv', 'mp4'],
transcodeVideoCodec: 'h265',
},
{ itemId: "movie-1" },
{ itemId: 'movie-1' },
);
assert.equal(plan.mode, "transcode");
assert.equal(plan.mode, 'transcode');
assert.match(plan.url, /master\.m3u8\?/);
assert.match(plan.url, /VideoCodec=h265/);
} finally {
@@ -397,22 +397,22 @@ test("resolvePlaybackPlan falls back to transcode when direct play blocked", asy
}
});
test("resolvePlaybackPlan reuses server transcoding url and appends missing params", async () => {
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",
Id: 'movie-4',
Name: 'Movie D',
UserData: { PlaybackPositionTicks: 50_000_000 },
MediaSources: [
{
Id: "ms-4",
Container: "mkv",
Id: 'ms-4',
Container: 'mkv',
SupportsDirectStream: false,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 3,
TranscodingUrl: "/Videos/movie-4/master.m3u8?VideoCodec=hevc",
TranscodingUrl: '/Videos/movie-4/master.m3u8?VideoCodec=hevc',
},
],
}),
@@ -422,10 +422,10 @@ test("resolvePlaybackPlan reuses server transcoding url and appends missing para
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
@@ -433,40 +433,40 @@ test("resolvePlaybackPlan reuses server transcoding url and appends missing para
directPlayPreferred: true,
},
{
itemId: "movie-4",
itemId: 'movie-4',
subtitleStreamIndex: 8,
},
);
assert.equal(plan.mode, "transcode");
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");
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 () => {
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",
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",
Id: 'ms-ep-2',
Container: 'mkv',
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 6,
@@ -479,60 +479,60 @@ test("resolvePlaybackPlan preserves episode metadata, stream selection, and resu
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
directPlayContainers: ['mkv'],
},
{
itemId: "ep-2",
itemId: 'ep-2',
subtitleStreamIndex: 9,
},
);
assert.equal(plan.mode, "direct");
assert.equal(plan.title, "Galaxy Quest S02E07 A New Hope");
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");
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 () => {
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('Playback info unavailable', { status: 500 });
}
return new Response(
JSON.stringify({
Id: "movie-fallback",
Id: 'movie-fallback',
MediaSources: [
{
Id: "ms-fallback",
Id: 'ms-fallback',
MediaStreams: [
{
Type: "Subtitle",
Type: 'Subtitle',
Index: 11,
Language: "eng",
Title: "English",
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt",
Language: 'eng',
Title: 'English',
DeliveryMethod: 'External',
DeliveryUrl: '/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt',
IsExternalUrl: false,
},
],
@@ -546,40 +546,34 @@ test("listSubtitleTracks falls back from PlaybackInfo to item media sources", as
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
"movie-fallback",
'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",
'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 () => {
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;
new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"badpw",
clientInfo,
),
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'badpw', clientInfo),
/Invalid Jellyfin username or password\./,
);
} finally {
@@ -587,16 +581,10 @@ test("authenticateWithPassword surfaces invalid credentials and server status fa
}
globalThis.fetch = (async () =>
new Response("Oops", { status: 500, statusText: "Internal Server Error" })) as typeof fetch;
new Response('Oops', { status: 500, statusText: 'Internal Server Error' })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
clientInfo,
),
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo),
/Jellyfin login failed \(500 Internal Server Error\)\./,
);
} finally {
@@ -604,20 +592,20 @@ test("authenticateWithPassword surfaces invalid credentials and server status fa
}
});
test("listLibraries surfaces token-expiry auth errors", async () => {
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;
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",
serverUrl: 'http://jellyfin.local',
accessToken: 'expired',
userId: 'u1',
username: 'kyle',
},
clientInfo,
),
@@ -628,14 +616,14 @@ test("listLibraries surfaces token-expiry auth errors", async () => {
}
});
test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", async () => {
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",
Id: 'movie-empty',
Name: 'Movie Empty',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [],
}),
@@ -647,14 +635,14 @@ test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", asy
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{ enabled: true },
{ itemId: "movie-empty" },
{ itemId: 'movie-empty' },
),
/No playable media source found for Jellyfin item\./,
);
@@ -665,13 +653,13 @@ test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", asy
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-no-stream",
Name: "Movie No Stream",
Id: 'movie-no-stream',
Name: 'Movie No Stream',
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-none",
Container: "avi",
Id: 'ms-none',
Container: 'avi',
SupportsDirectStream: false,
SupportsTranscoding: false,
},
@@ -685,14 +673,14 @@ test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", asy
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
serverUrl: 'http://jellyfin.local',
accessToken: 'token',
userId: 'u1',
username: 'kyle',
},
clientInfo,
{ enabled: true },
{ itemId: "movie-no-stream" },
{ itemId: 'movie-no-stream' },
),
/Jellyfin item cannot be streamed by direct play or transcoding\./,
);

View File

@@ -1,4 +1,4 @@
import { JellyfinConfig } from "../../types";
import { JellyfinConfig } from '../../types';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
@@ -23,7 +23,7 @@ export interface JellyfinPlaybackSelection {
}
export interface JellyfinPlaybackPlan {
mode: "direct" | "transcode";
mode: 'direct' | 'transcode';
url: string;
title: string;
startTimeTicks: number;
@@ -105,15 +105,15 @@ export interface JellyfinClientInfo {
}
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
return value.trim().replace(/\/+$/, '');
}
function ensureString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
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;
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
function resolveDeliveryUrl(
@@ -126,8 +126,8 @@ function resolveDeliveryUrl(
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);
if (!resolved.searchParams.has('api_key')) {
resolved.searchParams.set('api_key', session.accessToken);
}
return resolved.toString();
}
@@ -136,31 +136,28 @@ function resolveDeliveryUrl(
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";
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);
if (!fallback.searchParams.has('api_key')) {
fallback.searchParams.set('api_key', session.accessToken);
}
return fallback.toString();
}
function createAuthorizationHeader(
client: JellyfinClientInfo,
token?: string,
): string {
function createAuthorizationHeader(client: JellyfinClientInfo, token?: string): string {
const parts = [
`Client="${client.clientName}"`,
`Device="${client.clientName}"`,
@@ -168,7 +165,7 @@ function createAuthorizationHeader(
`Version="${client.clientVersion}"`,
];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(", ")}`;
return `MediaBrowser ${parts.join(', ')}`;
}
async function jellyfinRequestJson<T>(
@@ -178,12 +175,9 @@ async function jellyfinRequestJson<T>(
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);
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,
@@ -191,14 +185,10 @@ async function jellyfinRequestJson<T>(
});
if (response.status === 401 || response.status === 403) {
throw new Error(
"Jellyfin authentication failed (invalid or expired token).",
);
throw new Error('Jellyfin authentication failed (invalid or expired token).');
}
if (!response.ok) {
throw new Error(
`Jellyfin request failed (${response.status} ${response.statusText}).`,
);
throw new Error(`Jellyfin request failed (${response.status} ${response.statusText}).`);
}
return response.json() as Promise<T>;
}
@@ -210,21 +200,21 @@ function createDirectPlayUrl(
plan: JellyfinPlaybackPlan,
): string {
const query = new URLSearchParams({
static: "true",
static: 'true',
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
query.set("LiveStreamId", mediaSource.LiveStreamId);
query.set('LiveStreamId', mediaSource.LiveStreamId);
}
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
query.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
query.set('StartTimeTicks', String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
}
@@ -238,26 +228,17 @@ function createTranscodeUrl(
): 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('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('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('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));
if (!url.searchParams.has('StartTimeTicks') && plan.startTimeTicks > 0) {
url.searchParams.set('StartTimeTicks', String(plan.startTimeTicks));
}
return url.toString();
}
@@ -265,17 +246,17 @@ function createTranscodeUrl(
const query = new URLSearchParams({
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, "h264"),
TranscodingContainer: "ts",
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
TranscodingContainer: 'ts',
});
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
query.set('AudioStreamIndex', String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
query.set('StartTimeTicks', String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`;
}
@@ -288,7 +269,7 @@ function getStreamDefaults(source: JellyfinMediaSource): {
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const defaultAudio = streams.find(
(stream) => stream.Type === "Audio" && stream.IsDefault === true,
(stream) => stream.Type === 'Audio' && stream.IsDefault === true,
);
return {
audioStreamIndex: asIntegerOrNull(defaultAudio?.Index),
@@ -296,19 +277,16 @@ function getStreamDefaults(source: JellyfinMediaSource): {
}
function getDisplayTitle(item: JellyfinItem): string {
if (item.Type === "Episode") {
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();
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";
return ensureString(item.Name).trim() || 'Jellyfin Item';
}
function shouldPreferDirectPlay(
source: JellyfinMediaSource,
config: JellyfinConfig,
): boolean {
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
if (source.SupportsDirectStream !== true) return false;
if (config.directPlayPreferred === false) return false;
@@ -327,14 +305,14 @@ export async function authenticateWithPassword(
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.");
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",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
@@ -344,19 +322,17 @@ export async function authenticateWithPassword(
});
if (response.status === 401 || response.status === 403) {
throw new Error("Invalid Jellyfin username or password.");
throw new Error('Invalid Jellyfin username or password.');
}
if (!response.ok) {
throw new Error(
`Jellyfin login failed (${response.status} ${response.statusText}).`,
);
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.");
throw new Error('Jellyfin login response missing token/user.');
}
return {
@@ -373,7 +349,7 @@ export async function listLibraries(
): Promise<JellyfinLibrary[]> {
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Views`,
{ method: "GET" },
{ method: 'GET' },
session,
client,
);
@@ -381,10 +357,8 @@ export async function listLibraries(
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,
),
name: ensureString(item.Name, 'Untitled'),
collectionType: ensureString((item as { CollectionType?: string }).CollectionType),
type: ensureString(item.Type),
}));
}
@@ -398,24 +372,24 @@ export async function listItems(
limit?: number;
},
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
if (!options.libraryId) throw new Error("Missing Jellyfin library id.");
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",
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());
query.set('SearchTerm', options.searchTerm.trim());
}
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Items?${query.toString()}`,
{ method: "GET" },
{ method: 'GET' },
session,
client,
);
@@ -433,46 +407,41 @@ export async function listSubtitleTracks(
client: JellyfinClientInfo,
itemId: string,
): Promise<JellyfinSubtitleTrack[]> {
if (!itemId.trim()) throw new Error("Missing Jellyfin item id.");
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;
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" },
{ method: 'GET' },
session,
client,
);
source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined;
}
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
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;
if (stream.Type !== 'Subtitle') continue;
const index = asIntegerOrNull(stream.Index);
if (index === null) continue;
tracks.push({
@@ -497,33 +466,27 @@ export async function resolvePlaybackPlan(
selection: JellyfinPlaybackSelection,
): Promise<JellyfinPlaybackPlan> {
if (!selection.itemId) {
throw new Error("Missing Jellyfin item id.");
throw new Error('Missing Jellyfin item id.');
}
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`,
{ method: "GET" },
{ method: 'GET' },
session,
client,
);
const source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
const source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined;
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
throw new Error('No playable media source found for Jellyfin item.');
}
const defaults = getStreamDefaults(source);
const audioStreamIndex =
selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(
0,
asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0,
);
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
const basePlan: JellyfinPlaybackPlan = {
mode: "transcode",
url: "",
mode: 'transcode',
url: '',
title: getDisplayTitle(item),
startTimeTicks,
audioStreamIndex,
@@ -533,36 +496,25 @@ export async function resolvePlaybackPlan(
if (shouldPreferDirectPlay(source, config)) {
return {
...basePlan,
mode: "direct",
mode: 'direct',
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (
source.SupportsTranscoding !== true &&
source.SupportsDirectStream === true
) {
if (source.SupportsTranscoding !== true && source.SupportsDirectStream === true) {
return {
...basePlan,
mode: "direct",
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.",
);
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,
),
mode: 'transcode',
url: createTranscodeUrl(session, selection.itemId, source, basePlan, config),
};
}

View File

@@ -1,16 +1,10 @@
import {
JimakuApiResponse,
JimakuConfig,
JimakuLanguagePreference,
} from "../../types";
import { JimakuApiResponse, JimakuConfig, JimakuLanguagePreference } from '../../types';
import {
jimakuFetchJson as jimakuFetchJsonRequest,
resolveJimakuApiKey as resolveJimakuApiKeyFromConfig,
} from "../../jimaku/utils";
} from '../../jimaku/utils';
export function getJimakuConfig(
getResolvedConfig: () => { jimaku?: JimakuConfig },
): JimakuConfig {
export function getJimakuConfig(getResolvedConfig: () => { jimaku?: JimakuConfig }): JimakuConfig {
const config = getResolvedConfig();
return config.jimaku ?? {};
}
@@ -37,7 +31,7 @@ export function getJimakuMaxEntryResults(
): number {
const config = getJimakuConfig(getResolvedConfig);
const value = config.maxEntryResults;
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return defaultValue;
@@ -64,18 +58,14 @@ export async function jimakuFetchJson<T>(
return {
ok: false,
error: {
error:
"Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.",
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,
),
baseUrl: getJimakuBaseUrl(options.getResolvedConfig, options.defaultBaseUrl),
apiKey,
});
}

View File

@@ -6,27 +6,27 @@ export type JlptIgnoredPos1Entry = {
// 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 {
@@ -37,52 +37,44 @@ export function shouldIgnoreJlptByTerm(term: string): boolean {
// 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: 'Particles (ko/kara/nagara etc.): mostly grammatical glue, not independent vocabulary.',
},
{
pos1: "助動詞",
reason:
"Auxiliary verbs (past tense, politeness, modality): grammar helpers.",
pos1: '助動詞',
reason: 'Auxiliary verbs (past tense, politeness, modality): grammar helpers.',
},
{
pos1: "記号",
reason: "Symbols/punctuation and symbols-like tokens.",
pos1: '記号',
reason: 'Symbols/punctuation and symbols-like tokens.',
},
{
pos1: "補助記号",
reason: "Auxiliary symbols (e.g. bracket-like or markup tokens).",
pos1: '補助記号',
reason: 'Auxiliary symbols (e.g. bracket-like or markup tokens).',
},
{
pos1: "連体詞",
pos1: '連体詞',
reason: 'Adnominal forms (e.g. demonstratives like "この").',
},
{
pos1: "感動詞",
reason: "Interjections/onomatopoeia-style exclamations.",
pos1: '感動詞',
reason: 'Interjections/onomatopoeia-style exclamations.',
},
{
pos1: "接続詞",
reason:
"Conjunctions that connect clauses, usually not target vocab items.",
pos1: '接続詞',
reason: 'Conjunctions that connect clauses, usually not target vocab items.',
},
{
pos1: "接頭詞",
reason: "Prefixes/prefix-like grammatical elements.",
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 = JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1);
export const JLPT_IGNORED_MECAB_POS1_LIST: readonly string[] =
JLPT_IGNORED_MECAB_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,
);
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;

View File

@@ -1,7 +1,7 @@
import * as fs from "fs";
import * as path from "path";
import * as fs from 'fs';
import * as path from 'path';
import type { JlptLevel } from "../../types";
import type { JlptLevel } from '../../types';
export interface JlptVocabLookupOptions {
searchPaths: string[];
@@ -9,11 +9,11 @@ export interface JlptVocabLookupOptions {
}
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" },
{ 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,
@@ -30,13 +30,10 @@ function normalizeJlptTerm(value: string): string {
}
function hasFrequencyDisplayValue(meta: unknown): boolean {
if (!meta || typeof meta !== "object") return false;
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",
);
if (!frequency || typeof frequency !== 'object') return false;
return Object.prototype.hasOwnProperty.call(frequency as Record<string, unknown>, 'displayValue');
}
function addEntriesToMap(
@@ -62,7 +59,7 @@ function addEntriesToMap(
}
const [term, _entryId, meta] = rawEntry as [unknown, unknown, unknown];
if (typeof term !== "string") {
if (typeof term !== 'string') {
continue;
}
@@ -102,7 +99,7 @@ function collectDictionaryFromPath(
let rawText: string;
try {
rawText = fs.readFileSync(bankPath, "utf-8");
rawText = fs.readFileSync(bankPath, 'utf-8');
} catch {
log(`Failed to read JLPT bank file ${bankPath}`);
continue;
@@ -117,9 +114,7 @@ function collectDictionaryFromPath(
}
if (!Array.isArray(rawEntries)) {
log(
`JLPT bank file has unsupported format (expected JSON array): ${bankPath}`,
);
log(`JLPT bank file has unsupported format (expected JSON array): ${bankPath}`);
continue;
}
@@ -156,9 +151,7 @@ export async function createJlptVocabularyLookup(
if (terms.size > 0) {
resolvedBanks.push(dictionaryPath);
foundBankCount += 1;
options.log(
`JLPT dictionary loaded from ${dictionaryPath} (${terms.size} entries)`,
);
options.log(`JLPT dictionary loaded from ${dictionaryPath} (${terms.size} entries)`);
return (term: string): JlptLevel | null => {
if (!term) return null;
const normalized = normalizeJlptTerm(term);
@@ -172,17 +165,15 @@ export async function createJlptVocabularyLookup(
}
options.log(
`JLPT dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(", ")}`,
`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.",
'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(", ")}`,
);
options.log(`JLPT dictionary search matched path(s): ${resolvedBanks.join(', ')}`);
}
return NOOP_LOOKUP;
}

View File

@@ -1,13 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import {
copyCurrentSubtitle,
handleMineSentenceDigit,
handleMultiCopyDigit,
mineSentenceCard,
} from "./mining";
} from './mining';
test("copyCurrentSubtitle reports tracker and subtitle guards", () => {
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
const osd: string[] = [];
const copied: string[] = [];
@@ -16,7 +16,7 @@ test("copyCurrentSubtitle reports tracker and subtitle guards", () => {
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "Subtitle tracker not available");
assert.equal(osd.at(-1), 'Subtitle tracker not available');
copyCurrentSubtitle({
subtitleTimingTracker: {
@@ -27,29 +27,29 @@ test("copyCurrentSubtitle reports tracker and subtitle guards", () => {
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "No current subtitle");
assert.equal(osd.at(-1), 'No current subtitle');
assert.deepEqual(copied, []);
});
test("copyCurrentSubtitle copies current subtitle text", () => {
test('copyCurrentSubtitle copies current subtitle text', () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitle({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => "hello world",
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");
assert.deepEqual(copied, ['hello world']);
assert.equal(osd.at(-1), 'Copied subtitle');
});
test("mineSentenceCard handles missing integration and disconnected mpv", async () => {
test('mineSentenceCard handles missing integration and disconnected mpv', async () => {
const osd: string[] = [];
assert.equal(
@@ -60,7 +60,7 @@ test("mineSentenceCard handles missing integration and disconnected mpv", async
}),
false,
);
assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
assert.equal(osd.at(-1), 'AnkiConnect integration not enabled');
assert.equal(
await mineSentenceCard({
@@ -72,7 +72,7 @@ test("mineSentenceCard handles missing integration and disconnected mpv", async
},
mpvClient: {
connected: false,
currentSubText: "line",
currentSubText: 'line',
currentSubStart: 1,
currentSubEnd: 2,
},
@@ -81,10 +81,10 @@ test("mineSentenceCard handles missing integration and disconnected mpv", async
false,
);
assert.equal(osd.at(-1), "MPV not connected");
assert.equal(osd.at(-1), 'MPV not connected');
});
test("mineSentenceCard creates sentence card from mpv subtitle state", async () => {
test('mineSentenceCard creates sentence card from mpv subtitle state', async () => {
const created: Array<{
sentence: string;
startTime: number;
@@ -97,22 +97,17 @@ test("mineSentenceCard creates sentence card from mpv subtitle state", async ()
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (
sentence,
startTime,
endTime,
secondarySub,
) => {
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: "subtitle line",
currentSubText: 'subtitle line',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: "secondary line",
currentSecondarySubText: 'secondary line',
},
showMpvOsd: () => {},
});
@@ -120,21 +115,21 @@ test("mineSentenceCard creates sentence card from mpv subtitle state", async ()
assert.equal(createdCard, true);
assert.deepEqual(created, [
{
sentence: "subtitle line",
sentence: 'subtitle line',
startTime: 10,
endTime: 12,
secondarySub: "secondary line",
secondarySub: 'secondary line',
},
]);
});
test("handleMultiCopyDigit copies available history and reports truncation", () => {
test('handleMultiCopyDigit copies available history and reports truncation', () => {
const osd: string[] = [];
const copied: string[] = [];
handleMultiCopyDigit(5, {
subtitleTimingTracker: {
getRecentBlocks: (count) => ["a", "b"].slice(0, count),
getRecentBlocks: (count) => ['a', 'b'].slice(0, count),
getCurrentSubtitle: () => null,
findTiming: () => null,
},
@@ -142,33 +137,31 @@ test("handleMultiCopyDigit copies available history and reports truncation", ()
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ["a\n\nb"]);
assert.equal(osd.at(-1), "Only 2 lines available, copied 2");
assert.deepEqual(copied, ['a\n\nb']);
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
});
test("handleMineSentenceDigit reports async create failures", async () => {
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"],
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === "one"
? { startTime: 1, endTime: 3 }
: { startTime: 4, endTime: 7 },
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {
throw new Error("mine boom");
throw new Error('mine boom');
},
},
getCurrentSecondarySubText: () => "sub2",
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }),
onCardsMined: (count) => {
@@ -179,26 +172,22 @@ test("handleMineSentenceDigit reports async create failures", async () => {
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(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 () => {
test('handleMineSentenceDigit increments successful card count', async () => {
const osd: string[] = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ["one", "two"],
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === "one"
? { startTime: 1, endTime: 3 }
: { startTime: 4, endTime: 7 },
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
@@ -206,7 +195,7 @@ test("handleMineSentenceDigit increments successful card count", async () => {
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => true,
},
getCurrentSecondarySubText: () => "sub2",
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: () => {},
onCardsMined: (count) => {

View File

@@ -37,16 +37,14 @@ export function handleMultiCopyDigit(
const availableCount = Math.min(count, 200);
const blocks = deps.subtitleTimingTracker.getRecentBlocks(availableCount);
if (blocks.length === 0) {
deps.showMpvOsd("No subtitle history available");
deps.showMpvOsd('No subtitle history available');
return;
}
const actualCount = blocks.length;
deps.writeClipboardText(blocks.join("\n\n"));
deps.writeClipboardText(blocks.join('\n\n'));
if (actualCount < count) {
deps.showMpvOsd(
`Only ${actualCount} lines available, copied ${actualCount}`,
);
deps.showMpvOsd(`Only ${actualCount} lines available, copied ${actualCount}`);
} else {
deps.showMpvOsd(`Copied ${actualCount} lines`);
}
@@ -58,16 +56,16 @@ export function copyCurrentSubtitle(deps: {
showMpvOsd: (text: string) => void;
}): void {
if (!deps.subtitleTimingTracker) {
deps.showMpvOsd("Subtitle tracker not available");
deps.showMpvOsd('Subtitle tracker not available');
return;
}
const currentSubtitle = deps.subtitleTimingTracker.getCurrentSubtitle();
if (!currentSubtitle) {
deps.showMpvOsd("No current subtitle");
deps.showMpvOsd('No current subtitle');
return;
}
deps.writeClipboardText(currentSubtitle);
deps.showMpvOsd("Copied subtitle");
deps.showMpvOsd('Copied subtitle');
}
function requireAnkiIntegration(
@@ -75,7 +73,7 @@ function requireAnkiIntegration(
showMpvOsd: (text: string) => void,
): AnkiIntegrationLike | null {
if (!ankiIntegration) {
showMpvOsd("AnkiConnect integration not enabled");
showMpvOsd('AnkiConnect integration not enabled');
return null;
}
return ankiIntegration;
@@ -119,11 +117,11 @@ export async function mineSentenceCard(deps: {
const mpvClient = deps.mpvClient;
if (!mpvClient || !mpvClient.connected) {
deps.showMpvOsd("MPV not connected");
deps.showMpvOsd('MPV not connected');
return false;
}
if (!mpvClient.currentSubText) {
deps.showMpvOsd("No current subtitle");
deps.showMpvOsd('No current subtitle');
return false;
}
@@ -150,7 +148,7 @@ export function handleMineSentenceDigit(
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
if (blocks.length === 0) {
deps.showMpvOsd("No subtitle history available");
deps.showMpvOsd('No subtitle history available');
return;
}
@@ -161,28 +159,23 @@ export function handleMineSentenceDigit(
}
if (timings.length === 0) {
deps.showMpvOsd("Subtitle timing not found");
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 sentence = blocks.join(' ');
const cardsToMine = 1;
deps.ankiIntegration
.createSentenceCard(
sentence,
rangeStart,
rangeEnd,
deps.getCurrentSecondarySubText(),
)
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
}
})
.catch((err) => {
deps.logError("mineSentenceMultiple failed:", err);
deps.logError('mineSentenceMultiple failed:', err);
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
});
}

View File

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

View File

@@ -22,7 +22,7 @@ import {
MPV_REQUEST_ID_SUBTEXT_ASS,
MPV_REQUEST_ID_SUB_USE_MARGINS,
MPV_REQUEST_ID_PAUSE,
} from "./mpv-protocol";
} from './mpv-protocol';
type MpvProtocolCommand = {
command: unknown[];
@@ -34,135 +34,135 @@ export interface MpvSendCommand {
}
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",
'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"],
command: ['get_property', 'sub-text'],
request_id: MPV_REQUEST_ID_SUBTEXT,
},
{
command: ["get_property", "sub-text-ass"],
command: ['get_property', 'sub-text-ass'],
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
},
{
command: ["get_property", "path"],
command: ['get_property', 'path'],
request_id: MPV_REQUEST_ID_PATH,
},
{
command: ["get_property", "media-title"],
command: ['get_property', 'media-title'],
},
{
command: ["get_property", "pause"],
command: ['get_property', 'pause'],
request_id: MPV_REQUEST_ID_PAUSE,
},
{
command: ["get_property", "secondary-sub-text"],
command: ['get_property', 'secondary-sub-text'],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,
},
{
command: ["get_property", "secondary-sub-visibility"],
command: ['get_property', 'secondary-sub-visibility'],
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
},
{
command: ["get_property", "aid"],
command: ['get_property', 'aid'],
request_id: MPV_REQUEST_ID_AID,
},
{
command: ["get_property", "sub-pos"],
command: ['get_property', 'sub-pos'],
request_id: MPV_REQUEST_ID_SUB_POS,
},
{
command: ["get_property", "sub-font-size"],
command: ['get_property', 'sub-font-size'],
request_id: MPV_REQUEST_ID_SUB_FONT_SIZE,
},
{
command: ["get_property", "sub-scale"],
command: ['get_property', 'sub-scale'],
request_id: MPV_REQUEST_ID_SUB_SCALE,
},
{
command: ["get_property", "sub-margin-y"],
command: ['get_property', 'sub-margin-y'],
request_id: MPV_REQUEST_ID_SUB_MARGIN_Y,
},
{
command: ["get_property", "sub-margin-x"],
command: ['get_property', 'sub-margin-x'],
request_id: MPV_REQUEST_ID_SUB_MARGIN_X,
},
{
command: ["get_property", "sub-font"],
command: ['get_property', 'sub-font'],
request_id: MPV_REQUEST_ID_SUB_FONT,
},
{
command: ["get_property", "sub-spacing"],
command: ['get_property', 'sub-spacing'],
request_id: MPV_REQUEST_ID_SUB_SPACING,
},
{
command: ["get_property", "sub-bold"],
command: ['get_property', 'sub-bold'],
request_id: MPV_REQUEST_ID_SUB_BOLD,
},
{
command: ["get_property", "sub-italic"],
command: ['get_property', 'sub-italic'],
request_id: MPV_REQUEST_ID_SUB_ITALIC,
},
{
command: ["get_property", "sub-scale-by-window"],
command: ['get_property', 'sub-scale-by-window'],
request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
},
{
command: ["get_property", "osd-height"],
command: ['get_property', 'osd-height'],
request_id: MPV_REQUEST_ID_OSD_HEIGHT,
},
{
command: ["get_property", "osd-dimensions"],
command: ['get_property', 'osd-dimensions'],
request_id: MPV_REQUEST_ID_OSD_DIMENSIONS,
},
{
command: ["get_property", "sub-border-size"],
command: ['get_property', 'sub-border-size'],
request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE,
},
{
command: ["get_property", "sub-shadow-offset"],
command: ['get_property', 'sub-shadow-offset'],
request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
},
{
command: ["get_property", "sub-ass-override"],
command: ['get_property', 'sub-ass-override'],
request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
},
{
command: ["get_property", "sub-use-margins"],
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] });
send({ command: ['observe_property', index + 1, property] });
});
}

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { MpvSubtitleRenderMetrics } from "../../types";
import test from 'node:test';
import assert from 'node:assert/strict';
import type { MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
@@ -8,7 +8,7 @@ import {
splitMpvMessagesFromBuffer,
parseVisibilityProperty,
asBoolean,
} from "./mpv-protocol";
} from './mpv-protocol';
function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
deps: MpvProtocolHandleMessageDeps;
@@ -22,11 +22,11 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
};
} {
const state = {
subText: "",
secondarySubText: "",
subText: '',
secondarySubText: '',
events: [] as Array<unknown>,
commands: [] as unknown[],
mediaPath: "",
mediaPath: '',
restored: 0,
};
const metrics: MpvSubtitleRenderMetrics = {
@@ -35,13 +35,13 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: "",
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: "yes",
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
@@ -52,7 +52,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
state,
deps: {
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ["ja"] },
secondarySub: { secondarySubLanguages: ['ja'] },
}),
getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false,
@@ -107,46 +107,44 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
};
}
test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => {
test('dispatchMpvProtocolMessage emits subtitle text on property change', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: "property-change", name: "sub-text", data: "字幕" },
{ event: 'property-change', name: 'sub-text', data: '字幕' },
deps,
);
assert.equal(state.subText, "字幕");
assert.deepEqual(state.events, [{ text: "字幕", isOverlayVisible: false }]);
assert.equal(state.subText, '字幕');
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test("dispatchMpvProtocolMessage sets secondary subtitle track based on track list response", async () => {
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" },
{ type: 'audio', id: 1, lang: 'eng' },
{ type: 'sub', id: 2, lang: 'ja' },
],
},
deps,
);
assert.deepEqual(state.commands, [
{ command: ["set_property", "secondary-sid", 2] },
]);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
});
test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => {
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: "shutdown" }, deps);
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
});
test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set", async () => {
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {
let pendingPauseAtSubEnd = true;
let pauseAtTime: number | null = null;
const { deps, state } = createDeps({
@@ -154,7 +152,7 @@ test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
setPendingPauseAtSubEnd: (next) => {
pendingPauseAtSubEnd = next;
},
getCurrentSubText: () => "字幕",
getCurrentSubText: () => '字幕',
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
setPauseAtTime: (next) => {
@@ -162,49 +160,42 @@ test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
},
});
await dispatchMpvProtocolMessage(
{ event: "property-change", name: "sub-end", data: 42 },
deps,
);
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.events, [{ text: '字幕', start: 0, end: 0 }]);
assert.deepEqual(state.commands[state.commands.length - 1], {
command: ["set_property", "pause", false],
command: ['set_property', 'pause', false],
});
});
test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => {
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");
assert.equal(parsed.messages[0].event, 'shutdown');
assert.equal(parsed.messages[1].name, 'media-title');
});
test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => {
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) });
},
);
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}");
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);
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

@@ -1,4 +1,4 @@
import { MpvSubtitleRenderMetrics } from "../../types";
import { MpvSubtitleRenderMetrics } from '../../types';
export type MpvMessage = {
event?: string;
@@ -48,16 +48,9 @@ export interface MpvProtocolHandleMessageDeps {
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean;
emitSubtitleChange: (payload: {
text: string;
isOverlayVisible: boolean;
}) => void;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: {
text: string;
start: number;
end: number;
}) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
@@ -69,9 +62,7 @@ export interface MpvProtocolHandleMessageDeps {
emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void;
emitSubtitleMetricsChange: (
payload: Partial<MpvSubtitleRenderMetrics>,
) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
setSecondarySubVisibility: (visible: boolean) => void;
@@ -92,13 +83,10 @@ export interface MpvProtocolHandleMessageDeps {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
}>,
) => void;
sendCommand: (payload: {
command: unknown[];
request_id?: number;
}) => boolean;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
restorePreviousSecondarySubVisibility: () => void;
}
@@ -107,8 +95,8 @@ export function splitMpvMessagesFromBuffer(
onMessage?: MpvMessageParser,
onError?: MpvParseErrorHandler,
): MpvProtocolParseResult {
const lines = buffer.split("\n");
const nextBuffer = lines.pop() || "";
const lines = buffer.split('\n');
const nextBuffer = lines.pop() || '';
const messages: MpvMessage[] = [];
for (const line of lines) {
@@ -136,47 +124,45 @@ 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) || "";
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") {
} 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") {
} 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.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) || "";
} 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,
);
} else if (msg.name === 'aid') {
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex();
} else if (msg.name === "time-pos") {
} else if (msg.name === 'time-pos') {
deps.emitTimePosChange({ time: (msg.data as number) || 0 });
deps.setCurrentTimePos((msg.data as number) || 0);
if (
@@ -184,65 +170,59 @@ export async function dispatchMpvProtocolMessage(
deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number)
) {
deps.setPauseAtTime(null);
deps.sendCommand({ command: ["set_property", "pause", true] });
deps.sendCommand({ command: ['set_property', 'pause', true] });
}
} else if (msg.name === "pause") {
} else if (msg.name === 'pause') {
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
} else if (msg.name === "media-title") {
} else if (msg.name === 'media-title') {
deps.emitMediaTitleChange({
title: typeof msg.data === "string" ? msg.data.trim() : null,
title: typeof msg.data === 'string' ? msg.data.trim() : null,
});
} else if (msg.name === "path") {
const path = (msg.data as string) || "";
} 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") {
} else if (msg.name === 'sub-pos') {
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
} else if (msg.name === "sub-font-size") {
} else if (msg.name === 'sub-font-size') {
deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number });
} else if (msg.name === "sub-scale") {
} else if (msg.name === 'sub-scale') {
deps.emitSubtitleMetricsChange({ subScale: msg.data as number });
} else if (msg.name === "sub-margin-y") {
} else if (msg.name === 'sub-margin-y') {
deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number });
} else if (msg.name === "sub-margin-x") {
} else if (msg.name === 'sub-margin-x') {
deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number });
} else if (msg.name === "sub-font") {
} else if (msg.name === 'sub-font') {
deps.emitSubtitleMetricsChange({ subFont: msg.data as string });
} else if (msg.name === "sub-spacing") {
} else if (msg.name === 'sub-spacing') {
deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number });
} else if (msg.name === "sub-bold") {
} else if (msg.name === 'sub-bold') {
deps.emitSubtitleMetricsChange({
subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold),
});
} else if (msg.name === "sub-italic") {
} else if (msg.name === 'sub-italic') {
deps.emitSubtitleMetricsChange({
subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic),
});
} else if (msg.name === "sub-border-size") {
} else if (msg.name === 'sub-border-size') {
deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number });
} else if (msg.name === "sub-shadow-offset") {
} else if (msg.name === 'sub-shadow-offset') {
deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number });
} else if (msg.name === "sub-ass-override") {
} else if (msg.name === 'sub-ass-override') {
deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string });
} else if (msg.name === "sub-scale-by-window") {
} else if (msg.name === 'sub-scale-by-window') {
deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(
msg.data,
deps.getSubtitleMetrics().subScaleByWindow,
),
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
});
} else if (msg.name === "sub-use-margins") {
} else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(
msg.data,
deps.getSubtitleMetrics().subUseMargins,
),
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
});
} else if (msg.name === "osd-height") {
} else if (msg.name === 'osd-height') {
deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number });
} else if (msg.name === "osd-dimensions") {
} else if (msg.name === 'osd-dimensions') {
const dims = msg.data as Record<string, unknown> | null;
if (!dims) {
deps.emitSubtitleMetricsChange({ osdDimensions: null });
@@ -259,7 +239,7 @@ export async function dispatchMpvProtocolMessage(
});
}
}
} else if (msg.event === "shutdown") {
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
} else if (msg.request_id) {
if (deps.resolvePendingRequest(msg.request_id, msg)) {
@@ -279,12 +259,12 @@ export async function dispatchMpvProtocolMessage(
if (Array.isArray(tracks)) {
const config = deps.getResolvedConfig();
const languages = config.secondarySub?.secondarySubLanguages || [];
const subTracks = tracks.filter((track) => track.type === "sub");
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],
command: ['set_property', 'secondary-sid', match.id],
});
break;
}
@@ -296,27 +276,25 @@ export async function dispatchMpvProtocolMessage(
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
const nextSubText = (msg.data as string) || "";
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) || "" });
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
deps.emitMediaPathChange({ path: (msg.data as string) || "" });
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.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) || "";
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
deps.emitSecondarySubtitleChange({ text: nextSubText });
} else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) {
@@ -356,17 +334,11 @@ export async function dispatchMpvProtocolMessage(
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,
),
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,
),
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
});
} else if (msg.request_id === MPV_REQUEST_ID_PAUSE) {
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
@@ -393,12 +365,12 @@ export async function dispatchMpvProtocolMessage(
}
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") {
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;
if (['yes', 'true', '1'].includes(normalized)) return true;
if (['no', 'false', '0'].includes(normalized)) return false;
}
return fallback;
}
@@ -409,14 +381,14 @@ export function asFiniteNumber(value: unknown, fallback: number): number {
}
export function parseVisibilityProperty(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value !== "string") return 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") {
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === "no" || normalized === "false" || normalized === "0") {
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}

View File

@@ -1,22 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { MpvSubtitleRenderMetrics } from "../../types";
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";
} from './mpv-render-metrics';
const BASE: MpvSubtitleRenderMetrics = {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
};
test("applyMpvSubtitleRenderMetricsPatch returns unchanged on empty patch", () => {
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", () => {
test('applyMpvSubtitleRenderMetricsPatch reports changed when patch modifies value', () => {
const { next, changed } = applyMpvSubtitleRenderMetricsPatch(BASE, {
subPos: 95,
});

View File

@@ -1,5 +1,5 @@
import { MpvSubtitleRenderMetrics } from "../../types";
import { asBoolean, asFiniteNumber, asString } from "../utils/coerce";
import { MpvSubtitleRenderMetrics } from '../../types';
import { asBoolean, asFiniteNumber, asString } from '../utils/coerce';
export const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
@@ -7,13 +7,13 @@ export const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
subScale: 1,
subMarginY: 34,
subMarginX: 19,
subFont: "sans-serif",
subFont: 'sans-serif',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 2.5,
subShadowOffset: 0,
subAssOverride: "yes",
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 720,
@@ -35,12 +35,12 @@ export function updateMpvSubtitleRenderMetrics(
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"
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),
@@ -63,23 +63,10 @@ export function updateMpvSubtitleRenderMetrics(
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,
),
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,
),
subScaleByWindow: asBoolean(patch.subScaleByWindow, current.subScaleByWindow),
subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins),
osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000),
osdDimensions: nextOsdDimensions,
@@ -107,7 +94,6 @@ export function applyMpvSubtitleRenderMetricsPatch(
next.subScaleByWindow !== current.subScaleByWindow ||
next.subUseMargins !== current.subUseMargins ||
next.osdHeight !== current.osdHeight ||
JSON.stringify(next.osdDimensions) !==
JSON.stringify(current.osdDimensions);
JSON.stringify(next.osdDimensions) !== JSON.stringify(current.osdDimensions);
return { next, changed };
}

View File

@@ -1,13 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentAudioStreamIndex } from "./mpv";
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", () => {
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 },
{ type: 'audio', id: 1, selected: false, 'ff-index': 1 },
{ type: 'audio', id: 2, selected: true, 'ff-index': 3 },
],
null,
),
@@ -15,12 +15,12 @@ test("resolveCurrentAudioStreamIndex returns selected ff-index when no current t
);
});
test("resolveCurrentAudioStreamIndex prefers matching current audio track id", () => {
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 },
{ type: 'audio', id: 1, selected: true, 'ff-index': 3 },
{ type: 'audio', id: 2, selected: false, 'ff-index': 6 },
],
2,
),
@@ -28,6 +28,6 @@ test("resolveCurrentAudioStreamIndex prefers matching current audio track id", (
);
});
test("resolveCurrentAudioStreamIndex returns null when tracks are not an array", () => {
test('resolveCurrentAudioStreamIndex returns null when tracks are not an array', () => {
assert.equal(resolveCurrentAudioStreamIndex(null, null), null);
});

View File

@@ -1,13 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as net from "node:net";
import { EventEmitter } from "node:events";
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";
} from './mpv-transport';
class FakeSocket extends EventEmitter {
public connectedPaths: string[] = [];
@@ -17,7 +17,7 @@ class FakeSocket extends EventEmitter {
connect(path: string): void {
this.connectedPaths.push(path);
setTimeout(() => {
this.emit("connect");
this.emit('connect');
}, 0);
}
@@ -28,13 +28,13 @@ class FakeSocket extends EventEmitter {
destroy(): void {
this.destroyed = true;
this.emit("close");
this.emit('close');
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test("getMpvReconnectDelay follows existing reconnect ramp", () => {
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
assert.equal(getMpvReconnectDelay(0, true), 1000);
assert.equal(getMpvReconnectDelay(1, true), 1000);
assert.equal(getMpvReconnectDelay(2, true), 2000);
@@ -47,7 +47,7 @@ test("getMpvReconnectDelay follows existing reconnect ramp", () => {
assert.equal(getMpvReconnectDelay(6, false), 2000);
});
test("scheduleMpvReconnect clears existing timer and increments attempt", () => {
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> = [];
@@ -60,9 +60,7 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
cleared.push(timer);
};
@@ -94,27 +92,27 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
assert.equal(connected, 1);
});
test("MpvSocketTransport connects and sends payloads over a live socket", async () => {
test('MpvSocketTransport connects and sends payloads over a live socket', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock",
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push("connect");
events.push('connect');
},
onData: () => {
events.push("data");
events.push('data');
},
onError: () => {
events.push("error");
events.push('error');
},
onClose: () => {
events.push("close");
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
const payload: MpvSocketMessagePayload = {
command: ["sub-seek", 1],
command: ['sub-seek', 1],
request_id: 1,
};
@@ -123,31 +121,31 @@ test("MpvSocketTransport connects and sends payloads over a live socket", async
transport.connect();
await wait();
assert.equal(events.includes("connect"), true);
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.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 () => {
test('MpvSocketTransport reports lifecycle transitions and callback order', async () => {
const events: string[] = [];
const fakeError = new Error("boom");
const fakeError = new Error('boom');
const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock",
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push("connect");
events.push('connect');
},
onData: () => {
events.push("data");
events.push('data');
},
onError: () => {
events.push("error");
events.push('error');
},
onClose: () => {
events.push("close");
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -156,35 +154,35 @@ test("MpvSocketTransport reports lifecycle transitions and callback order", asyn
await wait();
const socket = transport.getSocket() as unknown as FakeSocket;
socket.emit("error", fakeError);
socket.emit("data", Buffer.from("{}"));
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(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 () => {
test('MpvSocketTransport ignores connect requests while already connecting or connected', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock",
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push("connect");
events.push('connect');
},
onData: () => {
events.push("data");
events.push('data');
},
onError: () => {
events.push("error");
events.push('error');
},
onClose: () => {
events.push("close");
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -193,20 +191,20 @@ test("MpvSocketTransport ignores connect requests while already connecting or co
transport.connect();
await wait();
assert.equal(events.includes("connect"), true);
assert.equal(events.includes('connect'), true);
const socket = transport.getSocket() as unknown as FakeSocket;
socket.emit("close");
socket.emit('close');
await wait();
transport.connect();
await wait();
assert.equal(events.filter((entry) => entry === "connect").length, 2);
assert.equal(events.filter((entry) => entry === 'connect').length, 2);
});
test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => {
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock",
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},

View File

@@ -1,9 +1,6 @@
import * as net from "net";
import * as net from 'net';
export function getMpvReconnectDelay(
attempt: number,
hasConnectedOnce: boolean,
): number {
export function getMpvReconnectDelay(attempt: number, hasConnectedOnce: boolean): number {
if (hasConnectedOnce) {
if (attempt < 2) {
return 1000;
@@ -111,23 +108,23 @@ export class MpvSocketTransport {
this.socketRef = this.socketFactory();
this.socket = this.socketRef;
this.socketRef.on("connect", () => {
this.socketRef.on('connect', () => {
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
this.socketRef.on("data", (data: Buffer) => {
this.socketRef.on('data', (data: Buffer) => {
this.callbacks.onData(data);
});
this.socketRef.on("error", (error: Error) => {
this.socketRef.on('error', (error: Error) => {
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
this.socketRef.on("close", () => {
this.socketRef.on('close', () => {
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
@@ -141,7 +138,7 @@ export class MpvSocketTransport {
return false;
}
const message = JSON.stringify(payload) + "\n";
const message = JSON.stringify(payload) + '\n';
this.socketRef.write(message);
return true;
}

View File

@@ -1,16 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
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";
} from './mpv';
import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol';
function makeDeps(
overrides: Partial<MpvIpcClientProtocolDeps> = {},
): MpvIpcClientDeps {
function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
return {
getResolvedConfig: () => ({}) as any,
autoStartOverlay: false,
@@ -23,48 +21,45 @@ function makeDeps(
};
}
function invokeHandleMessage(
client: MpvIpcClient,
msg: unknown,
): Promise<void> {
return (
client as unknown as { handleMessage: (msg: unknown) => Promise<void> }
).handleMessage(msg);
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());
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" });
await invokeHandleMessage(client, { request_id: 1234, data: 'ok' });
assert.deepEqual(resolved, { 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 () => {
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) => {
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: "字幕",
event: 'property-change',
name: 'sub-text',
data: '字幕',
});
assert.equal(events.length, 1);
assert.equal(events[0].text, "字幕");
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());
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);
@@ -75,30 +70,27 @@ test("MpvIpcClient parses JSON line protocol in processBuffer", () => {
(client as any).processBuffer();
assert.equal(seen.length, 2);
assert.equal(seen[0].name, "path");
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 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" });
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"),
async () => client.requestProperty('path'),
/Failed to read MPV property 'path': property unavailable/,
);
});
test("MpvIpcClient failPendingRequests resolves outstanding requests as disconnected", () => {
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
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);
@@ -110,16 +102,16 @@ test("MpvIpcClient failPendingRequests resolves outstanding requests as disconne
(client as any).failPendingRequests();
assert.deepEqual(resolved, [
{ request_id: 10, error: "disconnected" },
{ request_id: 11, error: "disconnected" },
{ 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", () => {
test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const client = new MpvIpcClient(
"/tmp/mpv.sock",
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => null,
setReconnectTimer: (timer) => {
@@ -148,12 +140,12 @@ test("MpvIpcClient scheduleReconnect schedules timer and invokes connect", () =>
assert.equal(connectCalled, true);
});
test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
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",
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => existingTimer,
setReconnectTimer: (timer) => {
@@ -173,9 +165,7 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
handler();
return 1 as unknown as ReturnType<typeof setTimeout>;
};
(globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
cleared.push(timer);
};
@@ -192,10 +182,10 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
assert.equal(connectCalled, true);
});
test("MpvIpcClient onClose resolves outstanding requests and schedules reconnect", () => {
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const client = new MpvIpcClient(
"/tmp/mpv.sock",
'/tmp/mpv.sock',
makeDeps({
getReconnectTimer: () => null,
setReconnectTimer: (timer) => {
@@ -227,14 +217,14 @@ test("MpvIpcClient onClose resolves outstanding requests and schedules reconnect
}
assert.equal(resolved.length, 1);
assert.deepEqual(resolved[0], { request_id: 1, error: "disconnected" });
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", () => {
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
@@ -249,23 +239,22 @@ test("MpvIpcClient reconnect replays property subscriptions and initial state re
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",
(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[0] === 'observe_property' &&
(command as { command: unknown[] }).command[1] === 1 &&
(command as { command: unknown[] }).command[2] === "sub-text",
(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",
(command as { command: unknown[] }).command[0] === 'get_property' &&
(command as { command: unknown[] }).command[1] === 'path',
);
assert.equal(hasSecondaryVisibilityReset, true);
@@ -273,11 +262,11 @@ test("MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasPathRequest, true);
});
test("MpvIpcClient captures and disables secondary subtitle visibility on request", async () => {
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const previous: boolean[] = [];
client.on("secondary-subtitle-visibility", ({ visible }) => {
client.on('secondary-subtitle-visibility', ({ visible }) => {
previous.push(visible);
});
@@ -288,22 +277,22 @@ test("MpvIpcClient captures and disables secondary subtitle visibility on reques
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
data: "yes",
data: 'yes',
});
assert.deepEqual(previous, [true]);
assert.deepEqual(commands, [
{
command: ["set_property", "secondary-sub-visibility", "no"],
command: ['set_property', 'secondary-sub-visibility', 'no'],
},
]);
});
test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value", async () => {
test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const previous: boolean[] = [];
client.on("secondary-subtitle-visibility", ({ visible }) => {
client.on('secondary-subtitle-visibility', ({ visible }) => {
previous.push(visible);
});
@@ -314,7 +303,7 @@ test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
data: "yes",
data: 'yes',
});
client.restorePreviousSecondarySubVisibility();
@@ -322,10 +311,10 @@ test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
assert.equal(previous.length, 1);
assert.deepEqual(commands, [
{
command: ["set_property", "secondary-sub-visibility", "no"],
command: ['set_property', 'secondary-sub-visibility', 'no'],
},
{
command: ["set_property", "secondary-sub-visibility", "yes"],
command: ['set_property', 'secondary-sub-visibility', 'yes'],
},
]);
@@ -333,21 +322,21 @@ test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
assert.equal(commands.length, 2);
});
test("MpvIpcClient updates current audio stream index from track list", async () => {
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
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",
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 },
{ 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 },
],
});

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "events";
import { Config, MpvClient, MpvSubtitleRenderMetrics } from "../../types";
import { EventEmitter } from 'events';
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
@@ -7,21 +7,18 @@ import {
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from "./mpv-protocol";
import {
requestMpvInitialState,
subscribeToMpvProperties,
} from "./mpv-properties";
import { scheduleMpvReconnect, MpvSocketTransport } from "./mpv-transport";
import { createLogger } from "../../logger";
} from './mpv-protocol';
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
import { createLogger } from '../../logger';
const logger = createLogger("main:mpv");
const logger = createLogger('main:mpv');
export type MpvTrackProperty = {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
};
export function resolveCurrentAudioStreamIndex(
@@ -32,17 +29,13 @@ export function resolveCurrentAudioStreamIndex(
return null;
}
const audioTracks = tracks.filter((track) => track.type === "audio");
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;
const ffIndex = activeTrack?.['ff-index'];
return typeof ffIndex === 'number' && Number.isInteger(ffIndex) && ffIndex >= 0 ? ffIndex : null;
}
export interface MpvRuntimeClientLike {
@@ -59,22 +52,18 @@ export function showMpvOsdRuntime(
fallbackLog: (text: string) => void = (line) => logger.info(line),
): void {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["show-text", text, "3000"] });
mpvClient.send({ command: ['show-text', text, '3000'] });
return;
}
fallbackLog(`OSD (MPV not connected): ${text}`);
}
export function replayCurrentSubtitleRuntime(
mpvClient: MpvRuntimeClientLike | null,
): void {
export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.replayCurrentSubtitle) return;
mpvClient.replayCurrentSubtitle();
}
export function playNextSubtitleRuntime(
mpvClient: MpvRuntimeClientLike | null,
): void {
export function playNextSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.playNextSubtitle) return;
mpvClient.playNextSubtitle();
}
@@ -95,7 +84,7 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible);
}
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-protocol";
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config;
@@ -110,17 +99,17 @@ export interface MpvIpcClientProtocolDeps {
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 };
'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;
@@ -128,20 +117,20 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport;
public socket: ReturnType<MpvSocketTransport["getSocket"]> = null;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter();
private buffer = "";
private buffer = '';
public connected = false;
private connecting = false;
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
public currentVideoPath = "";
public currentVideoPath = '';
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
public currentSubText = "";
public currentSecondarySubText = "";
public currentSubText = '';
public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null;
private currentAudioTrackId: number | null = null;
private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
@@ -150,13 +139,13 @@ export class MpvIpcClient implements MpvClient {
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: "",
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: "yes",
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
@@ -174,11 +163,11 @@ export class MpvIpcClient implements MpvClient {
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
logger.debug("Connected to MPV socket");
logger.debug('Connected to MPV socket');
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit("connection-change", { connected: true });
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
@@ -186,10 +175,9 @@ export class MpvIpcClient implements MpvClient {
requestMpvInitialState(this.send.bind(this));
const shouldAutoStart =
this.deps.autoStartOverlay ||
this.deps.getResolvedConfig().auto_start_overlay === true;
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
logger.debug("Auto-starting overlay, hiding mpv subtitles");
logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
@@ -204,15 +192,15 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer();
},
onError: (err: Error) => {
logger.debug("MPV socket error:", err.message);
logger.debug('MPV socket error:', err.message);
this.failPendingRequests();
},
onClose: () => {
logger.debug("MPV socket closed");
logger.debug('MPV socket closed');
this.connected = false;
this.connecting = false;
this.socket = null;
this.emit("connection-change", { connected: false });
this.emit('connection-change', { connected: false });
this.failPendingRequests();
this.scheduleReconnect();
},
@@ -240,14 +228,12 @@ export class MpvIpcClient implements MpvClient {
this.eventBus.emit(event as string, payload);
}
private emitSubtitleMetricsChange(
patch: Partial<MpvSubtitleRenderMetrics>,
): void {
private emitSubtitleMetricsChange(patch: Partial<MpvSubtitleRenderMetrics>): void {
this.mpvSubtitleRenderMetrics = {
...this.mpvSubtitleRenderMetrics,
...patch,
};
this.emit("subtitle-metrics-change", { patch });
this.emit('subtitle-metrics-change', { patch });
}
setSocketPath(socketPath: string): void {
@@ -262,7 +248,7 @@ export class MpvIpcClient implements MpvClient {
return;
}
logger.info("MPV IPC connect requested.");
logger.info('MPV IPC connect requested.');
this.connecting = true;
this.transport.connect();
}
@@ -274,9 +260,7 @@ export class MpvIpcClient implements MpvClient {
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)...`,
);
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`);
},
connect: () => {
this.connect();
@@ -291,7 +275,7 @@ export class MpvIpcClient implements MpvClient {
this.handleMessage(message);
},
(line, error) => {
logger.error("Failed to parse MPV message:", line, error);
logger.error('Failed to parse MPV message:', line, error);
},
);
this.buffer = parsed.nextBuffer;
@@ -307,22 +291,22 @@ export class MpvIpcClient implements MpvClient {
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
emitSubtitleChange: (payload) => {
this.emit("subtitle-change", payload);
this.emit('subtitle-change', payload);
},
emitSubtitleAssChange: (payload) => {
this.emit("subtitle-ass-change", payload);
this.emit('subtitle-ass-change', payload);
},
emitSubtitleTiming: (payload) => {
this.emit("subtitle-timing", payload);
this.emit('subtitle-timing', payload);
},
emitTimePosChange: (payload) => {
this.emit("time-pos-change", payload);
this.emit('time-pos-change', payload);
},
emitPauseChange: (payload) => {
this.emit("pause-change", payload);
this.emit('pause-change', payload);
},
emitSecondarySubtitleChange: (payload) => {
this.emit("secondary-subtitle-change", payload);
this.emit('secondary-subtitle-change', payload);
},
getCurrentSubText: () => this.currentSubText,
setCurrentSubText: (text: string) => {
@@ -337,10 +321,10 @@ export class MpvIpcClient implements MpvClient {
},
getCurrentSubEnd: () => this.currentSubEnd,
emitMediaPathChange: (payload) => {
this.emit("media-path-change", payload);
this.emit('media-path-change', payload);
},
emitMediaTitleChange: (payload) => {
this.emit("media-title-change", payload);
this.emit('media-title-change', payload);
},
emitSubtitleMetricsChange: (patch) => {
this.emitSubtitleMetricsChange(patch);
@@ -350,8 +334,7 @@ export class MpvIpcClient implements MpvClient {
},
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
this.tryResolvePendingRequest(requestId, message),
setSecondarySubVisibility: (visible: boolean) =>
this.setSecondarySubVisibility(visible),
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
syncCurrentAudioStreamIndex: () => {
this.syncCurrentAudioStreamIndex();
},
@@ -377,7 +360,7 @@ export class MpvIpcClient implements MpvClient {
this.currentVideoPath = value;
},
emitSecondarySubtitleVisibility: (payload) => {
this.emit("secondary-subtitle-visibility", payload);
this.emit('secondary-subtitle-visibility', payload);
},
setPreviousSecondarySubVisibility: (visible: boolean) => {
this.previousSecondarySubVisibility = visible;
@@ -400,7 +383,7 @@ export class MpvIpcClient implements MpvClient {
setTimeout(() => {
this.send({
command: ["get_property", "track-list"],
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
});
}, 500);
@@ -408,7 +391,7 @@ export class MpvIpcClient implements MpvClient {
private syncCurrentAudioStreamIndex(): void {
this.send({
command: ["get_property", "track-list"],
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
});
}
@@ -418,13 +401,10 @@ export class MpvIpcClient implements MpvClient {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
}>,
): void {
this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(
tracks,
this.currentAudioTrackId,
);
this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(tracks, this.currentAudioTrackId);
}
send(command: { command: unknown[]; request_id?: number }): boolean {
@@ -437,7 +417,7 @@ export class MpvIpcClient implements MpvClient {
request(command: unknown[]): Promise<MpvMessage> {
return new Promise((resolve, reject) => {
if (!this.connected || !this.socket) {
reject(new Error("MPV not connected"));
reject(new Error('MPV not connected'));
return;
}
@@ -446,39 +426,34 @@ export class MpvIpcClient implements MpvClient {
const sent = this.send({ command, request_id: requestId });
if (!sent) {
this.pendingRequests.delete(requestId);
reject(new Error("Failed to send MPV request"));
reject(new Error('Failed to send MPV request'));
return;
}
setTimeout(() => {
if (this.pendingRequests.delete(requestId)) {
reject(new Error("MPV request timed out"));
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}`,
);
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" });
resolve({ request_id: requestId, error: 'disconnected' });
}
this.pendingRequests.clear();
}
private tryResolvePendingRequest(
requestId: number,
message: MpvMessage,
): boolean {
private tryResolvePendingRequest(requestId: number, message: MpvMessage): boolean {
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return false;
@@ -490,40 +465,32 @@ export class MpvIpcClient implements MpvClient {
setSubVisibility(visible: boolean): void {
this.send({
command: ["set_property", "sub-visibility", visible ? "yes" : "no"],
command: ['set_property', 'sub-visibility', visible ? 'yes' : 'no'],
});
}
replayCurrentSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 0] });
this.send({ command: ['sub-seek', 0] });
}
playNextSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 1] });
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",
],
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",
],
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
});
}
}

View File

@@ -1,11 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createNumericShortcutRuntime,
createNumericShortcutSession,
} from "./numeric-shortcut";
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut';
test("createNumericShortcutRuntime creates sessions wired to globalShortcut", () => {
test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => {
const registered: string[] = [];
const unregistered: string[] = [];
const osd: string[] = [];
@@ -35,22 +32,22 @@ test("createNumericShortcutRuntime creates sessions wired to globalShortcut", ()
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: "Select count",
timeout: "Timed out",
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");
assert.ok(registered.includes('1'));
assert.ok(registered.includes('Escape'));
assert.equal(osd[0], 'Select count');
handlers.get("Escape")?.();
handlers.get('Escape')?.();
assert.equal(session.isActive(), false);
assert.ok(unregistered.includes("Escape"));
assert.ok(unregistered.includes('Escape'));
});
test("numeric shortcut session handles digit selection and unregisters shortcuts", () => {
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
const handlers = new Map<string, () => void>();
const unregistered: string[] = [];
const osd: string[] = [];
@@ -77,24 +74,24 @@ test("numeric shortcut session handles digit selection and unregisters shortcuts
digits.push(digit);
},
messages: {
prompt: "Pick a digit",
timeout: "Timed out",
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.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"));
assert.ok(unregistered.includes('Escape'));
assert.ok(unregistered.includes('1'));
assert.ok(unregistered.includes('9'));
});
test("numeric shortcut session emits timeout message", () => {
test('numeric shortcut session emits timeout message', () => {
const osd: string[] = [];
const session = createNumericShortcutSession({
registerShortcut: () => true,
@@ -113,17 +110,17 @@ test("numeric shortcut session emits timeout message", () => {
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: "Pick a digit",
timeout: "Timed out",
cancelled: "Aborted",
prompt: 'Pick a digit',
timeout: 'Timed out',
cancelled: 'Aborted',
},
});
assert.equal(session.isActive(), false);
assert.ok(osd.includes("Timed out"));
assert.ok(osd.includes('Timed out'));
});
test("numeric shortcut session handles escape cancellation", () => {
test('numeric shortcut session handles escape cancellation', () => {
const handlers = new Map<string, () => void>();
const osd: string[] = [];
const session = createNumericShortcutSession({
@@ -145,12 +142,12 @@ test("numeric shortcut session handles escape cancellation", () => {
timeoutMs: 5000,
onDigit: () => {},
messages: {
prompt: "Pick a digit",
timeout: "Timed out",
cancelled: "Aborted",
prompt: 'Pick a digit',
timeout: 'Timed out',
cancelled: 'Aborted',
},
});
handlers.get("Escape")?.();
handlers.get('Escape')?.();
assert.equal(session.isActive(), false);
assert.ok(osd.includes("Aborted"));
assert.ok(osd.includes('Aborted'));
});

View File

@@ -6,22 +6,16 @@ interface GlobalShortcutLike {
export interface NumericShortcutRuntimeOptions {
globalShortcut: GlobalShortcutLike;
showMpvOsd: (text: string) => void;
setTimer: (
handler: () => void,
timeoutMs: number,
) => ReturnType<typeof setTimeout>;
setTimer: (handler: () => void, timeoutMs: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
}
export function createNumericShortcutRuntime(
options: NumericShortcutRuntimeOptions,
) {
export function createNumericShortcutRuntime(options: NumericShortcutRuntimeOptions) {
const createSession = () =>
createNumericShortcutSession({
registerShortcut: (accelerator, handler) =>
options.globalShortcut.register(accelerator, handler),
unregisterShortcut: (accelerator) =>
options.globalShortcut.unregister(accelerator),
unregisterShortcut: (accelerator) => options.globalShortcut.unregister(accelerator),
setTimer: options.setTimer,
clearTimer: options.clearTimer,
showMpvOsd: options.showMpvOsd,
@@ -41,10 +35,7 @@ export interface NumericShortcutSessionMessages {
export interface NumericShortcutSessionDeps {
registerShortcut: (accelerator: string, handler: () => void) => boolean;
unregisterShortcut: (accelerator: string) => void;
setTimer: (
handler: () => void,
timeoutMs: number,
) => ReturnType<typeof setTimeout>;
setTimer: (handler: () => void, timeoutMs: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
showMpvOsd: (text: string) => void;
}
@@ -61,7 +52,7 @@ export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) {
let digitShortcuts: string[] = [];
let escapeShortcut: string | null = null;
let cancelledMessage = "Cancelled";
let cancelledMessage = 'Cancelled';
const cancel = (showCancelled = false): void => {
if (!active) return;
@@ -87,13 +78,9 @@ export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) {
}
};
const start = ({
timeoutMs,
onDigit,
messages,
}: NumericShortcutSessionStartParams): void => {
const start = ({ timeoutMs, onDigit, messages }: NumericShortcutSessionStartParams): void => {
cancel();
cancelledMessage = messages.cancelled ?? "Cancelled";
cancelledMessage = messages.cancelled ?? 'Cancelled';
active = true;
for (let i = 1; i <= 9; i++) {
@@ -110,11 +97,11 @@ export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) {
}
if (
deps.registerShortcut("Escape", () => {
deps.registerShortcut('Escape', () => {
cancel(true);
})
) {
escapeShortcut = "Escape";
escapeShortcut = 'Escape';
}
timeout = deps.setTimer(() => {

View File

@@ -1,14 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { KikuFieldGroupingChoice } from "../../types";
import {
createFieldGroupingCallbackRuntime,
sendToVisibleOverlayRuntime,
} from "./overlay-bridge";
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", () => {
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
const sent: unknown[][] = [];
const restoreSet = new Set<"runtime-options" | "subsync">();
const restoreSet = new Set<'runtime-options' | 'subsync'>();
let visibleOverlayVisible = false;
const ok = sendToVisibleOverlayRuntime({
@@ -25,22 +22,20 @@ test("sendToVisibleOverlayRuntime restores visibility flag when opening hidden o
setVisibleOverlayVisible: (visible: boolean) => {
visibleOverlayVisible = visible;
},
channel: "runtime-options:open",
restoreOnModalClose: "runtime-options",
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"]]);
assert.equal(restoreSet.has('runtime-options'), true);
assert.deepEqual(sent, [['runtime-options:open']]);
});
test("createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent", async () => {
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const callback = createFieldGroupingCallbackRuntime<
"runtime-options" | "subsync"
>({
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
@@ -55,16 +50,16 @@ test("createFieldGroupingCallbackRuntime cancels when overlay request cannot be
const result = await callback({
original: {
noteId: 1,
expression: "a",
sentencePreview: "a",
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: "b",
sentencePreview: "b",
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,

View File

@@ -1,9 +1,6 @@
import {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
import { createFieldGroupingCallback } from "./field-grouping";
import { BrowserWindow } from "electron";
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallback } from './field-grouping';
import { BrowserWindow } from 'electron';
export function sendToVisibleOverlayRuntime<T extends string>(options: {
mainWindow: BrowserWindow | null;
@@ -30,7 +27,7 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
}
};
if (options.mainWindow.webContents.isLoading()) {
options.mainWindow.webContents.once("did-finish-load", () => {
options.mainWindow.webContents.once('did-finish-load', () => {
if (
options.mainWindow &&
!options.mainWindow.isDestroyed() &&
@@ -51,9 +48,7 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
@@ -68,6 +63,6 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getResolver: options.getResolver,
setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) =>
options.sendToVisibleOverlay("kiku:field-grouping-request", data),
options.sendToVisibleOverlay('kiku:field-grouping-request', data),
});
}

View File

@@ -1,15 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOverlayContentMeasurementStore,
sanitizeOverlayContentMeasurement,
} from "./overlay-content-measurement";
} from './overlay-content-measurement';
test("sanitizeOverlayContentMeasurement accepts valid payload with null rect", () => {
test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: "visible",
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
@@ -18,17 +18,17 @@ test("sanitizeOverlayContentMeasurement accepts valid payload with null rect", (
);
assert.deepEqual(measurement, {
layer: "visible",
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: null,
});
});
test("sanitizeOverlayContentMeasurement rejects invalid ranges", () => {
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: "invisible",
layer: 'invisible',
measuredAtMs: 100,
viewport: { width: 0, height: 1080 },
contentRect: { x: 0, y: 0, width: 100, height: 20 },
@@ -39,7 +39,7 @@ test("sanitizeOverlayContentMeasurement rejects invalid ranges", () => {
assert.equal(measurement, null);
});
test("overlay measurement store keeps latest payload per layer", () => {
test('overlay measurement store keeps latest payload per layer', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
@@ -48,25 +48,25 @@ test("overlay measurement store keeps latest payload per layer", () => {
});
const visible = store.report({
layer: "visible",
layer: 'visible',
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
});
const invisible = store.report({
layer: "invisible",
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);
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", () => {
test('overlay measurement store rate-limits invalid payload warnings', () => {
let now = 1_000;
const warnings: string[] = [];
const store = createOverlayContentMeasurementStore({
@@ -76,12 +76,12 @@ test("overlay measurement store rate-limits invalid payload warnings", () => {
},
});
store.report({ layer: "visible" });
store.report({ layer: "visible" });
store.report({ layer: 'visible' });
store.report({ layer: 'visible' });
assert.equal(warnings.length, 0);
now = 11_000;
store.report({ layer: "visible" });
store.report({ layer: 'visible' });
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Dropped 3 invalid measurement payload/);
});

View File

@@ -1,27 +1,20 @@
import {
OverlayContentMeasurement,
OverlayContentRect,
OverlayLayer,
} from "../../types";
import { createLogger } from "../../logger";
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger("main:overlay-content-measurement");
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
>;
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>;
export function sanitizeOverlayContentMeasurement(
payload: unknown,
nowMs: number,
): OverlayContentMeasurement | null {
if (!payload || typeof payload !== "object") return null;
if (!payload || typeof payload !== 'object') return null;
const candidate = payload as {
layer?: unknown;
@@ -35,20 +28,12 @@ export function sanitizeOverlayContentMeasurement(
} | null;
};
if (candidate.layer !== "visible" && candidate.layer !== "invisible") {
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,
);
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;
@@ -81,7 +66,7 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
return null;
}
if (!rect || typeof rect !== "object") {
if (!rect || typeof rect !== 'object') {
return null;
}
@@ -110,7 +95,7 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
}
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
}
if (value < min || value > max) {
@@ -138,10 +123,7 @@ export function createOverlayContentMeasurementStore(options?: {
const measurement = sanitizeOverlayContentMeasurement(payload, nowMs);
if (!measurement) {
droppedInvalid += 1;
if (
droppedInvalid > 0 &&
nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS
) {
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.`,
);
@@ -155,9 +137,7 @@ export function createOverlayContentMeasurementStore(options?: {
return measurement;
}
function getLatestByLayer(
layer: OverlayLayer,
): OverlayContentMeasurement | null {
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null {
return latestByLayer[layer];
}

View File

@@ -1,12 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} from "./overlay-manager";
} from './overlay-manager';
test("overlay manager initializes with empty windows and hidden overlays", () => {
test('overlay manager initializes with empty windows and hidden overlays', () => {
const manager = createOverlayManager();
assert.equal(manager.getMainWindow(), null);
assert.equal(manager.getInvisibleWindow(), null);
@@ -15,7 +15,7 @@ test("overlay manager initializes with empty windows and hidden overlays", () =>
assert.deepEqual(manager.getOverlayWindows(), []);
});
test("overlay manager stores window references and returns stable window order", () => {
test('overlay manager stores window references and returns stable window order', () => {
const manager = createOverlayManager();
const visibleWindow = {
isDestroyed: () => false,
@@ -29,15 +29,12 @@ test("overlay manager stores window references and returns stable window order",
assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getOverlayWindow("visible"), visibleWindow);
assert.equal(manager.getOverlayWindow("invisible"), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [
visibleWindow,
invisibleWindow,
]);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]);
});
test("overlay manager excludes destroyed windows", () => {
test('overlay manager excludes destroyed windows', () => {
const manager = createOverlayManager();
manager.setMainWindow({
isDestroyed: () => true,
@@ -49,7 +46,7 @@ test("overlay manager excludes destroyed windows", () => {
assert.equal(manager.getOverlayWindows().length, 1);
});
test("overlay manager stores visibility state", () => {
test('overlay manager stores visibility state', () => {
const manager = createOverlayManager();
manager.setVisibleOverlayVisible(true);
@@ -58,7 +55,7 @@ test("overlay manager stores visibility state", () => {
assert.equal(manager.getInvisibleOverlayVisible(), true);
});
test("overlay manager broadcasts to non-destroyed windows", () => {
test('overlay manager broadcasts to non-destroyed windows', () => {
const manager = createOverlayManager();
const calls: unknown[][] = [];
const aliveWindow = {
@@ -78,12 +75,12 @@ test("overlay manager broadcasts to non-destroyed windows", () => {
manager.setMainWindow(aliveWindow);
manager.setInvisibleWindow(deadWindow);
manager.broadcastToOverlayWindows("x", 1, "a");
manager.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [["x", 1, "a"]]);
assert.deepEqual(calls, [['x', 1, 'a']]);
});
test("overlay manager applies bounds by layer", () => {
test('overlay manager applies bounds by layer', () => {
const manager = createOverlayManager();
const visibleCalls: Electron.Rectangle[] = [];
const invisibleCalls: Electron.Rectangle[] = [];
@@ -102,13 +99,13 @@ test("overlay manager applies bounds by layer", () => {
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setOverlayWindowBounds("visible", {
manager.setOverlayWindowBounds('visible', {
x: 10,
y: 20,
width: 30,
height: 40,
});
manager.setOverlayWindowBounds("invisible", {
manager.setOverlayWindowBounds('invisible', {
x: 1,
y: 2,
width: 3,
@@ -119,7 +116,7 @@ test("overlay manager applies bounds by layer", () => {
assert.deepEqual(invisibleCalls, [{ x: 1, y: 2, width: 3, height: 4 }]);
});
test("runtime-option and debug broadcasts use expected channels", () => {
test('runtime-option and debug broadcasts use expected channels', () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime(
() => [],
@@ -141,7 +138,7 @@ test("runtime-option and debug broadcasts use expected channels", () => {
assert.equal(changed, true);
assert.equal(state, true);
assert.deepEqual(broadcasts, [
["runtime-options:changed", []],
["overlay-debug-visualization:set", true],
['runtime-options:changed', []],
['overlay-debug-visualization:set', true],
]);
});

View File

@@ -1,8 +1,8 @@
import { BrowserWindow } from "electron";
import { RuntimeOptionState, WindowGeometry } from "../../types";
import { updateOverlayWindowBounds } from "./overlay-window";
import { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
type OverlayLayer = "visible" | "invisible";
type OverlayLayer = 'visible' | 'invisible';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
@@ -10,10 +10,7 @@ export interface OverlayManager {
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (
layer: OverlayLayer,
geometry: WindowGeometry,
) => void;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
@@ -37,13 +34,9 @@ export function createOverlayManager(): OverlayManager {
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getOverlayWindow: (layer) =>
layer === "visible" ? mainWindow : invisibleWindow,
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBounds(
geometry,
layer === "visible" ? mainWindow : invisibleWindow,
);
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
@@ -82,10 +75,7 @@ export function broadcastRuntimeOptionsChangedRuntime(
getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): void {
broadcastToOverlayWindows(
"runtime-options:changed",
getRuntimeOptionsState(),
);
broadcastToOverlayWindows('runtime-options:changed', getRuntimeOptionsState());
}
export function setOverlayDebugVisualizationEnabledRuntime(
@@ -96,6 +86,6 @@ export function setOverlayDebugVisualizationEnabledRuntime(
): boolean {
if (currentEnabled === nextEnabled) return false;
setState(nextEnabled);
broadcastToOverlayWindows("overlay-debug-visualization:set", nextEnabled);
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
return true;
}

View File

@@ -1,12 +1,12 @@
import { BrowserWindow } from "electron";
import { AnkiIntegration } from "../../anki-integration";
import { BaseWindowTracker, createWindowTracker } from "../../window-trackers";
import { BrowserWindow } from 'electron';
import { AnkiIntegration } from '../../anki-integration';
import { BaseWindowTracker, createWindowTracker } from '../../window-trackers';
import {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from "../../types";
} from '../../types';
export function initializeOverlayRuntime(options: {
backendOverride: string | null;
@@ -30,15 +30,10 @@ export function initializeOverlayRuntime(options: {
send?: (payload: { command: string[] }) => void;
} | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (
config?: AnkiConnectConfig,
) => AnkiConnectConfig;
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (
title: string,
options: { body?: string; icon?: string },
) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -48,14 +43,10 @@ export function initializeOverlayRuntime(options: {
} {
options.createMainWindow();
options.createInvisibleWindow();
const invisibleOverlayVisible =
options.getInitialInvisibleOverlayVisibility();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(
options.backendOverride,
options.getMpvSocketPath(),
);
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
@@ -86,22 +77,18 @@ export function initializeOverlayRuntime(options: {
const mpvClient = options.getMpvClient();
const runtimeOptionsManager = options.getRuntimeOptionsManager();
if (
config.ankiConnect &&
subtitleTimingTracker &&
mpvClient &&
runtimeOptionsManager
) {
const effectiveAnkiConfig =
runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect);
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") {
if (mpvClient && typeof mpvClient.send === 'function') {
mpvClient.send({
command: ["show-text", text, "3000"],
command: ['show-text', text, '3000'],
});
}
},

View File

@@ -1,15 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ConfiguredShortcuts } from "../utils/shortcut-config";
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";
} from './overlay-shortcut-handler';
function makeShortcuts(
overrides: Partial<ConfiguredShortcuts> = {},
): ConfiguredShortcuts {
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
@@ -37,34 +35,34 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
osd.push(text);
},
openRuntimeOptions: () => {
calls.push("openRuntimeOptions");
calls.push('openRuntimeOptions');
},
openJimaku: () => {
calls.push("openJimaku");
calls.push('openJimaku');
},
markAudioCard: async () => {
calls.push("markAudioCard");
calls.push('markAudioCard');
},
copySubtitleMultiple: (timeoutMs) => {
calls.push(`copySubtitleMultiple:${timeoutMs}`);
},
copySubtitle: () => {
calls.push("copySubtitle");
calls.push('copySubtitle');
},
toggleSecondarySub: () => {
calls.push("toggleSecondarySub");
calls.push('toggleSecondarySub');
},
updateLastCardFromClipboard: async () => {
calls.push("updateLastCardFromClipboard");
calls.push('updateLastCardFromClipboard');
},
triggerFieldGrouping: async () => {
calls.push("triggerFieldGrouping");
calls.push('triggerFieldGrouping');
},
triggerSubsync: async () => {
calls.push("triggerSubsync");
calls.push('triggerSubsync');
},
mineSentence: async () => {
calls.push("mineSentence");
calls.push('mineSentence');
},
mineSentenceMultiple: (timeoutMs) => {
calls.push(`mineSentenceMultiple:${timeoutMs}`);
@@ -75,10 +73,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
return { deps, calls, osd };
}
test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => {
test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', async () => {
const { deps, calls } = createDeps();
const { overlayHandlers, fallbackHandlers } =
createOverlayShortcutRuntimeHandlers(deps);
const { overlayHandlers, fallbackHandlers } = createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.copySubtitle();
overlayHandlers.copySubtitleMultiple(1111);
@@ -91,18 +88,18 @@ test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers",
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
"copySubtitle",
"copySubtitleMultiple:1111",
"toggleSecondarySub",
"openRuntimeOptions",
"openJimaku",
"mineSentenceMultiple:2222",
"updateLastCardFromClipboard",
"mineSentence",
'copySubtitle',
'copySubtitleMultiple:1111',
'toggleSecondarySub',
'openRuntimeOptions',
'openJimaku',
'mineSentenceMultiple:2222',
'updateLastCardFromClipboard',
'mineSentence',
]);
});
test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => {
test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => {
const logs: unknown[][] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
@@ -112,7 +109,7 @@ test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", asyn
try {
const { deps, osd } = createDeps({
markAudioCard: async () => {
throw new Error("audio boom");
throw new Error('audio boom');
},
});
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
@@ -121,23 +118,20 @@ test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", asyn
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")),
);
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", () => {
test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => {
const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> =
[];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: "Ctrl+M",
copySubtitleMultiple: 'Ctrl+M',
multiCopyTimeoutMs: 4321,
});
@@ -149,38 +143,32 @@ test("runOverlayShortcutLocalFallback dispatches matching actions with timeout",
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+M";
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}`),
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 },
]);
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 }> =
[];
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
toggleSecondarySub: "Ctrl+2",
toggleSecondarySub: 'Ctrl+2',
});
const result = runOverlayShortcutLocalFallback(
@@ -191,7 +179,7 @@ test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+2";
return accelerator === 'Ctrl+2';
},
{
openRuntimeOptions: () => {},
@@ -209,16 +197,13 @@ test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
);
assert.equal(result, true);
assert.deepEqual(matched, [
{ accelerator: "Ctrl+2", allowWhenRegistered: true },
]);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> =
[];
test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
openJimaku: "Ctrl+J",
openJimaku: 'Ctrl+J',
});
const result = runOverlayShortcutLocalFallback(
@@ -229,7 +214,7 @@ test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut",
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+J";
return accelerator === 'Ctrl+J';
},
{
openRuntimeOptions: () => {},
@@ -247,57 +232,50 @@ test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut",
);
assert.equal(result, true);
assert.deepEqual(matched, [
{ accelerator: "Ctrl+J", allowWhenRegistered: true },
]);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
test('runOverlayShortcutLocalFallback returns false when no action matches', () => {
const shortcuts = makeShortcuts({
copySubtitle: "Ctrl+C",
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;
},
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

@@ -1,8 +1,8 @@
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import { OverlayShortcutHandlers } from "./overlay-shortcut";
import { createLogger } from "../../logger";
import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { OverlayShortcutHandlers } from './overlay-shortcut';
import { createLogger } from '../../logger';
const logger = createLogger("main:overlay-shortcut-handler");
const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
@@ -47,9 +47,7 @@ function wrapAsync(
};
}
export function createOverlayShortcutRuntimeHandlers(
deps: OverlayShortcutRuntimeDeps,
): {
export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntimeDeps): {
overlayHandlers: OverlayShortcutHandlers;
fallbackHandlers: OverlayShortcutFallbackHandlers;
} {
@@ -63,26 +61,26 @@ export function createOverlayShortcutRuntimeHandlers(
updateLastCardFromClipboard: wrapAsync(
() => deps.updateLastCardFromClipboard(),
deps,
"updateLastCardFromClipboard",
"Update failed",
'updateLastCardFromClipboard',
'Update failed',
),
triggerFieldGrouping: wrapAsync(
() => deps.triggerFieldGrouping(),
deps,
"triggerFieldGrouping",
"Field grouping failed",
'triggerFieldGrouping',
'Field grouping failed',
),
triggerSubsync: wrapAsync(
() => deps.triggerSubsync(),
deps,
"triggerSubsyncFromConfig",
"Subsync failed",
'triggerSubsyncFromConfig',
'Subsync failed',
),
mineSentence: wrapAsync(
() => deps.mineSentence(),
deps,
"mineSentenceCard",
"Mine sentence failed",
'mineSentenceCard',
'Mine sentence failed',
),
mineSentenceMultiple: (timeoutMs) => {
deps.mineSentenceMultiple(timeoutMs);
@@ -91,8 +89,8 @@ export function createOverlayShortcutRuntimeHandlers(
markAudioCard: wrapAsync(
() => deps.markAudioCard(),
deps,
"markLastCardAsAudioCard",
"Audio card failed",
'markLastCardAsAudioCard',
'Audio card failed',
),
openRuntimeOptions: () => {
deps.openRuntimeOptions();
@@ -122,11 +120,7 @@ export function createOverlayShortcutRuntimeHandlers(
export function runOverlayShortcutLocalFallback(
input: Electron.Input,
shortcuts: ConfiguredShortcuts,
matcher: (
input: Electron.Input,
accelerator: string,
allowWhenRegistered?: boolean,
) => boolean,
matcher: (input: Electron.Input, accelerator: string, allowWhenRegistered?: boolean) => boolean,
handlers: OverlayShortcutFallbackHandlers,
): boolean {
const actions: Array<{
@@ -204,9 +198,7 @@ export function runOverlayShortcutLocalFallback(
for (const action of actions) {
if (!action.accelerator) continue;
if (
matcher(input, action.accelerator, action.allowWhenRegistered === true)
) {
if (matcher(input, action.accelerator, action.allowWhenRegistered === true)) {
action.run();
return true;
}

View File

@@ -1,9 +1,9 @@
import { globalShortcut } from "electron";
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import { isGlobalShortcutRegisteredSafe } from "./shortcut-fallback";
import { createLogger } from "../../logger";
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");
const logger = createLogger('main:overlay-shortcut-service');
export interface OverlayShortcutHandlers {
copySubtitle: () => void;
@@ -42,9 +42,7 @@ export function registerOverlayShortcuts(
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
logger.warn(
`Failed to register overlay shortcut ${label}: ${accelerator}`,
);
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
return;
}
registeredAny = true;
@@ -54,23 +52,19 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.copySubtitleMultiple,
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
"copySubtitleMultiple",
'copySubtitleMultiple',
);
}
if (shortcuts.copySubtitle) {
registerOverlayShortcut(
shortcuts.copySubtitle,
() => handlers.copySubtitle(),
"copySubtitle",
);
registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle');
}
if (shortcuts.triggerFieldGrouping) {
registerOverlayShortcut(
shortcuts.triggerFieldGrouping,
() => handlers.triggerFieldGrouping(),
"triggerFieldGrouping",
'triggerFieldGrouping',
);
}
@@ -78,23 +72,19 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.triggerSubsync,
() => handlers.triggerSubsync(),
"triggerSubsync",
'triggerSubsync',
);
}
if (shortcuts.mineSentence) {
registerOverlayShortcut(
shortcuts.mineSentence,
() => handlers.mineSentence(),
"mineSentence",
);
registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence');
}
if (shortcuts.mineSentenceMultiple) {
registerOverlayShortcut(
shortcuts.mineSentenceMultiple,
() => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs),
"mineSentenceMultiple",
'mineSentenceMultiple',
);
}
@@ -102,7 +92,7 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.toggleSecondarySub,
() => handlers.toggleSecondarySub(),
"toggleSecondarySub",
'toggleSecondarySub',
);
}
@@ -110,7 +100,7 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.updateLastCardFromClipboard,
() => handlers.updateLastCardFromClipboard(),
"updateLastCardFromClipboard",
'updateLastCardFromClipboard',
);
}
@@ -118,7 +108,7 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.markAudioCard,
() => handlers.markAudioCard(),
"markAudioCard",
'markAudioCard',
);
}
@@ -126,23 +116,17 @@ export function registerOverlayShortcuts(
registerOverlayShortcut(
shortcuts.openRuntimeOptions,
() => handlers.openRuntimeOptions(),
"openRuntimeOptions",
'openRuntimeOptions',
);
}
if (shortcuts.openJimaku) {
registerOverlayShortcut(
shortcuts.openJimaku,
() => handlers.openJimaku(),
"openJimaku",
);
registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku');
}
return registeredAny;
}
export function unregisterOverlayShortcuts(
shortcuts: ConfiguredShortcuts,
): void {
export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void {
if (shortcuts.copySubtitle) {
globalShortcut.unregister(shortcuts.copySubtitle);
}
@@ -178,13 +162,8 @@ export function unregisterOverlayShortcuts(
}
}
export function registerOverlayShortcutsRuntime(
deps: OverlayShortcutLifecycleDeps,
): boolean {
return registerOverlayShortcuts(
deps.getConfiguredShortcuts(),
deps.getOverlayHandlers(),
);
export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean {
return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers());
}
export function unregisterOverlayShortcutsRuntime(

View File

@@ -1,6 +1,6 @@
import { BrowserWindow, screen } from "electron";
import { BaseWindowTracker } from "../../window-trackers";
import { WindowGeometry } from "../../types";
import { BrowserWindow, screen } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
@@ -88,7 +88,7 @@ export function updateInvisibleOverlayVisibility(args: {
const showInvisibleWithoutFocus = (): void => {
args.ensureOverlayWindowLevel(args.invisibleWindow!);
if (typeof args.invisibleWindow!.showInactive === "function") {
if (typeof args.invisibleWindow!.showInactive === 'function') {
args.invisibleWindow!.showInactive();
} else {
args.invisibleWindow!.show();
@@ -159,10 +159,7 @@ export function setVisibleOverlayVisible(options: {
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (
options.shouldBindVisibleOverlayToMpvSubVisibility() &&
options.isMpvConnected()
) {
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
options.setMpvSubVisibility(!options.visible);
}
}

View File

@@ -1,11 +1,11 @@
import { BrowserWindow } from "electron";
import * as path from "path";
import { WindowGeometry } from "../../types";
import { createLogger } from "../../logger";
import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger("main:overlay-window");
const logger = createLogger('main:overlay-window');
export type OverlayWindowKind = "visible" | "invisible";
export type OverlayWindowKind = 'visible' | 'invisible';
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
@@ -21,8 +21,8 @@ export function updateOverlayWindowBounds(
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === "darwin") {
window.setAlwaysOnTop(true, "screen-saver", 1);
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
window.setFullScreenable(false);
return;
@@ -37,8 +37,7 @@ export function enforceOverlayLayerOrder(options: {
invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible)
return;
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
@@ -73,7 +72,7 @@ export function createOverlayWindow(
hasShadow: false,
focusable: true,
webPreferences: {
preload: path.join(__dirname, "..", "..", "preload.js"),
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
@@ -83,46 +82,38 @@ export function createOverlayWindow(
options.ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, "..", "..", "renderer", "index.html");
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
window
.loadFile(htmlPath, {
query: { layer: kind === "visible" ? "visible" : "invisible" },
query: { layer: kind === 'visible' ? 'visible' : 'invisible' },
})
.catch((err) => {
logger.error("Failed to load HTML file:", 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-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
});
window.webContents.on("did-finish-load", () => {
window.webContents.on('did-finish-load', () => {
options.onRuntimeOptionsChanged();
window.webContents.send(
"overlay-debug-visualization:set",
'overlay-debug-visualization:set',
options.overlayDebugVisualizationEnabled,
);
});
if (kind === "visible") {
window.webContents.on("devtools-opened", () => {
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {
options.setOverlayDebugVisualizationEnabled(true);
});
window.webContents.on("devtools-closed", () => {
window.webContents.on('devtools-closed', () => {
options.setOverlayDebugVisualizationEnabled(false);
});
}
window.webContents.on("before-input-event", (event, input) => {
window.webContents.on('before-input-event', (event, input) => {
if (!options.isOverlayVisible(kind)) return;
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
@@ -130,18 +121,18 @@ export function createOverlayWindow(
window.hide();
window.on("closed", () => {
window.on('closed', () => {
options.onWindowClosed(kind);
});
window.on("blur", () => {
window.on('blur', () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
}
});
if (options.isDev && kind === "visible") {
window.webContents.openDevTools({ mode: "detach" });
if (options.isDev && kind === 'visible') {
window.webContents.openDevTools({ mode: 'detach' });
}
return window;

View File

@@ -1,17 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility,
} from "./startup";
} from './startup';
const BASE_CONFIG = {
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: "platform-default" as const,
startupVisibility: 'platform-default' as const,
},
ankiConnect: {
behavior: {
@@ -20,36 +20,27 @@ const BASE_CONFIG = {
},
};
test("getInitialInvisibleOverlayVisibility handles visibility + platform", () => {
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => {
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: "visible" } },
"linux",
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } },
'linux',
),
true,
);
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: "hidden" } },
"darwin",
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } },
'darwin',
),
false,
);
assert.equal(
getInitialInvisibleOverlayVisibility(BASE_CONFIG, "linux"),
false,
);
assert.equal(
getInitialInvisibleOverlayVisibility(BASE_CONFIG, "darwin"),
true,
);
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,
);
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => {
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
@@ -60,13 +51,13 @@ test("shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visib
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
invisibleOverlay: { startupVisibility: "visible" },
invisibleOverlay: { startupVisibility: 'visible' },
}),
true,
);
});
test("shouldBindVisibleOverlayToMpvSubVisibility returns config value", () => {
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {
assert.equal(shouldBindVisibleOverlayToMpvSubVisibility(BASE_CONFIG), true);
assert.equal(
shouldBindVisibleOverlayToMpvSubVisibility({
@@ -77,7 +68,7 @@ test("shouldBindVisibleOverlayToMpvSubVisibility returns config value", () => {
);
});
test("isAutoUpdateEnabledRuntime prefers runtime option and falls back to config", () => {
test('isAutoUpdateEnabledRuntime prefers runtime option and falls back to config', () => {
assert.equal(
isAutoUpdateEnabledRuntime(BASE_CONFIG, {
getOptionValue: () => false,

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SecondarySubMode } from "../../types";
import { cycleSecondarySubMode } from "./subtitle-position";
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";
test('cycleSecondarySubMode cycles and emits broadcast + OSD', () => {
let mode: SecondarySubMode = 'hover';
let lastToggleAt = 0;
const broadcasts: SecondarySubMode[] = [];
const osd: string[] = [];
@@ -27,14 +27,14 @@ test("cycleSecondarySubMode cycles and emits broadcast + OSD", () => {
now: () => 1000,
});
assert.equal(mode, "hidden");
assert.deepEqual(broadcasts, ["hidden"]);
assert.deepEqual(osd, ["Secondary subtitle: hidden"]);
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";
test('cycleSecondarySubMode obeys debounce window', () => {
let mode: SecondarySubMode = 'visible';
let lastToggleAt = 950;
let broadcasted = false;
let osdShown = false;
@@ -57,7 +57,7 @@ test("cycleSecondarySubMode obeys debounce window", () => {
now: () => 1000,
});
assert.equal(mode, "visible");
assert.equal(mode, 'visible');
assert.equal(lastToggleAt, 950);
assert.equal(broadcasted, false);
assert.equal(osdShown, false);

View File

@@ -1,4 +1,4 @@
import { globalShortcut } from "electron";
import { globalShortcut } from 'electron';
export function isGlobalShortcutRegisteredSafe(accelerator: string): boolean {
try {
@@ -13,58 +13,50 @@ export function shortcutMatchesInputForLocalFallback(
accelerator: string,
allowWhenRegistered = false,
): boolean {
if (input.type !== "keyDown" || input.isAutoRepeat) return false;
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")
.replace(/\s+/g, '')
.replace(/cmdorctrl/gi, 'CommandOrControl')
.toLowerCase();
const parts = normalized.split("+").filter(Boolean);
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",
]);
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();
const inputKey = (input.key || '').toLowerCase();
if (keyToken.length === 1) {
if (inputKey !== keyToken) return false;
} else if (keyToken.startsWith("key") && keyToken.length === 4) {
} 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");
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);
process.platform === 'darwin' ? Boolean(input.meta || input.control) : Boolean(input.control);
if (!hasCmdOrCtrl) return false;
} else {
if (process.platform === "darwin") {
if (process.platform === 'darwin') {
if (input.meta || input.control) return false;
} else if (!expectedControl && input.control) {
return false;
@@ -72,16 +64,12 @@ export function shortcutMatchesInputForLocalFallback(
}
if (expectedMeta && !input.meta) return false;
if (!expectedMeta && modifierTokens.has("meta") === false && input.meta) {
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 (!expectedControl && modifierTokens.has('control') === false && input.control) {
if (!expectedCommandOrControl) return false;
}

View File

@@ -1,7 +1,7 @@
import { BrowserWindow, globalShortcut } from "electron";
import { createLogger } from "../../logger";
import { BrowserWindow, globalShortcut } from 'electron';
import { createLogger } from '../../logger';
const logger = createLogger("main:shortcut");
const logger = createLogger('main:shortcut');
export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined;
@@ -19,27 +19,18 @@ export interface RegisterGlobalShortcutsServiceOptions {
getMainWindow: () => BrowserWindow | null;
}
export function registerGlobalShortcuts(
options: RegisterGlobalShortcutsServiceOptions,
): void {
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";
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();
},
);
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
options.onToggleVisibleOverlay();
});
if (!toggleVisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
@@ -47,17 +38,10 @@ export function registerGlobalShortcuts(
}
}
if (
invisibleShortcut &&
normalizedInvisible &&
normalizedInvisible !== normalizedVisible
) {
const toggleInvisibleRegistered = globalShortcut.register(
invisibleShortcut,
() => {
options.onToggleInvisibleOverlay();
},
);
if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) {
const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => {
options.onToggleInvisibleOverlay();
});
if (!toggleInvisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
@@ -69,7 +53,7 @@ export function registerGlobalShortcuts(
normalizedInvisible === normalizedVisible
) {
logger.warn(
"Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal",
'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal',
);
}
@@ -81,15 +65,12 @@ export function registerGlobalShortcuts(
normalizedJimaku === normalizedSettings)
) {
logger.warn(
"Skipped registering openJimaku because it collides with another global shortcut",
'Skipped registering openJimaku because it collides with another global shortcut',
);
} else {
const openJimakuRegistered = globalShortcut.register(
options.shortcuts.openJimaku,
() => {
options.onOpenJimaku?.();
},
);
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
options.onOpenJimaku?.();
});
if (!openJimakuRegistered) {
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
@@ -98,22 +79,22 @@ export function registerGlobalShortcuts(
}
}
const settingsRegistered = globalShortcut.register("Alt+Shift+Y", () => {
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
options.onOpenYomitanSettings();
});
if (!settingsRegistered) {
logger.warn("Failed to register global shortcut: Alt+Shift+Y");
logger.warn('Failed to register global shortcut: Alt+Shift+Y');
}
if (options.isDev) {
const devtoolsRegistered = globalShortcut.register("F12", () => {
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");
logger.warn('Failed to register global shortcut: F12');
}
}
}

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { runStartupBootstrapRuntime } from "./startup";
import { CliArgs } from "../../cli/args";
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 {
@@ -51,136 +51,127 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
};
}
test("runStartupBootstrapRuntime configures startup state and starts lifecycle", () => {
test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => {
const calls: string[] = [];
const args = makeArgs({
logLevel: "debug",
socketPath: "/tmp/custom.sock",
logLevel: 'debug',
socketPath: '/tmp/custom.sock',
texthookerPort: 9001,
backend: "x11",
backend: 'x11',
autoStartOverlay: true,
texthooker: true,
});
const result = runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--log-level", "debug"],
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",
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.initialArgs, args);
assert.equal(result.mpvSocketPath, "/tmp/custom.sock");
assert.equal(result.mpvSocketPath, '/tmp/custom.sock');
assert.equal(result.texthookerPort, 9001);
assert.equal(result.backendOverride, "x11");
assert.equal(result.backendOverride, 'x11');
assert.equal(result.autoStartOverlay, true);
assert.equal(result.texthookerOnlyMode, true);
assert.deepEqual(calls, [
"setLog:debug:cli",
"forceX11",
"enforceWayland",
"startLifecycle",
]);
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
test("runStartupBootstrapRuntime keeps log-level precedence for repeated calls", () => {
test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => {
const calls: string[] = [];
const args = makeArgs({
logLevel: "warn",
logLevel: 'warn',
});
runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--log-level", "warn"],
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",
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.deepEqual(calls.slice(0, 3), [
"setLog:warn:cli",
"forceX11",
"enforceWayland",
]);
assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
});
test("runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags", () => {
test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => {
const calls: string[] = [];
const args = makeArgs({
jellyfin: true,
jellyfinLibraries: true,
socketPath: "/tmp/stable.sock",
socketPath: '/tmp/stable.sock',
texthookerPort: 8888,
});
const result = runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--jellyfin", "--jellyfin-libraries"],
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",
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.mpvSocketPath, "/tmp/stable.sock");
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.deepEqual(calls, ["forceX11", "enforceWayland", "startLifecycle"]);
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
});
test("runStartupBootstrapRuntime keeps --debug separate from log verbosity", () => {
test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => {
const calls: string[] = [];
const args = makeArgs({
debug: true,
});
runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--debug"],
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",
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.deepEqual(calls, ["forceX11", "enforceWayland", "startLifecycle"]);
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
});
test("runStartupBootstrapRuntime skips lifecycle when generate-config flow handled", () => {
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
const calls: string[] = [];
const args = makeArgs({ generateConfig: true, logLevel: "warn" });
const args = makeArgs({ generateConfig: true, logLevel: 'warn' });
const result = runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--generate-config"],
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",
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => true,
startAppLifecycle: () => calls.push("startLifecycle"),
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.mpvSocketPath, "/tmp/default.sock");
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.deepEqual(calls, ["setLog:warn:cli", "forceX11", "enforceWayland"]);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
});

View File

@@ -1,10 +1,6 @@
import { CliArgs } from "../../cli/args";
import type { LogLevelSource } from "../../logger";
import {
ConfigValidationWarning,
ResolvedConfig,
SecondarySubMode,
} from "../../types";
import { CliArgs } from '../../cli/args';
import type { LogLevelSource } from '../../logger';
import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
export interface StartupBootstrapRuntimeState {
initialArgs: CliArgs;
@@ -16,14 +12,14 @@ export interface StartupBootstrapRuntimeState {
}
interface RuntimeAutoUpdateOptionManagerLike {
getOptionValue: (id: "anki.autoUpdateNewCards") => unknown;
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";
startupVisibility: 'visible' | 'hidden' | 'platform-default';
};
ankiConnect?: {
behavior?: {
@@ -50,7 +46,7 @@ export function runStartupBootstrapRuntime(
const initialArgs = deps.parseArgs(deps.argv);
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, "cli");
deps.setLogLevel(initialArgs.logLevel, 'cli');
}
deps.forceX11Backend(initialArgs);
@@ -77,11 +73,11 @@ interface AppReadyConfigLike {
defaultMode?: SecondarySubMode;
};
websocket?: {
enabled?: boolean | "auto";
enabled?: boolean | 'auto';
port?: number;
};
logging?: {
level?: "debug" | "info" | "warn" | "error";
level?: 'debug' | 'info' | 'warn' | 'error';
};
}
@@ -117,23 +113,19 @@ export function getInitialInvisibleOverlayVisibility(
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === "visible") return true;
if (visibility === "hidden") return false;
if (platform === "linux") return false;
if (visibility === 'visible') return true;
if (visibility === 'hidden') return false;
if (platform === 'linux') return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(
config: RuntimeConfigLike,
): boolean {
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
if (config.auto_start_overlay === true) return true;
if (config.invisibleOverlay.startupVisibility === "visible") return true;
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
return false;
}
export function shouldBindVisibleOverlayToMpvSubVisibility(
config: RuntimeConfigLike,
): boolean {
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {
return config.bind_visible_overlay_to_mpv_sub_visibility;
}
@@ -141,19 +133,12 @@ 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
);
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> {
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
deps.loadSubtitlePosition();
deps.resolveKeybindings();
await deps.createMecabTokenizerAndCheck();
@@ -161,40 +146,33 @@ export async function runAppReadyRuntime(
deps.reloadConfig();
const config = deps.getResolvedConfig();
deps.setLogLevel(config.logging?.level ?? "info", "config");
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(
config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode,
);
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? "auto";
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || deps.defaultWebsocketPort;
if (
wsEnabled === true ||
(wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin())
) {
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort);
} else if (wsEnabled === "auto") {
deps.log("mpv_websocket detected, skipping built-in WebSocket server");
} 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.");
deps.log('Runtime ready: invoking createImmersionTracker.');
try {
deps.createImmersionTracker();
} catch (error) {
deps.log(
`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`,
);
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`);
}
} else {
deps.log("Runtime ready: createImmersionTracker dependency is missing.");
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
}
await deps.loadYomitanExtension();
if (deps.startJellyfinRemoteSession) {
@@ -202,11 +180,11 @@ export async function runAppReadyRuntime(
}
if (deps.texthookerOnlyMode) {
deps.log("Texthooker-only mode enabled; skipping overlay window.");
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.log('Overlay runtime deferred: waiting for explicit overlay command.');
}
deps.handleInitialArgs();

View File

@@ -1,17 +1,13 @@
import {
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
} from "../../types";
import { SubsyncResolvedConfig } from "../../subsync/utils";
import { runSubsyncManualFromIpc } from "./ipc-command";
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
import { SubsyncResolvedConfig } from '../../subsync/utils';
import { runSubsyncManualFromIpc } from './ipc-command';
import {
TriggerSubsyncFromConfigDeps,
runSubsyncManual,
triggerSubsyncFromConfig,
} from "./subsync";
} from './subsync';
const AUTOSUBSYNC_SPINNER_FRAMES = ["|", "/", "-", "\\"];
const AUTOSUBSYNC_SPINNER_FRAMES = ['|', '/', '-', '\\'];
interface MpvClientLike {
connected: boolean;
@@ -32,7 +28,7 @@ export interface SubsyncRuntimeDeps {
async function runWithSubsyncSpinnerService<T>(
task: () => Promise<T>,
showMpvOsd: (text: string) => void,
label = "Subsync: syncing",
label = 'Subsync: syncing',
): Promise<T> {
let frame = 0;
showMpvOsd(`${label} ${AUTOSUBSYNC_SPINNER_FRAMES[0]}`);
@@ -47,9 +43,7 @@ async function runWithSubsyncSpinnerService<T>(
}
}
function buildTriggerSubsyncDeps(
deps: SubsyncRuntimeDeps,
): TriggerSubsyncFromConfigDeps {
function buildTriggerSubsyncDeps(deps: SubsyncRuntimeDeps): TriggerSubsyncFromConfigDeps {
return {
getMpvClient: deps.getMpvClient,
getResolvedConfig: deps.getResolvedSubsyncConfig,
@@ -62,9 +56,7 @@ function buildTriggerSubsyncDeps(
};
}
export async function triggerSubsyncFromConfigRuntime(
deps: SubsyncRuntimeDeps,
): Promise<void> {
export async function triggerSubsyncFromConfigRuntime(deps: SubsyncRuntimeDeps): Promise<void> {
await triggerSubsyncFromConfig(buildTriggerSubsyncDeps(deps));
}
@@ -78,7 +70,6 @@ export async function runSubsyncManualFromIpcRuntime(
setSubsyncInProgress: triggerDeps.setSubsyncInProgress,
showMpvOsd: triggerDeps.showMpvOsd,
runWithSpinner: (task) => triggerDeps.runWithSubsyncSpinner(() => task()),
runSubsyncManual: (subsyncRequest) =>
runSubsyncManual(subsyncRequest, triggerDeps),
runSubsyncManual: (subsyncRequest) => runSubsyncManual(subsyncRequest, triggerDeps),
});
}

View File

@@ -1,13 +1,13 @@
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 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";
} from './subsync';
function makeDeps(
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
@@ -17,21 +17,21 @@ function makeDeps(
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") {
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: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: "sub",
type: 'sub',
selected: false,
external: true,
lang: "eng",
"external-filename": "/tmp/ref.srt",
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
{ id: 3, type: "audio", selected: true, "ff-index": 1 },
{ id: 3, type: 'audio', selected: true, 'ff-index': 1 },
];
}
return null;
@@ -41,10 +41,10 @@ function makeDeps(
return {
getMpvClient: () => mpvClient,
getResolvedConfig: () => ({
defaultMode: "manual",
alassPath: "/usr/bin/alass",
ffsubsyncPath: "/usr/bin/ffsubsync",
ffmpegPath: "/usr/bin/ffmpeg",
defaultMode: 'manual',
alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg',
}),
isSubsyncInProgress: () => false,
setSubsyncInProgress: () => {},
@@ -55,7 +55,7 @@ function makeDeps(
};
}
test("triggerSubsyncFromConfig returns early when already in progress", async () => {
test('triggerSubsyncFromConfig returns early when already in progress', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(
makeDeps({
@@ -65,10 +65,10 @@ test("triggerSubsyncFromConfig returns early when already in progress", async ()
},
}),
);
assert.deepEqual(osd, ["Subsync already running"]);
assert.deepEqual(osd, ['Subsync already running']);
});
test("triggerSubsyncFromConfig opens manual picker in manual mode", async () => {
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let inProgressState: boolean | null = null;
@@ -88,11 +88,11 @@ test("triggerSubsyncFromConfig opens manual picker in manual mode", async () =>
);
assert.equal(payloadTrackCount, 1);
assert.ok(osd.includes("Subsync: choose engine and source"));
assert.ok(osd.includes('Subsync: choose engine and source'));
assert.equal(inProgressState, false);
});
test("triggerSubsyncFromConfig reports failures to OSD", async () => {
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(
makeDeps({
@@ -103,34 +103,29 @@ test("triggerSubsyncFromConfig reports failures to OSD", async () => {
}),
);
assert.ok(
osd.some((line) => line.startsWith("Subsync failed: MPV not connected")),
);
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(),
);
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",
message: 'Select a subtitle source track for alass',
});
});
test("triggerSubsyncFromConfig reports path validation failures", async () => {
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",
defaultMode: 'auto',
alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg',
}),
setSubsyncInProgress: (value) => {
inProgress.push(value);
@@ -143,30 +138,28 @@ test("triggerSubsyncFromConfig reports path validation failures", async () => {
assert.deepEqual(inProgress, [true, false]);
assert.ok(
osd.some((line) =>
line.startsWith("Subsync failed: Configured ffmpeg executable not found"),
),
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.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");
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");
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`,
@@ -181,17 +174,17 @@ test("runSubsyncManual constructs ffsubsync command and returns success", async
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") {
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",
type: 'sub',
selected: true,
external: true,
"external-filename": primaryPath,
'external-filename': primaryPath,
},
];
}
@@ -199,45 +192,42 @@ test("runSubsyncManual constructs ffsubsync command and returns success", async
},
}),
getResolvedConfig: () => ({
defaultMode: "manual",
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual(
{ engine: "ffsubsync", sourceTrackId: null },
deps,
);
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(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('-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]);
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");
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");
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`,
@@ -249,24 +239,24 @@ test("runSubsyncManual constructs alass command and returns failure on non-zero
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") {
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",
type: 'sub',
selected: true,
external: true,
"external-filename": primaryPath,
'external-filename': primaryPath,
},
{
id: 2,
type: "sub",
type: 'sub',
selected: false,
external: true,
"external-filename": sourcePath,
'external-filename': sourcePath,
},
];
}
@@ -274,39 +264,36 @@ test("runSubsyncManual constructs alass command and returns failure on non-zero
},
}),
getResolvedConfig: () => ({
defaultMode: "manual",
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual(
{ engine: "alass", sourceTrackId: 2 },
deps,
);
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(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");
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");
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`,
@@ -318,17 +305,17 @@ test("runSubsyncManual resolves string sid values from mpv stream properties", a
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") {
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",
id: '1',
type: 'sub',
selected: true,
external: true,
"external-filename": primaryPath,
'external-filename': primaryPath,
},
];
}
@@ -336,25 +323,22 @@ test("runSubsyncManual resolves string sid values from mpv stream properties", a
},
}),
getResolvedConfig: () => ({
defaultMode: "manual",
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual(
{ engine: "ffsubsync", sourceTrackId: null },
deps,
);
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(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.equal(typeof outputPath, 'string');
assert.ok(outputPath.length > 0);
assert.equal(fs.readFileSync(outputPath, "utf8"), "");
assert.equal(fs.readFileSync(outputPath, 'utf8'), '');
});

View File

@@ -1,11 +1,7 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
} from "../../types";
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
import {
CommandResult,
codecToExtension,
@@ -17,32 +13,29 @@ import {
runCommand,
SubsyncContext,
SubsyncResolvedConfig,
} from "../../subsync/utils";
import { isRemoteMediaPath } from "../../jimaku/utils";
import { createLogger } from "../../logger";
} from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger("main:subsync");
const logger = createLogger('main:subsync');
interface FileExtractionResult {
path: string;
temporary: boolean;
}
function summarizeCommandFailure(
command: string,
result: CommandResult,
): string {
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}` : "",
`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(" | ")}`;
return `command failed (${command}) ${parts.join(' | ')}`;
}
interface MpvClientLike {
@@ -58,23 +51,21 @@ interface SubsyncCoreDeps {
}
function parseTrackId(value: unknown): number | null {
if (typeof value === "number") {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : null;
}
if (typeof value === "string") {
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 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;
if (!track || typeof track !== 'object') return track as MpvTrack;
const typed = track as MpvTrack & { id?: unknown };
const parsedId = parseTrackId(typed.id);
if (parsedId === null) {
@@ -96,47 +87,41 @@ export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
function getMpvClientForSubsync(deps: SubsyncCoreDeps): MpvClientLike {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error("MPV not 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"),
]);
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 : "";
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
throw new Error("No video is currently loaded");
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 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");
throw new Error('No active subtitle track found');
}
const secondaryTrack =
subtitleTracks.find((track) => track.id === secondarySid) ?? null;
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;
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
return {
@@ -165,9 +150,9 @@ async function extractSubtitleTrackToFile(
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");
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}`);
@@ -175,34 +160,30 @@ async function extractSubtitleTrackToFile(
return { path: externalPath, temporary: false };
}
const ffIndex = track["ff-index"];
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 (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"}`);
throw new Error(`Unsupported subtitle codec: ${track.codec ?? 'unknown'}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-subsync-"));
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",
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
"-map",
'-map',
`0:${ffIndex}`,
"-f",
'-f',
extension,
outputPath,
]);
@@ -210,7 +191,7 @@ async function extractSubtitleTrackToFile(
if (!extraction.ok || !fileExists(outputPath)) {
throw new Error(
`Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure(
"ffmpeg",
'ffmpeg',
extraction,
)}`,
);
@@ -237,10 +218,7 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
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"}`,
);
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
}
async function runAlassSync(
@@ -259,29 +237,29 @@ async function runFfsubsyncSync(
outputPath: string,
audioStreamIndex: number | null,
): Promise<CommandResult> {
const args = [videoPath, "-i", inputSubtitlePath, "-o", outputPath];
const args = [videoPath, '-i', inputSubtitlePath, '-o', outputPath];
if (audioStreamIndex !== null) {
args.push("--reference-stream", `0:${audioStreamIndex}`);
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");
throw new Error('MPV disconnected while loading subtitle');
}
client.send({ command: ["sub_add", pathToLoad] });
client.send({ command: ["set_property", "sub-delay", 0] });
client.send({ command: ['sub_add', pathToLoad] });
client.send({ command: ['set_property', 'sub-delay', 0] });
}
async function subsyncToReference(
engine: "alass" | "ffsubsync",
engine: 'alass' | 'ffsubsync',
referenceFilePath: string,
context: SubsyncContext,
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, "ffmpeg");
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
@@ -291,19 +269,11 @@ async function subsyncToReference(
try {
let result: CommandResult;
if (engine === "alass") {
const alassPath = ensureExecutablePath(resolved.alassPath, "alass");
result = await runAlassSync(
alassPath,
referenceFilePath,
primaryExtraction.path,
outputPath,
);
if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(
resolved.ffsubsyncPath,
"ffsubsync",
);
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
@@ -334,18 +304,16 @@ async function subsyncToReference(
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.",
'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> {
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");
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
@@ -356,7 +324,7 @@ async function runSubsyncAutoInternal(
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
"alass",
'alass',
secondaryExtraction.path,
context,
resolved,
@@ -366,7 +334,7 @@ async function runSubsyncAutoInternal(
return alassResult;
}
} catch (error) {
logger.warn("Auto alass sync failed, trying ffsubsync fallback:", error);
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
@@ -374,14 +342,11 @@ async function runSubsyncAutoInternal(
}
}
const ffsubsyncPath = ensureExecutablePath(
resolved.ffsubsyncPath,
"ffsubsync",
);
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: "No secondary subtitle for alass and ffsubsync not configured",
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
@@ -392,13 +357,7 @@ async function runSubsyncAutoInternal(
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference(
"ffsubsync",
context.videoPath,
context,
resolved,
client,
);
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual(
@@ -409,7 +368,7 @@ export async function runSubsyncManual(
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
if (request.engine === "ffsubsync") {
if (request.engine === 'ffsubsync') {
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
@@ -418,38 +377,19 @@ export async function runSubsyncManual(
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference(
"ffsubsync",
context.videoPath,
context,
resolved,
client,
);
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
const sourceTrack = getTrackById(
context.sourceTracks,
request.sourceTrackId ?? null,
);
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
if (!sourceTrack) {
return { ok: false, message: "Select a subtitle source track for alass" };
return { ok: false, message: 'Select a subtitle source track for alass' };
}
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, "ffmpeg");
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,
);
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
} finally {
if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction);
@@ -457,14 +397,12 @@ export async function runSubsyncManual(
}
}
export async function openSubsyncManualPicker(
deps: TriggerSubsyncFromConfigDeps,
): Promise<void> {
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")
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
label: formatTrackLabel(track),
@@ -473,26 +411,22 @@ export async function openSubsyncManualPicker(
deps.openManualPicker(payload);
}
export async function triggerSubsyncFromConfig(
deps: TriggerSubsyncFromConfigDeps,
): Promise<void> {
export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
if (deps.isSubsyncInProgress()) {
deps.showMpvOsd("Subsync already running");
deps.showMpvOsd('Subsync already running');
return;
}
const resolved = deps.getResolvedConfig();
try {
if (resolved.defaultMode === "manual") {
if (resolved.defaultMode === 'manual') {
await openSubsyncManualPicker(deps);
deps.showMpvOsd("Subsync: choose engine and source");
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() =>
runSubsyncAutoInternal(deps),
);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);

View File

@@ -1,10 +1,10 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { SecondarySubMode, SubtitlePosition } from "../../types";
import { createLogger } from "../../logger";
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");
const logger = createLogger('main:subtitle-position');
export interface CycleSecondarySubModeDeps {
getSecondarySubMode: () => SecondarySubMode;
@@ -16,34 +16,27 @@ export interface CycleSecondarySubModeDeps {
now?: () => number;
}
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"];
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
) {
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 nextMode =
SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
}
function getSubtitlePositionFilePath(
mediaPath: string,
subtitlePositionsDir: string,
): string {
function getSubtitlePositionFilePath(mediaPath: string, subtitlePositionsDir: string): string {
const key = normalizeMediaPathForSubtitlePosition(mediaPath);
const hash = crypto.createHash("sha256").update(key).digest("hex");
const hash = crypto.createHash('sha256').update(key).digest('hex');
return path.join(subtitlePositionsDir, `${hash}.json`);
}
@@ -51,10 +44,7 @@ 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)
) {
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) || /^ytsearch:/.test(trimmed)) {
return trimmed;
}
@@ -68,7 +58,7 @@ function normalizeMediaPathForSubtitlePosition(mediaPath: string): string {
normalized = resolved;
}
if (process.platform === "win32") {
if (process.platform === 'win32') {
normalized = normalized.toLowerCase();
}
@@ -84,10 +74,7 @@ function persistSubtitlePosition(
if (!fs.existsSync(subtitlePositionsDir)) {
fs.mkdirSync(subtitlePositionsDir, { recursive: true });
}
const positionPath = getSubtitlePositionFilePath(
currentMediaPath,
subtitlePositionsDir,
);
const positionPath = getSubtitlePositionFilePath(currentMediaPath, subtitlePositionsDir);
fs.writeFileSync(positionPath, JSON.stringify(position, null, 2));
}
@@ -110,22 +97,18 @@ export function loadSubtitlePosition(
return options.fallbackPosition;
}
const data = fs.readFileSync(positionPath, "utf-8");
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)
) {
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
const position: SubtitlePosition = { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === "number" &&
typeof parsed.invisibleOffsetXPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === "number" &&
typeof parsed.invisibleOffsetYPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
@@ -134,7 +117,7 @@ export function loadSubtitlePosition(
}
return options.fallbackPosition;
} catch (err) {
logger.error("Failed to load subtitle position:", (err as Error).message);
logger.error('Failed to load subtitle position:', (err as Error).message);
return options.fallbackPosition;
}
}
@@ -148,7 +131,7 @@ export function saveSubtitlePosition(options: {
}): void {
if (!options.currentMediaPath) {
options.onQueuePending(options.position);
logger.warn("Queued subtitle position save - no media path yet");
logger.warn('Queued subtitle position save - no media path yet');
return;
}
@@ -160,7 +143,7 @@ export function saveSubtitlePosition(options: {
);
options.onPersisted();
} catch (err) {
logger.error("Failed to save subtitle position:", (err as Error).message);
logger.error('Failed to save subtitle position:', (err as Error).message);
}
}
@@ -176,7 +159,7 @@ export function updateCurrentMediaPath(options: {
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
}): void {
const nextPath =
typeof options.mediaPath === "string" && options.mediaPath.trim().length > 0
typeof options.mediaPath === 'string' && options.mediaPath.trim().length > 0
? options.mediaPath
: null;
if (nextPath === options.currentMediaPath) return;
@@ -192,10 +175,7 @@ export function updateCurrentMediaPath(options: {
options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition();
} catch (err) {
logger.error(
"Failed to persist queued subtitle position:",
(err as Error).message,
);
logger.error('Failed to persist queued subtitle position:', (err as Error).message);
}
}

View File

@@ -1,18 +1,13 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import WebSocket from "ws";
import { createLogger } from "../../logger";
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import WebSocket from 'ws';
import { createLogger } from '../../logger';
const logger = createLogger("main:subtitle-ws");
const logger = createLogger('main:subtitle-ws');
export function hasMpvWebsocketPlugin(): boolean {
const mpvWebsocketPath = path.join(
os.homedir(),
".config",
"mpv",
"mpv_websocket",
);
const mpvWebsocketPath = path.join(os.homedir(), '.config', 'mpv', 'mpv_websocket');
return fs.existsSync(mpvWebsocketPath);
}
@@ -24,18 +19,18 @@ export class SubtitleWebSocket {
}
public start(port: number, getCurrentSubtitleText: () => string): void {
this.server = new WebSocket.Server({ port, host: "127.0.0.1" });
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
this.server.on("connection", (ws: WebSocket) => {
logger.info("WebSocket client connected");
this.server.on('connection', (ws: WebSocket) => {
logger.info('WebSocket client connected');
const currentText = getCurrentSubtitleText();
if (currentText) {
ws.send(JSON.stringify({ sentence: currentText }));
}
});
this.server.on("error", (err: Error) => {
logger.error("WebSocket server error:", err.message);
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}`);

View File

@@ -1,9 +1,9 @@
import * as fs from "fs";
import * as http from "http";
import * as path from "path";
import { createLogger } from "../../logger";
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import { createLogger } from '../../logger';
const logger = createLogger("main:texthooker");
const logger = createLogger('main:texthooker');
export class Texthooker {
private server: http.Server | null = null;
@@ -15,42 +15,39 @@ export class Texthooker {
public start(port: number): http.Server | null {
const texthookerPath = this.getTexthookerPath();
if (!texthookerPath) {
logger.error("texthooker-ui not found");
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 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",
'.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");
res.end('Not found');
return;
}
res.writeHead(200, { "Content-Type": mimeTypes[ext] || "text/plain" });
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
res.end(data);
});
});
this.server.listen(port, "127.0.0.1", () => {
this.server.listen(port, '127.0.0.1', () => {
logger.info(`Texthooker server running at http://127.0.0.1:${port}`);
});
@@ -66,17 +63,11 @@ export class Texthooker {
private getTexthookerPath(): string | null {
const searchPaths = [
path.join(__dirname, "..", "..", "..", "vendor", "texthooker-ui", "docs"),
path.join(
process.resourcesPath,
"app",
"vendor",
"texthooker-ui",
"docs",
),
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"))) {
if (fs.existsSync(path.join(candidate, 'index.html'))) {
return candidate;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import type { BrowserWindow, Extension } from "electron";
import { markNPlusOneTargets, mergeTokens } from "../../token-merger";
import type { BrowserWindow, Extension } from 'electron';
import { markNPlusOneTargets, mergeTokens } from '../../token-merger';
import {
JlptLevel,
MergedToken,
@@ -8,12 +8,9 @@ import {
SubtitleData,
Token,
FrequencyDictionaryLookup,
} from "../../types";
import {
shouldIgnoreJlptForMecabPos1,
shouldIgnoreJlptByTerm,
} from "./jlpt-token-filter";
import { createLogger } from "../../logger";
} from '../../types';
import { shouldIgnoreJlptForMecabPos1, shouldIgnoreJlptByTerm } from './jlpt-token-filter';
import { createLogger } from '../../logger';
interface YomitanParseHeadword {
term?: unknown;
@@ -38,7 +35,7 @@ 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 logger = createLogger("main:tokenizer");
const logger = createLogger('main:tokenizer');
const jlptLevelLookupCaches = new WeakMap<
(text: string) => JlptLevel | null,
@@ -50,11 +47,11 @@ const frequencyRankLookupCaches = new WeakMap<
>();
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object");
return Boolean(value && typeof value === 'object');
}
function isString(value: unknown): value is string {
return typeof value === "string";
return typeof value === 'string';
}
export interface TokenizerServiceDeps {
@@ -199,10 +196,8 @@ export function createTokenizerDepsRuntime(
getJlptEnabled: options.getJlptEnabled,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyRank: options.getFrequencyRank,
getMinSentenceWordsForNPlusOne:
options.getMinSentenceWordsForNPlusOne ?? (() => 3),
getYomitanGroupDebugEnabled:
options.getYomitanGroupDebugEnabled ?? (() => false),
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
tokenizeWithMecab: async (text) => {
const mecabTokenizer = options.getMecabTokenizer();
if (!mecabTokenizer) {
@@ -212,11 +207,7 @@ export function createTokenizerDepsRuntime(
if (!rawTokens || rawTokens.length === 0) {
return null;
}
return mergeTokens(
rawTokens,
options.isKnownWord,
options.getKnownWordMatchMode(),
);
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode());
},
};
}
@@ -226,7 +217,7 @@ function resolveKnownWordText(
headword: string,
matchMode: NPlusOneMatchMode,
): string {
return matchMode === "surface" ? surface : headword;
return matchMode === 'surface' ? surface : headword;
}
function applyKnownWordMarking(
@@ -235,11 +226,7 @@ function applyKnownWordMarking(
knownWordMatchMode: NPlusOneMatchMode,
): MergedToken[] {
return tokens.map((token) => {
const matchText = resolveKnownWordText(
token.surface,
token.headword,
knownWordMatchMode,
);
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
return {
...token,
@@ -271,7 +258,7 @@ function isFrequencyExcludedByPos(token: MergedToken): boolean {
return true;
}
return token.pos1 === "助詞" || token.pos1 === "助動詞";
return token.pos1 === '助詞' || token.pos1 === '助動詞';
}
function applyFrequencyMarking(
@@ -319,10 +306,10 @@ function resolveJlptLookupText(token: MergedToken): string {
function normalizeJlptTextForExclusion(text: string): string {
const raw = text.trim();
if (!raw) {
return "";
return '';
}
let normalized = "";
let normalized = '';
for (const char of raw) {
const code = char.codePointAt(0);
if (code === undefined) {
@@ -401,8 +388,7 @@ function isJlptEligibleToken(token: MergedToken): boolean {
token.reading,
token.headword,
].filter(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.length > 0,
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
);
for (const candidate of candidates) {
@@ -412,17 +398,11 @@ function isJlptEligibleToken(token: MergedToken): boolean {
}
const trimmedCandidate = candidate.trim();
if (
shouldIgnoreJlptByTerm(trimmedCandidate) ||
shouldIgnoreJlptByTerm(normalizedCandidate)
) {
if (shouldIgnoreJlptByTerm(trimmedCandidate) || shouldIgnoreJlptByTerm(normalizedCandidate)) {
return false;
}
if (
isRepeatedKanaSfx(candidate) ||
isRepeatedKanaSfx(normalizedCandidate)
) {
if (isRepeatedKanaSfx(candidate) || isRepeatedKanaSfx(normalizedCandidate)) {
return false;
}
}
@@ -430,9 +410,7 @@ function isJlptEligibleToken(token: MergedToken): boolean {
return true;
}
function isYomitanParseResultItem(
value: unknown,
): value is YomitanParseResultItem {
function isYomitanParseResultItem(value: unknown): value is YomitanParseResultItem {
if (!isObject(value)) {
return false;
}
@@ -460,18 +438,13 @@ function isYomitanParseLine(value: unknown): value is YomitanParseLine {
});
}
function isYomitanHeadwordRows(
value: unknown,
): value is YomitanParseHeadword[][] {
function isYomitanHeadwordRows(value: unknown): value is YomitanParseHeadword[][] {
return (
Array.isArray(value) &&
value.every(
(group) =>
Array.isArray(group) &&
group.every(
(item) =>
isObject(item) && isString((item as YomitanParseHeadword).term),
),
group.every((item) => isObject(item) && isString((item as YomitanParseHeadword).term)),
)
);
}
@@ -479,7 +452,7 @@ function isYomitanHeadwordRows(
function extractYomitanHeadword(segment: YomitanParseSegment): string {
const headwords = segment.headwords;
if (!isYomitanHeadwordRows(headwords)) {
return "";
return '';
}
for (const group of headwords) {
@@ -491,7 +464,7 @@ function extractYomitanHeadword(segment: YomitanParseSegment): string {
}
}
return "";
return '';
}
function applyJlptMarking(
@@ -503,14 +476,9 @@ function applyJlptMarking(
return { ...token, jlptLevel: undefined };
}
const primaryLevel = getCachedJlptLevel(
resolveJlptLookupText(token),
getJlptLevel,
);
const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel);
const fallbackLevel =
primaryLevel === null
? getCachedJlptLevel(token.surface, getJlptLevel)
: null;
primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null;
return {
...token,
@@ -535,9 +503,9 @@ function mapYomitanParseResultItemToMergedTokens(
return null;
}
const source = String(parseResult.source ?? "");
const source = String(parseResult.source ?? '');
const index =
typeof parseResult.index === "number" && Number.isInteger(parseResult.index)
typeof parseResult.index === 'number' && Number.isInteger(parseResult.index)
? parseResult.index
: 0;
@@ -551,9 +519,9 @@ function mapYomitanParseResultItemToMergedTokens(
}
validLineCount += 1;
let combinedSurface = "";
let combinedReading = "";
let combinedHeadword = "";
let combinedSurface = '';
let combinedReading = '';
let combinedHeadword = '';
for (const segment of line) {
const segmentText = segment.text;
@@ -562,7 +530,7 @@ function mapYomitanParseResultItemToMergedTokens(
}
combinedSurface += segmentText;
if (typeof segment.reading === "string") {
if (typeof segment.reading === 'string') {
combinedReading += segment.reading;
}
if (!combinedHeadword) {
@@ -586,15 +554,11 @@ function mapYomitanParseResultItemToMergedTokens(
startPos: start,
endPos: end,
partOfSpeech: PartOfSpeech.other,
pos1: "",
pos1: '',
isMerged: true,
isNPlusOneTarget: false,
isKnown: (() => {
const matchText = resolveKnownWordText(
combinedSurface,
headword,
knownWordMatchMode,
);
const matchText = resolveKnownWordText(combinedSurface, headword, knownWordMatchMode);
return matchText ? isKnownWord(matchText) : false;
})(),
});
@@ -615,15 +579,11 @@ function selectBestYomitanParseCandidate(
}
const scanningCandidates = candidates.filter(
(candidate) => candidate.source === "scanning-parser",
);
const mecabCandidates = candidates.filter(
(candidate) => candidate.source === "mecab",
(candidate) => candidate.source === 'scanning-parser',
);
const mecabCandidates = candidates.filter((candidate) => candidate.source === 'mecab');
const getBestByTokenCount = (
items: YomitanParseCandidate[],
): YomitanParseCandidate | null =>
const getBestByTokenCount = (items: YomitanParseCandidate[]): YomitanParseCandidate | null =>
items.length === 0
? null
: items.reduce((best, current) =>
@@ -641,16 +601,10 @@ function selectBestYomitanParseCandidate(
Array.from(token.surface).every((char) => isKanaChar(char)),
).length;
return (
readableTokenCount * 100 -
suspiciousKanaFragmentCount * 50 -
candidate.tokens.length
);
return readableTokenCount * 100 - suspiciousKanaFragmentCount * 50 - candidate.tokens.length;
};
const chooseBestCandidate = (
items: YomitanParseCandidate[],
): YomitanParseCandidate | null => {
const chooseBestCandidate = (items: YomitanParseCandidate[]): YomitanParseCandidate | null => {
if (items.length === 0) {
return null;
}
@@ -677,21 +631,15 @@ function selectBestYomitanParseCandidate(
}
const bestMecab = chooseBestCandidate(mecabCandidates);
if (
bestMecab &&
bestMecab.tokens.length > (bestScanning?.tokens.length ?? 0)
) {
if (bestMecab && bestMecab.tokens.length > (bestScanning?.tokens.length ?? 0)) {
return bestMecab.tokens;
}
return bestScanning ? bestScanning.tokens : null;
}
const multiTokenCandidates = candidates.filter(
(candidate) => candidate.tokens.length > 1,
);
const pool =
multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates;
const multiTokenCandidates = candidates.filter((candidate) => candidate.tokens.length > 1);
const pool = multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates;
const bestCandidate = chooseBestCandidate(pool);
return bestCandidate ? bestCandidate.tokens : null;
}
@@ -706,19 +654,9 @@ function mapYomitanParseResultsToMergedTokens(
}
const candidates = parseResults
.filter((item): item is YomitanParseResultItem =>
isYomitanParseResultItem(item),
)
.map((item) =>
mapYomitanParseResultItemToMergedTokens(
item,
isKnownWord,
knownWordMatchMode,
),
)
.filter(
(candidate): candidate is YomitanParseCandidate => candidate !== null,
);
.filter((item): item is YomitanParseResultItem => isYomitanParseResultItem(item))
.map((item) => mapYomitanParseResultItemToMergedTokens(item, isKnownWord, knownWordMatchMode))
.filter((candidate): candidate is YomitanParseCandidate => candidate !== null);
const bestCandidate = selectBestYomitanParseCandidate(candidates);
return bestCandidate;
@@ -729,7 +667,7 @@ function logSelectedYomitanGroups(text: string, tokens: MergedToken[]): void {
return;
}
logger.info("Selected Yomitan token groups", {
logger.info('Selected Yomitan token groups', {
text,
tokenCount: tokens.length,
groups: tokens.map((token, index) => ({
@@ -743,10 +681,7 @@ function logSelectedYomitanGroups(text: string, tokens: MergedToken[]): void {
});
}
function pickClosestMecabPos1(
token: MergedToken,
mecabTokens: MergedToken[],
): string | undefined {
function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): string | undefined {
if (mecabTokens.length === 0) {
return undefined;
}
@@ -765,8 +700,7 @@ function pickClosestMecabPos1(
}
const mecabStart = mecabToken.startPos ?? 0;
const mecabEnd =
mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
const overlapStart = Math.max(tokenStart, mecabStart);
const overlapEnd = Math.min(tokenEnd, mecabEnd);
const overlap = Math.max(0, overlapEnd - overlapStart);
@@ -805,7 +739,7 @@ async function enrichYomitanPos1(
} catch (err) {
const error = err as Error;
logger.warn(
"Failed to enrich Yomitan tokens with MeCab POS:",
'Failed to enrich Yomitan tokens with MeCab POS:',
error.message,
`tokenCount=${tokens.length}`,
`textLength=${text.length}`,
@@ -815,7 +749,7 @@ async function enrichYomitanPos1(
if (!mecabTokens || mecabTokens.length === 0) {
logger.warn(
"MeCab enrichment returned no tokens; preserving Yomitan token output.",
'MeCab enrichment returned no tokens; preserving Yomitan token output.',
`tokenCount=${tokens.length}`,
`textLength=${text.length}`,
);
@@ -839,10 +773,8 @@ async function enrichYomitanPos1(
});
}
async function ensureYomitanParserWindow(
deps: TokenizerServiceDeps,
): Promise<boolean> {
const electron = await import("electron");
async function ensureYomitanParserWindow(deps: TokenizerServiceDeps): Promise<boolean> {
const electron = await import('electron');
const yomitanExt = deps.getYomitanExt();
if (!yomitanExt) {
return false;
@@ -874,17 +806,14 @@ async function ensureYomitanParserWindow(
deps.setYomitanParserReadyPromise(
new Promise((resolve, reject) => {
parserWindow.webContents.once("did-finish-load", () => resolve());
parserWindow.webContents.once(
"did-fail-load",
(_event, _errorCode, errorDescription) => {
reject(new Error(errorDescription));
},
);
parserWindow.webContents.once('did-finish-load', () => resolve());
parserWindow.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => {
reject(new Error(errorDescription));
});
}),
);
parserWindow.on("closed", () => {
parserWindow.on('closed', () => {
if (deps.getYomitanParserWindow() === parserWindow) {
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
@@ -892,19 +821,14 @@ async function ensureYomitanParserWindow(
});
try {
await parserWindow.loadURL(
`chrome-extension://${yomitanExt.id}/search.html`,
);
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) {
await readyPromise;
}
return true;
} catch (err) {
logger.error(
"Failed to initialize Yomitan parser window:",
(err as Error).message,
);
logger.error('Failed to initialize Yomitan parser window:', (err as Error).message);
if (!parserWindow.isDestroyed()) {
parserWindow.destroy();
}
@@ -974,10 +898,7 @@ async function parseWithYomitanInternalParser(
`;
try {
const parseResults = await parserWindow.webContents.executeJavaScript(
script,
true,
);
const parseResults = await parserWindow.webContents.executeJavaScript(script, true);
const yomitanTokens = mapYomitanParseResultsToMergedTokens(
parseResults,
deps.isKnownWord,
@@ -993,7 +914,7 @@ async function parseWithYomitanInternalParser(
return enrichYomitanPos1(yomitanTokens, deps, text);
} catch (err) {
logger.error("Yomitan parser request failed:", (err as Error).message);
logger.error('Yomitan parser request failed:', (err as Error).message);
return null;
}
}
@@ -1011,27 +932,21 @@ export async function tokenizeSubtitle(
: 3;
const displayText = text
.replace(/\r\n/g, "\n")
.replace(/\\N/g, "\n")
.replace(/\\n/g, "\n")
.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 tokenizeText = displayText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
const jlptEnabled = deps.getJlptEnabled?.() !== false;
const frequencyEnabled = deps.getFrequencyDictionaryEnabled?.() !== false;
const frequencyLookup = deps.getFrequencyRank;
const yomitanTokens = await parseWithYomitanInternalParser(
tokenizeText,
deps,
);
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
if (yomitanTokens && yomitanTokens.length > 0) {
const knownMarkedTokens = applyKnownWordMarking(
yomitanTokens,
@@ -1053,10 +968,7 @@ export async function tokenizeSubtitle(
}));
return {
text: displayText,
tokens: markNPlusOneTargets(
jlptMarkedTokens,
sanitizedMinSentenceWordsForNPlusOne,
),
tokens: markNPlusOneTargets(jlptMarkedTokens, sanitizedMinSentenceWordsForNPlusOne),
};
}
@@ -1083,14 +995,11 @@ export async function tokenizeSubtitle(
}));
return {
text: displayText,
tokens: markNPlusOneTargets(
jlptMarkedTokens,
sanitizedMinSentenceWordsForNPlusOne,
),
tokens: markNPlusOneTargets(jlptMarkedTokens, sanitizedMinSentenceWordsForNPlusOne),
};
}
} catch (err) {
logger.error("Tokenization error:", (err as Error).message);
logger.error('Tokenization error:', (err as Error).message);
}
return { text: displayText, tokens: null };

View File

@@ -1,9 +1,9 @@
import { BrowserWindow, Extension, session } from "electron";
import * as fs from "fs";
import * as path from "path";
import { createLogger } from "../../logger";
import { BrowserWindow, Extension, session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
const logger = createLogger("main:yomitan-extension-loader");
const logger = createLogger('main:yomitan-extension-loader');
export interface YomitanExtensionLoaderDeps {
userDataPath: string;
@@ -15,30 +15,26 @@ export interface YomitanExtensionLoaderDeps {
}
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
if (process.platform === "win32") {
if (process.platform === 'win32') {
return sourceDir;
}
const extensionsRoot = path.join(userDataPath, "extensions");
const targetDir = path.join(extensionsRoot, "yomitan");
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const sourceManifest = path.join(sourceDir, "manifest.json");
const targetManifest = path.join(targetDir, "manifest.json");
const sourceManifest = path.join(sourceDir, 'manifest.json');
const targetManifest = path.join(targetDir, 'manifest.json');
let shouldCopy = !fs.existsSync(targetDir);
if (
!shouldCopy &&
fs.existsSync(sourceManifest) &&
fs.existsSync(targetManifest)
) {
if (!shouldCopy && fs.existsSync(sourceManifest) && fs.existsSync(targetManifest)) {
try {
const sourceVersion = (
JSON.parse(fs.readFileSync(sourceManifest, "utf-8")) as {
JSON.parse(fs.readFileSync(sourceManifest, 'utf-8')) as {
version: string;
}
).version;
const targetVersion = (
JSON.parse(fs.readFileSync(targetManifest, "utf-8")) as {
JSON.parse(fs.readFileSync(targetManifest, 'utf-8')) as {
version: string;
}
).version;
@@ -62,11 +58,11 @@ export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
const searchPaths = [
path.join(__dirname, "..", "..", "vendor", "yomitan"),
path.join(__dirname, "..", "..", "..", "vendor", "yomitan"),
path.join(process.resourcesPath, "yomitan"),
"/usr/share/SubMiner/yomitan",
path.join(deps.userDataPath, "yomitan"),
path.join(__dirname, '..', '..', 'vendor', 'yomitan'),
path.join(__dirname, '..', '..', '..', 'vendor', 'yomitan'),
path.join(process.resourcesPath, 'yomitan'),
'/usr/share/SubMiner/yomitan',
path.join(deps.userDataPath, 'yomitan'),
];
let extPath: string | null = null;
@@ -78,8 +74,8 @@ export async function loadYomitanExtension(
}
if (!extPath) {
logger.error("Yomitan extension not found in any search path");
logger.error("Install Yomitan to one of:", searchPaths);
logger.error('Yomitan extension not found in any search path');
logger.error('Install Yomitan to one of:', searchPaths);
return null;
}
@@ -105,8 +101,8 @@ export async function loadYomitanExtension(
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
logger.error("Failed to load Yomitan extension:", (err as Error).message);
logger.error("Full error:", err);
logger.error('Failed to load Yomitan extension:', (err as Error).message);
logger.error('Full error:', err);
deps.setYomitanExtension(null);
return null;
}

View File

@@ -1,7 +1,7 @@
import { BrowserWindow, Extension, session } from "electron";
import { createLogger } from "../../logger";
import { BrowserWindow, Extension, session } from 'electron';
import { createLogger } from '../../logger';
const logger = createLogger("main:yomitan-settings");
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
yomitanExt: Extension | null;
@@ -9,33 +9,23 @@ export interface OpenYomitanSettingsWindowOptions {
setWindow: (window: BrowserWindow | null) => void;
}
export function openYomitanSettingsWindow(
options: OpenYomitanSettingsWindowOptions,
): void {
logger.info("openYomitanSettings called");
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
logger.info('openYomitanSettings called');
if (!options.yomitanExt) {
logger.error(
"Yomitan extension not loaded - yomitanExt is:",
options.yomitanExt,
);
logger.error(
"This may be due to Manifest V3 service worker issues with Electron",
);
logger.error('Yomitan extension not loaded - yomitanExt is:', options.yomitanExt);
logger.error('This may be due to Manifest V3 service worker issues with Electron');
return;
}
const existingWindow = options.getExistingWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
logger.info("Settings window already exists, focusing");
logger.info('Settings window already exists, focusing');
existingWindow.focus();
return;
}
logger.info(
"Creating new settings window for extension:",
options.yomitanExt.id,
);
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
const settingsWindow = new BrowserWindow({
width: 1200,
@@ -50,7 +40,7 @@ export function openYomitanSettingsWindow(
options.setWindow(settingsWindow);
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
logger.info("Loading settings URL:", settingsUrl);
logger.info('Loading settings URL:', settingsUrl);
let loadAttempts = 0;
const maxAttempts = 3;
@@ -59,15 +49,13 @@ export function openYomitanSettingsWindow(
settingsWindow
.loadURL(settingsUrl)
.then(() => {
logger.info("Settings URL loaded successfully");
logger.info('Settings URL loaded successfully');
})
.catch((err: Error) => {
logger.error("Failed to load settings URL:", err);
logger.error('Failed to load settings URL:', err);
loadAttempts++;
if (loadAttempts < maxAttempts && !settingsWindow.isDestroyed()) {
logger.info(
`Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`,
);
logger.info(`Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`);
setTimeout(attemptLoad, 500);
}
});
@@ -75,33 +63,23 @@ export function openYomitanSettingsWindow(
attemptLoad();
settingsWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
logger.error(
"Settings page failed to load:",
errorCode,
errorDescription,
);
},
);
settingsWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => {
logger.error('Settings page failed to load:', errorCode, errorDescription);
});
settingsWindow.webContents.on("did-finish-load", () => {
logger.info("Settings page loaded successfully");
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
});
setTimeout(() => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.setSize(
settingsWindow.getSize()[0],
settingsWindow.getSize()[1],
);
settingsWindow.setSize(settingsWindow.getSize()[0], settingsWindow.getSize()[1]);
settingsWindow.webContents.invalidate();
settingsWindow.show();
}
}, 500);
settingsWindow.on("closed", () => {
settingsWindow.on('closed', () => {
options.setWindow(null);
});
}