Run prettier across source files

This commit is contained in:
2026-04-03 14:04:07 -07:00
parent acb490fa10
commit c31e55398d
60 changed files with 615 additions and 534 deletions

View File

@@ -43,7 +43,10 @@ export interface CliInvocations {
function applyRootOptions(program: Command): void { function applyRootOptions(program: Command): void {
program program
.option('-b, --backend <backend>', 'Display backend (auto, hyprland, sway, x11, macos, windows)') .option(
'-b, --backend <backend>',
'Display backend (auto, hyprland, sway, x11, macos, windows)',
)
.option('-d, --directory <dir>', 'Directory to browse') .option('-d, --directory <dir>', 'Directory to browse')
.option('-a, --args <args>', 'Pass arguments to MPV') .option('-a, --args <args>', 'Pass arguments to MPV')
.option('-r, --recursive', 'Search directories recursively') .option('-r, --recursive', 'Search directories recursively')

View File

@@ -457,7 +457,9 @@ function withFindAppBinaryPlatformSandbox(
const originalPlatform = process.platform; const originalPlatform = process.platform;
try { try {
Object.defineProperty(process, 'platform', { value: platform, configurable: true }); Object.defineProperty(process, 'platform', { value: platform, configurable: true });
withFindAppBinaryEnvSandbox(() => run(platform === 'win32' ? (path.win32 as typeof path) : path)); withFindAppBinaryEnvSandbox(() =>
run(platform === 'win32' ? (path.win32 as typeof path) : path),
);
} finally { } finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
} }
@@ -495,110 +497,126 @@ function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: ()
} }
} }
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { concurrency: false }, () => { test(
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); 'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
const originalHomedir = os.homedir; { concurrency: false },
try { () => {
os.homedir = () => baseDir; const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage'); const originalHomedir = os.homedir;
makeExecutable(appImage); try {
os.homedir = () => baseDir;
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage);
withFindAppBinaryPlatformSandbox('linux', (pathModule) => { withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
const result = findAppBinary('/some/other/path/subminer', pathModule); const result = findAppBinary('/some/other/path/subminer', pathModule);
assert.equal(result, appImage); assert.equal(result, appImage);
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); },
);
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, () => { test(
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); 'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
const originalHomedir = os.homedir; { concurrency: false },
try { () => {
os.homedir = () => baseDir; const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
withFindAppBinaryPlatformSandbox('linux', (pathModule) => { const originalHomedir = os.homedir;
withAccessSyncStub( try {
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', os.homedir = () => baseDir;
() => { withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
const result = findAppBinary('/some/other/path/subminer', pathModule); withAccessSyncStub(
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); (filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
}, () => {
); const result = findAppBinary('/some/other/path/subminer', pathModule);
}); assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
} finally { },
os.homedir = originalHomedir; );
fs.rmSync(baseDir, { recursive: true, force: true }); });
} } finally {
}); os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, () => { test(
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-')); 'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
const originalHomedir = os.homedir; { concurrency: false },
const originalPath = process.env.PATH; () => {
try { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
os.homedir = () => baseDir; const originalHomedir = os.homedir;
// No AppImage candidates in empty home dir; place subminer wrapper on PATH const originalPath = process.env.PATH;
const binDir = path.join(baseDir, 'bin'); try {
const wrapperPath = path.join(binDir, 'subminer'); os.homedir = () => baseDir;
makeExecutable(wrapperPath); // No AppImage candidates in empty home dir; place subminer wrapper on PATH
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; const binDir = path.join(baseDir, 'bin');
const wrapperPath = path.join(binDir, 'subminer');
makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => { withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub( withAccessSyncStub(
(filePath) => filePath === wrapperPath, (filePath) => filePath === wrapperPath,
() => { () => {
// selfPath must differ from wrapperPath so the self-check does not exclude it // selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule); const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
assert.equal(result, wrapperPath); assert.equal(result, wrapperPath);
}, },
); );
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
process.env.PATH = originalPath; process.env.PATH = originalPath;
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); },
);
test('findAppBinary excludes PATH matches that canonicalize to the launcher path', { concurrency: false }, () => { test(
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-')); 'findAppBinary excludes PATH matches that canonicalize to the launcher path',
const originalHomedir = os.homedir; { concurrency: false },
const originalPath = process.env.PATH; () => {
try { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
os.homedir = () => baseDir; const originalHomedir = os.homedir;
const binDir = path.join(baseDir, 'bin'); const originalPath = process.env.PATH;
const wrapperPath = path.join(binDir, 'subminer'); try {
const canonicalPath = path.join(baseDir, 'launch', 'subminer'); os.homedir = () => baseDir;
makeExecutable(wrapperPath); const binDir = path.join(baseDir, 'bin');
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; const wrapperPath = path.join(binDir, 'subminer');
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => { withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub( withAccessSyncStub(
(filePath) => filePath === wrapperPath, (filePath) => filePath === wrapperPath,
() => { () => {
withRealpathSyncStub( withRealpathSyncStub(
(filePath) => { (filePath) => {
if (filePath === canonicalPath || filePath === wrapperPath) { if (filePath === canonicalPath || filePath === wrapperPath) {
return canonicalPath; return canonicalPath;
} }
return filePath; return filePath;
}, },
() => { () => {
const result = findAppBinary(canonicalPath, pathModule); const result = findAppBinary(canonicalPath, pathModule);
assert.equal(result, null); assert.equal(result, null);
}, },
); );
}, },
); );
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
process.env.PATH = originalPath; process.env.PATH = originalPath;
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); },
);
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => { test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-')); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
@@ -620,7 +638,10 @@ test('findAppBinary resolves Windows install paths when present', { concurrency:
withAccessSyncStub( withAccessSyncStub(
(filePath) => filePath === appExe, (filePath) => filePath === appExe,
() => { () => {
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule); const result = findAppBinary(
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
pathModule,
);
assert.equal(result, appExe); assert.equal(result, appExe);
}, },
); );
@@ -651,7 +672,10 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
withAccessSyncStub( withAccessSyncStub(
(filePath) => filePath === wrapperPath, (filePath) => filePath === wrapperPath,
() => { () => {
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule); const result = findAppBinary(
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
pathModule,
);
assert.equal(result, wrapperPath); assert.equal(result, wrapperPath);
}, },
); );
@@ -663,34 +687,41 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
} }
}); });
test('findAppBinary resolves a Windows install directory to SubMiner.exe', { concurrency: false }, () => { test(
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-')); 'findAppBinary resolves a Windows install directory to SubMiner.exe',
const originalHomedir = os.homedir; { concurrency: false },
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH; () => {
try { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
os.homedir = () => baseDir; const originalHomedir = os.homedir;
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner'); const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
const appExe = path.win32.join(installDir, 'SubMiner.exe');
process.env.SUBMINER_BINARY_PATH = installDir;
fs.mkdirSync(installDir, { recursive: true });
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appExe, 0o755);
const originalPlatform = process.platform;
try { try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); os.homedir = () => baseDir;
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32); const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
assert.equal(result, appExe); const appExe = path.win32.join(installDir, 'SubMiner.exe');
process.env.SUBMINER_BINARY_PATH = installDir;
fs.mkdirSync(installDir, { recursive: true });
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appExe, 0o755);
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
const result = findAppBinary(
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
path.win32,
);
assert.equal(result, appExe);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
} finally { } finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); os.homedir = originalHomedir;
if (originalSubminerBinaryPath === undefined) {
delete process.env.SUBMINER_BINARY_PATH;
} else {
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
}
fs.rmSync(baseDir, { recursive: true, force: true });
} }
} finally { },
os.homedir = originalHomedir; );
if (originalSubminerBinaryPath === undefined) {
delete process.env.SUBMINER_BINARY_PATH;
} else {
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
}
fs.rmSync(baseDir, { recursive: true, force: true });
}
});

View File

@@ -364,8 +364,12 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
} else if (process.platform === 'darwin') { } else if (process.platform === 'darwin') {
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner'); candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer'); candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner')); candidates.push(
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer')); pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
);
candidates.push(
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
);
} else { } else {
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage')); candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
candidates.push('/opt/SubMiner/SubMiner.AppImage'); candidates.push('/opt/SubMiner/SubMiner.AppImage');

View File

@@ -38,7 +38,11 @@ const lanes: Record<string, LaneConfig> = {
}, },
}; };
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] { function collectFiles(
rootDir: string,
includeSuffixes: string[],
excludeSet: Set<string>,
): string[] {
const out: string[] = []; const out: string[] = [];
const visit = (currentDir: string) => { const visit = (currentDir: string) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) { for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
@@ -145,7 +149,12 @@ function parseLcovReport(report: string): LcovRecord[] {
} }
if (line.startsWith('BRDA:')) { if (line.startsWith('BRDA:')) {
const [lineNumber, block, branch, hits] = line.slice(5).split(','); const [lineNumber, block, branch, hits] = line.slice(5).split(',');
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) { if (
lineNumber === undefined ||
block === undefined ||
branch === undefined ||
hits === undefined
) {
continue; continue;
} }
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, { ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
@@ -224,7 +233,9 @@ export function mergeLcovReports(reports: string[]): string {
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`); chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
} }
chunks.push(`FNF:${functions.length}`); chunks.push(`FNF:${functions.length}`);
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`); chunks.push(
`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`,
);
const branches = [...record.branches.values()].sort((a, b) => const branches = [...record.branches.values()].sort((a, b) =>
a.line === b.line a.line === b.line
@@ -298,7 +309,9 @@ function runCoverageLane(): number {
} }
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8'); writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`); process.stdout.write(
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
);
return 0; return 0;
} finally { } finally {
rmSync(shardRoot, { recursive: true, force: true }); rmSync(shardRoot, { recursive: true, force: true });

View File

@@ -369,7 +369,8 @@ export class AnkiIntegration {
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]); this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
}, },
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo), findDuplicateNoteIds: (expression, noteInfo) =>
this.findDuplicateNoteIds(expression, noteInfo),
recordCardsMinedCallback: (count, noteIds) => { recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation'); this.recordCardsMinedSafely(count, noteIds, 'card creation');
}, },
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
}); });
} }
private async findDuplicateNoteIds( private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
expression: string,
noteInfo: NoteInfo,
): Promise<number[]> {
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, { return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown, findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,

View File

@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
} }
try { try {
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody; const forwardedBody =
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const targetUrl = new URL(req.url || '/', upstreamUrl).toString(); const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
const contentType = const contentType =
typeof req.headers['content-type'] === 'string' typeof req.headers['content-type'] === 'string'
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> { private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
const action = const action =
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote') { if (action !== 'addNote') {
return requestJson; return requestJson;
} }
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds) const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
? params.subminerDuplicateNoteIds ? params.subminerDuplicateNoteIds
: []; : [];
return [...new Set(rawNoteIds.filter((entry): entry is number => { return [
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0; ...new Set(
}))].sort((left, right) => left - right); rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}),
),
].sort((left, right) => left - right);
} }
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean { private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {

View File

@@ -113,10 +113,7 @@ interface CardCreationDeps {
setUpdateInProgress: (value: boolean) => void; setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void; trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: ( findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
expression: string,
noteInfo: CardCreationNoteInfo,
) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
} }
@@ -573,10 +570,7 @@ export class CardCreationService {
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo), await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
); );
} catch (error) { } catch (error) {
log.warn( log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
'Failed to capture pre-add duplicate note ids:',
(error as Error).message,
);
} }
} }
@@ -728,9 +722,7 @@ export class CardCreationService {
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo { private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return { return {
noteId: -1, noteId: -1,
fields: Object.fromEntries( fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
Object.entries(fields).map(([name, value]) => [name, { value }]),
),
}; };
} }

View File

@@ -307,21 +307,27 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
}; };
let notesInfoCalls = 0; let notesInfoCalls = 0;
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, { const duplicateIds = await findDuplicateNoteIds(
findNotes: async () => [200], '貴様',
notesInfo: async (noteIds) => { 100,
notesInfoCalls += 1; currentNote,
return noteIds.map((noteId) => ({ {
noteId, findNotes: async () => [200],
fields: { notesInfo: async (noteIds) => {
Expression: { value: '貴様' }, notesInfoCalls += 1;
}, return noteIds.map((noteId) => ({
})); noteId,
fields: {
Expression: { value: '貴様' },
},
}));
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
}, },
getDeck: () => 'Japanese::Mining', 0,
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), );
logWarn: () => {},
}, 0);
assert.deepEqual(duplicateIds, []); assert.deepEqual(duplicateIds, []);
assert.equal(notesInfoCalls, 0); assert.equal(notesInfoCalls, 0);

View File

@@ -24,13 +24,7 @@ export async function findDuplicateNote(
noteInfo: NoteInfo, noteInfo: NoteInfo,
deps: DuplicateDetectionDeps, deps: DuplicateDetectionDeps,
): Promise<number | null> { ): Promise<number | null> {
const duplicateNoteIds = await findDuplicateNoteIds( const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
expression,
excludeNoteId,
noteInfo,
deps,
1,
);
return duplicateNoteIds[0] ?? null; return duplicateNoteIds[0] ?? null;
} }

View File

@@ -35,17 +35,8 @@ const {
startupWarmups, startupWarmups,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
ankiConnect, INTEGRATIONS_DEFAULT_CONFIG;
jimaku,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG;

View File

@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
}, },
{ {
title: 'YouTube Playback Settings', title: 'YouTube Playback Settings',
description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'], description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
key: 'youtube', key: 'youtube',
}, },
{ {

View File

@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
}, },
}; };
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition { function resolvePresenceStyle(
preset: DiscordPresenceStylePreset | undefined,
): PresenceStyleDefinition {
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default; return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
} }
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
const status = buildStatus(snapshot); const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const details = const details =
snapshot.connected && snapshot.mediaPath snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
? trimField(title)
: style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state = const state =
snapshot.connected && snapshot.mediaPath snapshot.connected && snapshot.mediaPath
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
if (style.smallImageText.trim().length > 0) { if (style.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(style.smallImageText.trim()); activity.smallImageText = trimField(style.smallImageText.trim());
} }
if ( if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
style.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(style.buttonUrl.trim())
) {
activity.buttons = [ activity.buttons = [
{ {
label: trimField(style.buttonLabel.trim(), 32), label: trimField(style.buttonLabel.trim(), 32),

View File

@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
}; };
}; };
const eventsRetention = daysToRetentionWindow( const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
retention.eventsDays, const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
7, const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
3650,
);
const telemetryRetention = daysToRetentionWindow(
retention.telemetryDays,
30,
3650,
);
const sessionsRetention = daysToRetentionWindow(
retention.sessionsDays,
30,
3650,
);
this.eventsRetentionMs = eventsRetention.ms; this.eventsRetentionMs = eventsRetention.ms;
this.eventsRetentionDays = eventsRetention.days; this.eventsRetentionDays = eventsRetention.days;
this.telemetryRetentionMs = telemetryRetention.ms; this.telemetryRetentionMs = telemetryRetention.ms;
this.telemetryRetentionDays = telemetryRetention.days; this.telemetryRetentionDays = telemetryRetention.days;
this.sessionsRetentionMs = sessionsRetention.ms; this.sessionsRetentionMs = sessionsRetention.ms;
this.sessionsRetentionDays = sessionsRetention.days; this.sessionsRetentionDays = sessionsRetention.days;
this.dailyRollupRetentionMs = daysToRetentionWindow( this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
retention.dailyRollupsDays,
365,
36500,
).ms;
this.monthlyRollupRetentionMs = daysToRetentionWindow( this.monthlyRollupRetentionMs = daysToRetentionWindow(
retention.monthlyRollupsDays, retention.monthlyRollupsDays,
5 * 365, 5 * 365,
36500, 36500,
).ms; ).ms;
this.vacuumIntervalMs = daysToRetentionWindow( this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
retention.vacuumIntervalDays,
7,
3650,
).ms;
this.db = new Database(this.dbPath); this.db = new Database(this.dbPath);
applyPragmas(this.db); applyPragmas(this.db);
ensureSchema(this.db); ensureSchema(this.db);

View File

@@ -975,79 +975,79 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
); );
} }
const insertDailyRollup = db.prepare( const insertDailyRollup = db.prepare(
` `
INSERT INTO imm_daily_rollups ( INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
const insertMonthlyRollup = db.prepare( const insertMonthlyRollup = db.prepare(
` `
INSERT INTO imm_monthly_rollups ( INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
db.prepare( db.prepare(
` `
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
'二月', '二月',
'二月', '二月',
'にがつ', 'にがつ',
'noun', 'noun',
'名詞', '名詞',
'', '',
'', '',
(BigInt(febStartedAtMs) / 1000n).toString(), (BigInt(febStartedAtMs) / 1000n).toString(),
(BigInt(febStartedAtMs) / 1000n).toString(), (BigInt(febStartedAtMs) / 1000n).toString(),
1, 1,
); );
db.prepare( db.prepare(
` `
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
'三月', '三月',
'三月', '三月',
'さんがつ', 'さんがつ',
'noun', 'noun',
'名詞', '名詞',
'', '',
'', '',
(BigInt(marStartedAtMs) / 1000n).toString(), (BigInt(marStartedAtMs) / 1000n).toString(),
(BigInt(marStartedAtMs) / 1000n).toString(), (BigInt(marStartedAtMs) / 1000n).toString(),
1, 1,
); );
const dashboard = getTrendsDashboard(db, '30d', 'month'); const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2); assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual( assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label), dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
); );
assert.deepEqual( assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label), dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
); );
assert.deepEqual( assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label), dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
); );
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
'猫',
'猫',
'ねこ',
'noun',
'名詞',
'',
'',
String(twoDaysAgo),
String(twoDaysAgo),
1,
);
const hints = getQueryHints(db); const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1); assert.equal(hints.newWordsToday, 1);

View File

@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
LIMIT 1 LIMIT 1
`, `,
) )
.get( .get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
videoId, found: number;
toDbTimestamp(startedAtMs), } | null;
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
return Boolean(row); return Boolean(row);
} }
@@ -150,7 +147,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE global_id = 1 WHERE global_id = 1
`, `,
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs)); ).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
} }
function rebuildLifetimeSummariesInternal( function rebuildLifetimeSummariesInternal(

View File

@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
const remainingTelemetry = db const remainingTelemetry = db
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry') .prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
.get() as { count: number }; .get() as { count: number };
const remainingSessions = db const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
.prepare('SELECT COUNT(*) AS count FROM imm_sessions') count: number;
.get() as { count: number }; };
assert.equal(result.deletedSessionEvents, 0); assert.equal(result.deletedSessionEvents, 0);
assert.equal(result.deletedTelemetryRows, 0); assert.equal(result.deletedTelemetryRows, 0);

View File

@@ -56,10 +56,7 @@ export function pruneRawRetention(
sessionsRetentionDays?: number; sessionsRetentionDays?: number;
}, },
): RawRetentionResult { ): RawRetentionResult {
const resolveCutoff = ( const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
retentionMs: number,
retentionDays: number | undefined,
): string => {
if (retentionDays !== undefined) { if (retentionDays !== undefined) {
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n); return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
} }
@@ -68,9 +65,11 @@ export function pruneRawRetention(
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs) const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? ( ? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run( db
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays), .prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
) as { changes: number } .run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
changes: number;
}
).changes ).changes
: 0; : 0;
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs) const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)

View File

@@ -150,9 +150,11 @@ export function getSessionEvents(
ORDER BY ts_ms ASC ORDER BY ts_ms ASC
LIMIT ? LIMIT ?
`); `);
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & { const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
tsMs: number | string; SessionEventRow & {
}>; tsMs: number | string;
}
>;
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
tsMs: fromDbTimestamp(row.tsMs) ?? 0, tsMs: fromDbTimestamp(row.tsMs) ?? 0,

View File

@@ -355,9 +355,7 @@ export function upsertCoverArt(
const fetchedAtMs = toDbTimestamp(nowMs()); const fetchedAtMs = toDbTimestamp(nowMs());
const coverBlob = normalizeCoverBlobBytes(art.coverBlob); const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
const computedCoverBlobHash = const computedCoverBlobHash =
coverBlob && coverBlob.length > 0 coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
? createHash('sha256').update(coverBlob).digest('hex')
: null;
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null; let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) { if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
coverBlobHash = existing?.coverBlobHash ?? null; coverBlobHash = existing?.coverBlobHash ?? null;

View File

@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
ORDER BY s.started_at_ms DESC ORDER BY s.started_at_ms DESC
LIMIT ? LIMIT ?
`); `);
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & { const rows = prepared.all(limit) as Array<
startedAtMs: number | string; SessionSummaryQueryRow & {
endedAtMs: number | string | null; startedAtMs: number | string;
}>; endedAtMs: number | string | null;
}
>;
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0, startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
@@ -69,19 +71,21 @@ export function getSessionTimeline(
`; `;
if (limit === undefined) { if (limit === undefined) {
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & { const rows = db.prepare(select).all(sessionId) as Array<
sampleMs: number | string; SessionTimelineRow & {
}>; sampleMs: number | string;
}
>;
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0, sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
})); }));
} }
const rows = db const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
.prepare(`${select}\n LIMIT ?`) SessionTimelineRow & {
.all(sessionId, limit) as Array<SessionTimelineRow & { sampleMs: number | string;
sampleMs: number | string; }
}>; >;
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0, sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,

View File

@@ -359,10 +359,7 @@ function getNumericCalendarValue(
return Number(row?.value ?? 0); return Number(row?.value ?? 0);
} }
export function getLocalEpochDay( export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
return getNumericCalendarValue( return getNumericCalendarValue(
db, db,
` `
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
); );
} }
export function getLocalMonthKey( export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
return getNumericCalendarValue( return getNumericCalendarValue(
db, db,
` `
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
); );
} }
export function getLocalDayOfWeek( export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
return getNumericCalendarValue( return getNumericCalendarValue(
db, db,
` `
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
); );
} }
export function getLocalHourOfDay( export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
return getNumericCalendarValue( return getNumericCalendarValue(
db, db,
` `
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
dayOffset: number, dayOffset: number,
): string { ): string {
const normalizedDayOffset = Math.trunc(dayOffset); const normalizedDayOffset = Math.trunc(dayOffset);
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`; const modifier =
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
const row = db const row = db
.prepare( .prepare(
` `

View File

@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'90d': 90, '90d': 90,
}; };
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
} }
const currentTimestamp = currentDbTimestamp(); const currentTimestamp = currentDbTimestamp();
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0); const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1)); const cutoffMs = getShiftedLocalDayTimestamp(
db,
currentTimestamp,
-(TREND_DAY_LIMITS[range] - 1),
);
const currentMonthKey = getLocalMonthKey(db, todayStartMs); const currentMonthKey = getLocalMonthKey(db, todayStartMs);
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs); const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
const currentYear = Math.floor(currentMonthKey / 100); const currentYear = Math.floor(currentMonthKey / 100);
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
const animePerDay = { const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId), episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) => watchTime: buildPerAnimeFromDailyRollups(
rollup.totalActiveMin, dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
), ),
cards: buildPerAnimeFromDailyRollups( cards: buildPerAnimeFromDailyRollups(
dailyRollups, dailyRollups,

View File

@@ -102,7 +102,9 @@ type SubtitleTrackCandidate = {
externalFilename: string | null; externalFilename: string | null;
}; };
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null { function normalizeSubtitleTrackCandidate(
track: Record<string, unknown>,
): SubtitleTrackCandidate | null {
const id = const id =
typeof track.id === 'number' typeof track.id === 'number'
? track.id ? track.id
@@ -122,8 +124,12 @@ function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): Subtit
return { return {
id, id,
lang: String(track.lang || '').trim().toLowerCase(), lang: String(track.lang || '')
title: String(track.title || '').trim().toLowerCase(), .trim()
.toLowerCase(),
title: String(track.title || '')
.trim()
.toLowerCase(),
selected: track.selected === true, selected: track.selected === true,
external: track.external === true, external: track.external === true,
externalFilename, externalFilename,
@@ -168,9 +174,7 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()]; const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) { for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find( const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
(track) => track.selected && track.lang === language,
);
if (selectedMatch) { if (selectedMatch) {
return selectedMatch.id; return selectedMatch.id;
} }

View File

@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
res.end(Buffer.from(body)); res.end(Buffer.from(body));
} }
function startNodeHttpServer( function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
app: Hono,
config: StatsServerConfig,
): { close: () => void } {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
void (async () => { void (async () => {
try { try {
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
const bunRuntime = globalThis as typeof globalThis & { const bunRuntime = globalThis as typeof globalThis & {
Bun?: { Bun?: {
serve?: (options: { serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
fetch: (typeof app)['fetch']; stop: () => void;
port: number; };
hostname: string;
}) => { stop: () => void };
}; };
}; };

View File

@@ -77,7 +77,11 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
} }
function getSourceTrackIdentity(track: MpvTrack): string { function getSourceTrackIdentity(track: MpvTrack): string {
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) { if (
track.external &&
typeof track['external-filename'] === 'string' &&
track['external-filename'].length > 0
) {
return `external:${track['external-filename'].toLowerCase()}`; return `external:${track['external-filename'].toLowerCase()}`;
} }
if (typeof track.id === 'number') { if (typeof track.id === 'number') {

View File

@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
: null, : null,
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds) duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
? envelope.duplicateNoteIds.filter( ? envelope.duplicateNoteIds.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0, (entry): entry is number =>
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
) )
: [], : [],
}; };

View File

@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void { function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp'); const scriptPath = path.join(dir, 'yt-dlp');
const script = process.platform === 'win32' const script =
? `#!/usr/bin/env bun process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)}); process.stdout.write(${JSON.stringify(payload)});
` `
: `#!/usr/bin/env sh : `#!/usr/bin/env sh
cat <<'EOF' | base64 -d cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')} ${Buffer.from(payload).toString('base64')}
EOF EOF

View File

@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void { function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp'); const scriptPath = path.join(dir, 'yt-dlp');
const script = process.platform === 'win32' const script =
? `#!/usr/bin/env bun process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)}); process.stdout.write(${JSON.stringify(payload)});
` `
: `#!/usr/bin/env sh : `#!/usr/bin/env sh
cat <<'EOF' | base64 -d cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')} ${Buffer.from(payload).toString('base64')}
EOF EOF

View File

@@ -114,12 +114,7 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'], ['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
); );
assert.deepEqual( assert.deepEqual(
normalizeLaunchMpvExtraArgs([ normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
'SubMiner.exe',
'--launch-mpv',
'--fullscreen',
'C:\\a.mkv',
]),
['--fullscreen'], ['--fullscreen'],
); );
assert.deepEqual( assert.deepEqual(

View File

@@ -77,15 +77,15 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return { return {
shouldUseMinimalStartup: Boolean( shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) || (initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats && (initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
), ),
shouldSkipHeavyStartup: Boolean( shouldSkipHeavyStartup: Boolean(
initialArgs && initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) || (shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats || initialArgs.stats ||
initialArgs.dictionary || initialArgs.dictionary ||
initialArgs.setup), initialArgs.setup),
), ),
}; };
} }
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options'; import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger'; import {
createLogger,
setLogLevel,
resolveDefaultLogFilePath,
type LogLevelSource,
} from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import { import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
@@ -496,7 +501,10 @@ import {
} from './config'; } from './config';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch'; import {
createSubtitlePrefetchService,
type SubtitlePrefetchService,
} from './core/services/subtitle-prefetch';
import { import {
buildSubtitleSidebarSourceKey, buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath, resolveSubtitleSourcePath,
@@ -1412,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos, getLastObservedTimePos: () => lastObservedTimePos,
subtitlePrefetchInitController, subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
resolveActiveSubtitleSidebarSourceHandler(input),
}); });
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
@@ -1426,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
const subtitlePrefetchRuntime = { const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath), refreshSubtitleSidebarFromSource: (sourcePath: string) =>
refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
@@ -1861,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
}, },
})(), })(),
); );
const buildGetRuntimeOptionsStateMainDepsHandler = const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
createBuildGetRuntimeOptionsStateMainDepsHandler({ {
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
}); },
);
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler(); const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler( const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
getRuntimeOptionsStateMainDeps, getRuntimeOptionsStateMainDeps,
@@ -3042,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
}, },
getImmersionTracker: () => appState.immersionTracker, getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(), ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(), stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
openExternal: (url: string) => shell.openExternal(url), openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => { writeResponse: (responsePath, payload) => {
@@ -3258,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () => shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () => shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
createImmersionTracker: () => { createImmersionTracker: () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },

View File

@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void }, { scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean }, { registry: boolean },
{ getModalWindow: () => null }, { getModalWindow: () => null },
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void }, {
inputState: boolean;
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
},
{ measurementStore: boolean }, { measurementStore: boolean },
{ modalRuntime: boolean }, { modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number }, { mpvSocketPath: string; texthookerPort: number },
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({ createOverlayManager: () => ({
getModalWindow: () => null, getModalWindow: () => null,
}), }),
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }), createOverlayModalInputState: () => ({
inputState: true,
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }), createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {}, getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {}, getSyncOverlayVisibilityForModal: () => () => {},
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
mpvSocketPath: '/tmp/subminer.sock', mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174, texthookerPort: 5174,
}); });
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp); assert.equal(
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp); services.appLifecycleApp.on('ready', () => {}),
services.appLifecycleApp,
);
assert.equal(
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
assert.deepEqual(appOnCalls, ['ready']); assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true); assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']); assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);

View File

@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
}; };
shouldBypassSingleInstanceLock: () => boolean; shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean; requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: ( registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
listener: (_event: unknown, argv: string[]) => void,
) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void; onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService; createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore; createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
overlayModalInputState: TOverlayModalInputState; overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void; onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime; }) => TOverlayModalRuntime;
createAppState: (input: { createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
} }
export interface MainBootServicesResult< export interface MainBootServicesResult<
@@ -239,9 +234,7 @@ export function createMainBootServices<
const appLifecycleApp = { const appLifecycleApp = {
requestSingleInstanceLock: () => requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock() params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
? true
: params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(), quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => { on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') { if (event === 'second-instance') {

View File

@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const extraLength = archive.readUInt16LE(cursor + 28); const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30; const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength; const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString( const fileName = archive
'utf8', .subarray(fileNameStart, fileNameStart + fileNameLength)
); .toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize); const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data)); entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize; cursor = dataStart + compressedSize;
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}) as typeof fs.writeFileSync; }) as typeof fs.writeFileSync;
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => { Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`); throw new Error(
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
);
}) as typeof Buffer.concat; }) as typeof Buffer.concat;
const result = buildDictionaryZip( const result = buildDictionaryZip(
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
assert.equal(indexJson.revision, '2026-03-27'); assert.equal(indexJson.revision, '2026-03-27');
assert.equal(indexJson.format, 3); assert.equal(indexJson.format, 3);
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as const termBank = JSON.parse(
CharacterDictionaryTermEntry[]; entries.get('term_bank_1.json')!.toString('utf8'),
) as CharacterDictionaryTermEntry[];
assert.equal(termBank.length, 1); assert.equal(termBank.length, 1);
assert.equal(termBank[0]?.[0], 'アルファ'); assert.equal(termBank[0]?.[0], 'アルファ');
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3])); assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));

View File

@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
return central; return central;
} }
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer { function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22); const end = Buffer.alloc(22);
let cursor = 0; let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor); writeUint32LE(end, 0x06054b50, cursor);

View File

@@ -37,13 +37,13 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
firstScheduled?.(); firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [ assert.deepEqual(
['script-message', 'subminer-autoplay-ready'], commands.filter((command) => command[0] === 'script-message'),
]); [['script-message', 'subminer-autoplay-ready']],
);
assert.ok( assert.ok(
commands.some( commands.some(
(command) => (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
), ),
); );
assert.equal(scheduled.length > 0, true); assert.equal(scheduled.length > 0, true);
@@ -84,13 +84,13 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [ assert.deepEqual(
['script-message', 'subminer-autoplay-ready'], commands.filter((command) => command[0] === 'script-message'),
]); [['script-message', 'subminer-autoplay-ready']],
);
assert.equal( assert.equal(
commands.filter( commands.filter(
(command) => (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0, ).length > 0,
true, true,
); );
@@ -130,13 +130,15 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true; playbackPaused = true;
gate.maybeSignalPluginAutoplayReady({ text: '字幕その2', tokens: null }, { forceWhilePaused: true }); gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal( assert.equal(
commands.filter( commands.filter(
(command) => (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length, ).length,
1, 1,
); );

View File

@@ -40,9 +40,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
} }
const mediaPath = const mediaPath =
deps.getCurrentMediaPath()?.trim() || deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200; const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({ const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -85,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) { if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); deps.schedule(
() => attemptRelease(playbackGeneration, attempt + 1),
releaseRetryDelayMs,
);
} }
return; return;
} }

View File

@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(handled, false); assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs // handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc'); const handledProtocol = composed.handleAnilistSetupProtocolUrl(
'subminer://anilist-setup?code=abc',
);
assert.equal(handledProtocol, true); assert.equal(handledProtocol, true);
}); });

View File

@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openJellyfinSetupWindow: () => {}, openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never, getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }), processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () => generateCharacterDictionary: async () => ({
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }), zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {}, runYoutubePlaybackFlow: async () => {},

View File

@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
export function composeCliStartupHandlers( export function composeCliStartupHandlers(
options: CliStartupComposerOptions, options: CliStartupComposerOptions,
): CliStartupComposerResult { ): CliStartupComposerResult {
const createCliCommandContext = createCliCommandContextFactory( const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
options.cliCommandContextMainDeps,
);
const handleCliCommand = createCliCommandRuntimeHandler({ const handleCliCommand = createCliCommandRuntimeHandler({
...options.cliCommandRuntimeHandlerMainDeps, ...options.cliCommandRuntimeHandlerMainDeps,
createCliCommandContext: () => createCliCommandContext(), createCliCommandContext: () => createCliCommandContext(),

View File

@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>; >;
export type HeadlessStartupComposerOptions< export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
TCliArgs, ComposerInputs<{
TStartupState, startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TStartupBootstrapRuntimeDeps, TCliArgs,
> = ComposerInputs<{ TStartupState,
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps< TStartupBootstrapRuntimeDeps
TCliArgs, >;
TStartupState, }>;
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerResult< export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
TCliArgs, ComposerOutputs<
TStartupState, Pick<
TStartupBootstrapRuntimeDeps, StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
> = ComposerOutputs< 'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
Pick< >
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>, >;
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export function composeHeadlessStartupHandlers< export function composeHeadlessStartupHandlers<
TCliArgs, TCliArgs,

View File

@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => { test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0; let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null }; let activePlayback: unknown = {
itemId: 'item-1',
mediaSourceId: 'src-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = []; const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({ const composed = composeJellyfinRemoteHandlers({

View File

@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
updateMpvSubtitleRenderMetricsMainDeps: { updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS, getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {}, setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({ applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => ({
next: { ...current, ...patch }, next: { ...current, ...patch },
changed: true, changed: true,
}), }),

View File

@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
options: OverlayVisibilityRuntimeComposerOptions, options: OverlayVisibilityRuntimeComposerOptions,
): OverlayVisibilityRuntimeComposerResult { ): OverlayVisibilityRuntimeComposerResult {
return { return {
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), updateVisibleOverlayVisibility: () =>
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler( restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler( createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
options.restorePreviousSecondarySubVisibilityMainDeps, options.restorePreviousSecondarySubVisibilityMainDeps,

View File

@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient { export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
return { return {
login: () => client.login(), login: () => client.login(),
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined), setActivity: (activity) =>
requireUser(client)
.setActivity(activity)
.then(() => undefined),
clearActivity: () => requireUser(client).clearActivity(), clearActivity: () => requireUser(client).clearActivity(),
destroy: () => client.destroy(), destroy: () => client.destroy(),
}; };
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
export function createDiscordRpcClient( export function createDiscordRpcClient(
clientId: string, clientId: string,
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient }, deps?: {
createClient?: (options: {
clientId: string;
transport: { type: 'ipc' };
}) => DiscordRpcRawClient;
},
): DiscordRpcClient { ): DiscordRpcClient {
const client = const client =
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ?? deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??

View File

@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome); const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true }); fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n'); fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({ const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux', platform: 'linux',

View File

@@ -187,7 +187,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
return { updated: false, configPath: installPaths.pluginConfigPath }; return { updated: false, configPath: installPaths.pluginConfigPath };
} }
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath); const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') { if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath); rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
} }

View File

@@ -139,7 +139,10 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
}); });
assert.match(html, /External profile configured/); assert.match(html, /External profile configured/);
assert.match(html, /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./); assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
}); });
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
@@ -155,7 +158,10 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh', action: 'refresh',
}); });
assert.equal(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), null); assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
);
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null); assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
}); });

View File

@@ -109,7 +109,9 @@ function pickBestTrackId(
if (left.track.external !== right.track.external) { if (left.track.external !== right.track.external) {
return left.track.external ? -1 : 1; return left.track.external ? -1 : 1;
} }
if (isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)) { if (
isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)
) {
return isLikelyHearingImpaired(left.track.title) ? 1 : -1; return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
} }
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) { if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
@@ -130,7 +132,9 @@ export function resolveManagedLocalSubtitleSelection(input: {
secondaryLanguages: string[]; secondaryLanguages: string[];
}): ManagedLocalSubtitleSelection { }): ManagedLocalSubtitleSelection {
const tracks = Array.isArray(input.trackList) const tracks = Array.isArray(input.trackList)
? input.trackList.map(normalizeTrack).filter((track): track is NormalizedSubtitleTrack => track !== null) ? input.trackList
.map(normalizeTrack)
.filter((track): track is NormalizedSubtitleTrack => track !== null)
: []; : [];
const preferredPrimaryLanguages = normalizeLanguageList( const preferredPrimaryLanguages = normalizeLanguageList(
input.primaryLanguages, input.primaryLanguages,
@@ -165,12 +169,10 @@ function normalizeLocalMediaPath(mediaPath: string | null | undefined): string |
export function createManagedLocalSubtitleSelectionRuntime(deps: { export function createManagedLocalSubtitleSelectionRuntime(deps: {
getCurrentMediaPath: () => string | null; getCurrentMediaPath: () => string | null;
getMpvClient: () => getMpvClient: () => {
| { connected?: boolean;
connected?: boolean; requestProperty?: (name: string) => Promise<unknown>;
requestProperty?: (name: string) => Promise<unknown>; } | null;
}
| null;
getPrimarySubtitleLanguages: () => string[]; getPrimarySubtitleLanguages: () => string[];
getSecondarySubtitleLanguages: () => string[]; getSecondarySubtitleLanguages: () => string[];
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void; sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;

View File

@@ -38,7 +38,8 @@ export function createPlaylistBrowserIpcRuntime(
return { return {
playlistBrowserRuntimeDeps, playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: { playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps), getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) => appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath), appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) => playPlaylistBrowserIndex: (index: number) =>

View File

@@ -102,7 +102,8 @@ function createFakeMpvClient(options: {
if (removingCurrent) { if (removingCurrent) {
syncFlags(); syncFlags();
this.currentVideoPath = this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath; playlist.find((item) => item.current || item.playing)?.filename ??
this.currentVideoPath;
} }
return true; return true;
} }
@@ -276,7 +277,10 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
['set_property', 'sub-auto', 'fuzzy'], ['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1], ['playlist-play-index', 1],
]); ]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]); assert.deepEqual(
scheduled.map((entry) => entry.delayMs),
[400],
);
scheduled[0]?.callback(); scheduled[0]?.callback();
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(mpvClient.getCommands().slice(-2), [ assert.deepEqual(mpvClient.getCommands().slice(-2), [
@@ -382,10 +386,7 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
const mpvClient = createFakeMpvClient({ const mpvClient = createFakeMpvClient({
currentVideoPath: episode1, currentVideoPath: episode1,
playlist: [ playlist: [{ filename: episode1, current: true }, { filename: episode2 }],
{ filename: episode1, current: true },
{ filename: episode2 },
],
}); });
const deps = { const deps = {
@@ -486,17 +487,14 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
scheduled[1]?.(); scheduled[1]?.();
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual( assert.deepEqual(mpvClient.getCommands().slice(-6), [
mpvClient.getCommands().slice(-6), ['set_property', 'sub-auto', 'fuzzy'],
[ ['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'], ['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1], ['playlist-play-index', 2],
['set_property', 'sub-auto', 'fuzzy'], ['set_property', 'sid', 'auto'],
['playlist-play-index', 2], ['set_property', 'secondary-sid', 'auto'],
['set_property', 'sid', 'auto'], ]);
['set_property', 'secondary-sid', 'auto'],
],
);
}); });
test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => { test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => {

View File

@@ -63,7 +63,10 @@ async function resolveCurrentFilePath(
function resolveDirectorySnapshot( function resolveDirectorySnapshot(
currentFilePath: string | null, currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> { ): Pick<
PlaylistBrowserSnapshot,
'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'
> {
if (!currentFilePath) { if (!currentFilePath) {
return { return {
directoryAvailable: false, directoryAvailable: false,

View File

@@ -34,10 +34,7 @@ export function getConfiguredWindowsMpvPathStatus(
return fileExists(configPath) ? 'configured' : 'invalid'; return fileExists(configPath) ? 'configured' : 'invalid';
} }
export function resolveWindowsMpvPath( export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
deps: WindowsMpvLaunchDeps,
configuredMpvPath = '',
): string {
const configPath = normalizeCandidate(configuredMpvPath); const configPath = normalizeCandidate(configuredMpvPath);
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists); const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') { if (configuredPathStatus === 'configured') {
@@ -178,9 +175,7 @@ export function createWindowsMpvLaunchDeps(options: {
error: result.error ?? undefined, error: result.error ?? undefined,
}; };
}, },
fileExists: fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
options.fileExists ??
defaultWindowsMpvFileExists,
spawnDetached: (command, args) => spawnDetached: (command, args) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {

View File

@@ -92,7 +92,9 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
]); ]);
launchedWindowsMpv = launchResult.ok; launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) { if (launchResult.ok && launchResult.mpvPath) {
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`); deps.logInfo(
`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`,
);
} }
if (!launchResult.ok) { if (!launchResult.ok) {
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.'); deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');

View File

@@ -358,12 +358,10 @@ export function createKeyboardHandlers(
}); });
} }
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] { function isSubtitleSeekCommand(
return ( command: (string | number)[] | undefined,
Array.isArray(command) && ): command is [string, number] {
command[0] === 'sub-seek' && return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
typeof command[1] === 'number'
);
} }
function dispatchConfiguredMpvCommand(command: (string | number)[]): void { function dispatchConfiguredMpvCommand(command: (string | number)[]): void {

View File

@@ -1,7 +1,4 @@
import type { import type { PlaylistBrowserDirectoryItem, PlaylistBrowserQueueItem } from '../../types';
PlaylistBrowserDirectoryItem,
PlaylistBrowserQueueItem,
} from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
type PlaylistBrowserRowRenderActions = { type PlaylistBrowserRowRenderActions = {
@@ -55,7 +52,7 @@ export function renderPlaylistBrowserDirectoryRow(
? item.episodeLabel ? item.episodeLabel
? `${item.episodeLabel} · Current file` ? `${item.episodeLabel} · Current file`
: 'Current file' : 'Current file'
: item.episodeLabel ?? 'Video file'; : (item.episodeLabel ?? 'Video file');
main.append(label, meta); main.append(label, meta);
const trailing = document.createElement('div'); const trailing = document.createElement('div');

View File

@@ -236,9 +236,17 @@ function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): Ele
notifyOverlayModalClosed: () => {}, notifyOverlayModalClosed: () => {},
focusMainWindow: async () => {}, focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {}, setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), appendPlaylistBrowserFile: async () => ({
ok: true,
message: 'ok',
snapshot: createSnapshot(),
}),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), removePlaylistBrowserIndex: async () => ({
ok: true,
message: 'ok',
snapshot: createSnapshot(),
}),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
...overrides, ...overrides,
} as ElectronAPI; } as ElectronAPI;
@@ -348,15 +356,13 @@ test('playlist browser modal action buttons stop double-click propagation', asyn
await modal.openPlaylistBrowserModal(); await modal.openPlaylistBrowserModal();
const row = const row = env.dom.playlistBrowserDirectoryList.children[0] as
env.dom.playlistBrowserDirectoryList.children[0] as | ReturnType<typeof createPlaylistRow>
| ReturnType<typeof createPlaylistRow> | undefined;
| undefined;
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined; const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
const button = const button = trailing?.children?.at(-1) as
trailing?.children?.at(-1) as | { listeners?: Map<string, Array<(event?: unknown) => void>> }
| { listeners?: Map<string, Array<(event?: unknown) => void>> } | undefined;
| undefined;
const dblclickHandler = button?.listeners?.get('dblclick')?.[0]; const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
assert.equal(typeof dblclickHandler, 'function'); assert.equal(typeof dblclickHandler, 'function');

View File

@@ -31,7 +31,8 @@ function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): n
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number { function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
const playlistIndex = const playlistIndex =
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing); snapshot.playingIndex ??
snapshot.playlistItems.findIndex((item) => item.current || item.playing);
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length); return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
} }
@@ -225,7 +226,10 @@ export function createPlaylistBrowserModal(
} }
async function removePlaylistItem(index: number): Promise<void> { async function removePlaylistItem(index: number): Promise<void> {
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item'); await handleMutation(
window.electronAPI.removePlaylistBrowserIndex(index),
'Removed queue item',
);
} }
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> { async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {

View File

@@ -453,8 +453,7 @@ body {
padding: 14px; padding: 14px;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(110, 115, 141, 0.16); border: 1px solid rgba(110, 115, 141, 0.16);
background: background: linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
@@ -496,8 +495,12 @@ body {
} }
.playlist-browser-row.current { .playlist-browser-row.current {
background: background: linear-gradient(
linear-gradient(90deg, rgba(138, 173, 244, 0.12), rgba(138, 173, 244, 0.03) 28%, transparent); 90deg,
rgba(138, 173, 244, 0.12),
rgba(138, 173, 244, 0.03) 28%,
transparent
);
box-shadow: inset 3px 0 0 #8aadf4; box-shadow: inset 3px 0 0 #8aadf4;
} }

View File

@@ -222,8 +222,12 @@ export function resolveRendererDom(): RendererDom {
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'), playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'), playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'), playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'), playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>(
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'), 'playlistBrowserDirectoryList',
),
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>(
'playlistBrowserPlaylistList',
),
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'), playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
}; };
} }

View File

@@ -47,13 +47,10 @@ test('RuntimeOptionsManager returns detached effective Anki config copies', () =
}, },
}; };
const manager = new RuntimeOptionsManager( const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
() => structuredClone(baseConfig), applyAnkiPatch: () => undefined,
{ onOptionsChanged: () => undefined,
applyAnkiPatch: () => undefined, });
onOptionsChanged: () => undefined,
},
);
const effective = manager.getEffectiveAnkiConnectConfig(); const effective = manager.getEffectiveAnkiConnectConfig();
effective.tags!.push('mutated'); effective.tags!.push('mutated');