mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Run prettier across source files
This commit is contained in:
@@ -43,7 +43,10 @@ export interface CliInvocations {
|
||||
|
||||
function applyRootOptions(program: Command): void {
|
||||
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('-a, --args <args>', 'Pass arguments to MPV')
|
||||
.option('-r, --recursive', 'Search directories recursively')
|
||||
|
||||
@@ -457,7 +457,9 @@ function withFindAppBinaryPlatformSandbox(
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
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 {
|
||||
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 }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
test(
|
||||
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(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 });
|
||||
}
|
||||
});
|
||||
test(
|
||||
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
test(
|
||||
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('findAppBinary excludes PATH matches that canonicalize to the launcher path', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
test(
|
||||
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
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) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
withRealpathSyncStub(
|
||||
(filePath) => {
|
||||
if (filePath === canonicalPath || filePath === wrapperPath) {
|
||||
return canonicalPath;
|
||||
}
|
||||
return filePath;
|
||||
},
|
||||
() => {
|
||||
const result = findAppBinary(canonicalPath, pathModule);
|
||||
assert.equal(result, null);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
withRealpathSyncStub(
|
||||
(filePath) => {
|
||||
if (filePath === canonicalPath || filePath === wrapperPath) {
|
||||
return canonicalPath;
|
||||
}
|
||||
return filePath;
|
||||
},
|
||||
() => {
|
||||
const result = findAppBinary(canonicalPath, pathModule);
|
||||
assert.equal(result, null);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
||||
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(
|
||||
(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);
|
||||
},
|
||||
);
|
||||
@@ -651,7 +672,10 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
|
||||
withAccessSyncStub(
|
||||
(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);
|
||||
},
|
||||
);
|
||||
@@ -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 }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
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;
|
||||
test(
|
||||
'findAppBinary resolves a Windows install directory to SubMiner.exe',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
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);
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
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 {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -364,8 +364,12 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
|
||||
} else if (process.platform === 'darwin') {
|
||||
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(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
||||
candidates.push(
|
||||
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
);
|
||||
candidates.push(
|
||||
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
|
||||
);
|
||||
} else {
|
||||
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
||||
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
||||
|
||||
@@ -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 visit = (currentDir: string) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
@@ -145,7 +149,12 @@ function parseLcovReport(report: string): LcovRecord[] {
|
||||
}
|
||||
if (line.startsWith('BRDA:')) {
|
||||
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;
|
||||
}
|
||||
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(`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) =>
|
||||
a.line === b.line
|
||||
@@ -298,7 +309,9 @@ function runCoverageLane(): number {
|
||||
}
|
||||
|
||||
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;
|
||||
} finally {
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -369,7 +369,8 @@ export class AnkiIntegration {
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
},
|
||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
||||
findDuplicateNoteIds: (expression, noteInfo) =>
|
||||
this.findDuplicateNoteIds(expression, noteInfo),
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private async findDuplicateNoteIds(
|
||||
expression: string,
|
||||
noteInfo: NoteInfo,
|
||||
): Promise<number[]> {
|
||||
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
|
||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
|
||||
@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
|
||||
}
|
||||
|
||||
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 contentType =
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
|
||||
|
||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||
const action =
|
||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
return requestJson;
|
||||
}
|
||||
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
|
||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||
? params.subminerDuplicateNoteIds
|
||||
: [];
|
||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||
}))].sort((left, right) => left - right);
|
||||
return [
|
||||
...new Set(
|
||||
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 {
|
||||
|
||||
@@ -113,10 +113,7 @@ interface CardCreationDeps {
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
trackLastAddedNoteId?: (noteId: number) => void;
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (
|
||||
expression: string,
|
||||
noteInfo: CardCreationNoteInfo,
|
||||
) => Promise<number[]>;
|
||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
@@ -573,10 +570,7 @@ export class CardCreationService {
|
||||
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'Failed to capture pre-add duplicate note ids:',
|
||||
(error as Error).message,
|
||||
);
|
||||
log.warn('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 {
|
||||
return {
|
||||
noteId: -1,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
||||
),
|
||||
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -307,21 +307,27 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
||||
};
|
||||
|
||||
let notesInfoCalls = 0;
|
||||
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
}));
|
||||
const duplicateIds = await findDuplicateNoteIds(
|
||||
'貴様',
|
||||
100,
|
||||
currentNote,
|
||||
{
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
}));
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
}, 0);
|
||||
0,
|
||||
);
|
||||
|
||||
assert.deepEqual(duplicateIds, []);
|
||||
assert.equal(notesInfoCalls, 0);
|
||||
|
||||
@@ -24,13 +24,7 @@ export async function findDuplicateNote(
|
||||
noteInfo: NoteInfo,
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
const duplicateNoteIds = await findDuplicateNoteIds(
|
||||
expression,
|
||||
excludeNoteId,
|
||||
noteInfo,
|
||||
deps,
|
||||
1,
|
||||
);
|
||||
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
|
||||
return duplicateNoteIds[0] ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,17 +35,8 @@ const {
|
||||
startupWarmups,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const {
|
||||
ankiConnect,
|
||||
jimaku,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
youtubeSubgen,
|
||||
} = INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
|
||||
const status = buildStatus(snapshot);
|
||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||
const details =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
? trimField(title)
|
||||
: style.fallbackDetails;
|
||||
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||
const state =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
|
||||
if (style.smallImageText.trim().length > 0) {
|
||||
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||
}
|
||||
if (
|
||||
style.buttonLabel.trim().length > 0 &&
|
||||
/^https?:\/\//.test(style.buttonUrl.trim())
|
||||
) {
|
||||
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
|
||||
activity.buttons = [
|
||||
{
|
||||
label: trimField(style.buttonLabel.trim(), 32),
|
||||
|
||||
@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
|
||||
};
|
||||
};
|
||||
|
||||
const eventsRetention = daysToRetentionWindow(
|
||||
retention.eventsDays,
|
||||
7,
|
||||
3650,
|
||||
);
|
||||
const telemetryRetention = daysToRetentionWindow(
|
||||
retention.telemetryDays,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
const sessionsRetention = daysToRetentionWindow(
|
||||
retention.sessionsDays,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
|
||||
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
|
||||
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
|
||||
this.eventsRetentionMs = eventsRetention.ms;
|
||||
this.eventsRetentionDays = eventsRetention.days;
|
||||
this.telemetryRetentionMs = telemetryRetention.ms;
|
||||
this.telemetryRetentionDays = telemetryRetention.days;
|
||||
this.sessionsRetentionMs = sessionsRetention.ms;
|
||||
this.sessionsRetentionDays = sessionsRetention.days;
|
||||
this.dailyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.dailyRollupsDays,
|
||||
365,
|
||||
36500,
|
||||
).ms;
|
||||
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
|
||||
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.monthlyRollupsDays,
|
||||
5 * 365,
|
||||
36500,
|
||||
).ms;
|
||||
this.vacuumIntervalMs = daysToRetentionWindow(
|
||||
retention.vacuumIntervalDays,
|
||||
7,
|
||||
3650,
|
||||
).ms;
|
||||
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
|
||||
this.db = new Database(this.dbPath);
|
||||
applyPragmas(this.db);
|
||||
ensureSchema(this.db);
|
||||
|
||||
@@ -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 (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`
|
||||
);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
);
|
||||
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'二月',
|
||||
'二月',
|
||||
'にがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
).run(
|
||||
'二月',
|
||||
'二月',
|
||||
'にがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'三月',
|
||||
'三月',
|
||||
'さんがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
).run(
|
||||
'三月',
|
||||
'三月',
|
||||
'さんがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.lookups.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.lookups.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'猫',
|
||||
'猫',
|
||||
'ねこ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
String(twoDaysAgo),
|
||||
String(twoDaysAgo),
|
||||
1,
|
||||
);
|
||||
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsToday, 1);
|
||||
|
||||
@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
videoId,
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
currentSessionId,
|
||||
) as { found: number } | null;
|
||||
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
|
||||
found: number;
|
||||
} | null;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
@@ -150,7 +147,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
|
||||
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
|
||||
}
|
||||
|
||||
function rebuildLifetimeSummariesInternal(
|
||||
|
||||
@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
|
||||
const remainingTelemetry = db
|
||||
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
||||
.get() as { count: number };
|
||||
const remainingSessions = db
|
||||
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
|
||||
.get() as { count: number };
|
||||
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
|
||||
count: number;
|
||||
};
|
||||
|
||||
assert.equal(result.deletedSessionEvents, 0);
|
||||
assert.equal(result.deletedTelemetryRows, 0);
|
||||
|
||||
@@ -56,10 +56,7 @@ export function pruneRawRetention(
|
||||
sessionsRetentionDays?: number;
|
||||
},
|
||||
): RawRetentionResult {
|
||||
const resolveCutoff = (
|
||||
retentionMs: number,
|
||||
retentionDays: number | undefined,
|
||||
): string => {
|
||||
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
|
||||
if (retentionDays !== undefined) {
|
||||
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
||||
}
|
||||
@@ -68,9 +65,11 @@ export function pruneRawRetention(
|
||||
|
||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||
? (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
|
||||
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
|
||||
) as { changes: number }
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes
|
||||
: 0;
|
||||
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
||||
|
||||
@@ -150,9 +150,11 @@ export function getSessionEvents(
|
||||
ORDER BY ts_ms ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
|
||||
tsMs: number | string;
|
||||
}>;
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
|
||||
SessionEventRow & {
|
||||
tsMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||
|
||||
@@ -355,9 +355,7 @@ export function upsertCoverArt(
|
||||
const fetchedAtMs = toDbTimestamp(nowMs());
|
||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||
const computedCoverBlobHash =
|
||||
coverBlob && coverBlob.length > 0
|
||||
? createHash('sha256').update(coverBlob).digest('hex')
|
||||
: null;
|
||||
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
|
||||
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
||||
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
||||
coverBlobHash = existing?.coverBlobHash ?? null;
|
||||
|
||||
@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}>;
|
||||
const rows = prepared.all(limit) as Array<
|
||||
SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||
@@ -69,19 +71,21 @@ export function getSessionTimeline(
|
||||
`;
|
||||
|
||||
if (limit === undefined) {
|
||||
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
const rows = db.prepare(select).all(sessionId) as Array<
|
||||
SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
const rows = db
|
||||
.prepare(`${select}\n LIMIT ?`)
|
||||
.all(sessionId, limit) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
|
||||
SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
|
||||
@@ -359,10 +359,7 @@ function getNumericCalendarValue(
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
export function getLocalEpochDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalMonthKey(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalDayOfWeek(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalHourOfDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
|
||||
dayOffset: number,
|
||||
): string {
|
||||
const normalizedDayOffset = Math.trunc(dayOffset);
|
||||
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||
const modifier =
|
||||
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
|
||||
@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||
'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'];
|
||||
|
||||
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
|
||||
}
|
||||
const currentTimestamp = currentDbTimestamp();
|
||||
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 cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
||||
const currentYear = Math.floor(currentMonthKey / 100);
|
||||
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
|
||||
|
||||
const animePerDay = {
|
||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
|
||||
rollup.totalActiveMin,
|
||||
watchTime: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalActiveMin,
|
||||
),
|
||||
cards: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
|
||||
@@ -102,7 +102,9 @@ type SubtitleTrackCandidate = {
|
||||
externalFilename: string | null;
|
||||
};
|
||||
|
||||
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
|
||||
function normalizeSubtitleTrackCandidate(
|
||||
track: Record<string, unknown>,
|
||||
): SubtitleTrackCandidate | null {
|
||||
const id =
|
||||
typeof track.id === 'number'
|
||||
? track.id
|
||||
@@ -122,8 +124,12 @@ function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): Subtit
|
||||
|
||||
return {
|
||||
id,
|
||||
lang: String(track.lang || '').trim().toLowerCase(),
|
||||
title: String(track.title || '').trim().toLowerCase(),
|
||||
lang: String(track.lang || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
title: String(track.title || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
selected: track.selected === true,
|
||||
external: track.external === true,
|
||||
externalFilename,
|
||||
@@ -168,9 +174,7 @@ function pickSecondarySubtitleTrackId(
|
||||
const uniqueTracks = [...dedupedTracks.values()];
|
||||
|
||||
for (const language of normalizedLanguages) {
|
||||
const selectedMatch = uniqueTracks.find(
|
||||
(track) => track.selected && track.lang === language,
|
||||
);
|
||||
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||
if (selectedMatch) {
|
||||
return selectedMatch.id;
|
||||
}
|
||||
|
||||
@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
||||
res.end(Buffer.from(body));
|
||||
}
|
||||
|
||||
function startNodeHttpServer(
|
||||
app: Hono,
|
||||
config: StatsServerConfig,
|
||||
): { close: () => void } {
|
||||
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||
const server = http.createServer((req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
Bun?: {
|
||||
serve?: (options: {
|
||||
fetch: (typeof app)['fetch'];
|
||||
port: number;
|
||||
hostname: string;
|
||||
}) => { stop: () => void };
|
||||
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -77,7 +77,11 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
if (typeof track.id === 'number') {
|
||||
|
||||
@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
|
||||
: null,
|
||||
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||
? 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,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
process.stdout.write(${JSON.stringify(payload)});
|
||||
`
|
||||
: `#!/usr/bin/env sh
|
||||
: `#!/usr/bin/env sh
|
||||
cat <<'EOF' | base64 -d
|
||||
${Buffer.from(payload).toString('base64')}
|
||||
EOF
|
||||
|
||||
@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
process.stdout.write(${JSON.stringify(payload)});
|
||||
`
|
||||
: `#!/usr/bin/env sh
|
||||
: `#!/usr/bin/env sh
|
||||
cat <<'EOF' | base64 -d
|
||||
${Buffer.from(payload).toString('base64')}
|
||||
EOF
|
||||
|
||||
@@ -114,12 +114,7 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--fullscreen',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
|
||||
['--fullscreen'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
|
||||
43
src/main.ts
43
src/main.ts
@@ -77,15 +77,15 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.setup),
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
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 {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
@@ -496,7 +501,10 @@ import {
|
||||
} from './config';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
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 {
|
||||
buildSubtitleSidebarSourceKey,
|
||||
resolveSubtitleSourcePath,
|
||||
@@ -1412,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) =>
|
||||
resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
});
|
||||
|
||||
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
@@ -1426,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
const subtitlePrefetchRuntime = {
|
||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||
@@ -1861,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
},
|
||||
})(),
|
||||
);
|
||||
const buildGetRuntimeOptionsStateMainDepsHandler =
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
||||
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||
{
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
});
|
||||
},
|
||||
);
|
||||
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
|
||||
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
|
||||
getRuntimeOptionsStateMainDeps,
|
||||
@@ -3042,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
},
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
writeResponse: (responsePath, payload) => {
|
||||
@@ -3258,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
|
||||
@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getModalWindow: () => null },
|
||||
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (isActive: boolean) => void;
|
||||
},
|
||||
{ measurementStore: boolean },
|
||||
{ modalRuntime: boolean },
|
||||
{ mpvSocketPath: string; texthookerPort: number },
|
||||
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
createOverlayManager: () => ({
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
|
||||
createOverlayModalInputState: () => ({
|
||||
inputState: true,
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
});
|
||||
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
|
||||
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('ready', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('second-instance', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
||||
|
||||
@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
|
||||
};
|
||||
shouldBypassSingleInstanceLock: () => boolean;
|
||||
requestSingleInstanceLockEarly: () => boolean;
|
||||
registerSecondInstanceHandlerEarly: (
|
||||
listener: (_event: unknown, argv: string[]) => void,
|
||||
) => void;
|
||||
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
|
||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||
createConfigService: (configDir: string) => TConfigService;
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => TOverlayModalRuntime;
|
||||
createAppState: (input: {
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
}) => TAppState;
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
||||
}
|
||||
|
||||
export interface MainBootServicesResult<
|
||||
@@ -239,9 +234,7 @@ export function createMainBootServices<
|
||||
|
||||
const appLifecycleApp = {
|
||||
requestSingleInstanceLock: () =>
|
||||
params.shouldBypassSingleInstanceLock()
|
||||
? true
|
||||
: params.requestSingleInstanceLockEarly(),
|
||||
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
||||
quit: () => params.app.quit(),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'second-instance') {
|
||||
|
||||
@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
|
||||
const extraLength = archive.readUInt16LE(cursor + 28);
|
||||
const fileNameStart = cursor + 30;
|
||||
const dataStart = fileNameStart + fileNameLength + extraLength;
|
||||
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
|
||||
'utf8',
|
||||
);
|
||||
const fileName = archive
|
||||
.subarray(fileNameStart, fileNameStart + fileNameLength)
|
||||
.toString('utf8');
|
||||
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
entries.set(fileName, Buffer.from(data));
|
||||
cursor = dataStart + compressedSize;
|
||||
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
}) as typeof fs.writeFileSync;
|
||||
|
||||
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;
|
||||
|
||||
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.format, 3);
|
||||
|
||||
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
|
||||
CharacterDictionaryTermEntry[];
|
||||
const termBank = JSON.parse(
|
||||
entries.get('term_bank_1.json')!.toString('utf8'),
|
||||
) as CharacterDictionaryTermEntry[];
|
||||
assert.equal(termBank.length, 1);
|
||||
assert.equal(termBank[0]?.[0], 'アルファ');
|
||||
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
||||
|
||||
@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
|
||||
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);
|
||||
let cursor = 0;
|
||||
writeUint32LE(end, 0x06054b50, cursor);
|
||||
|
||||
@@ -37,13 +37,13 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
|
||||
firstScheduled?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length > 0,
|
||||
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));
|
||||
|
||||
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));
|
||||
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -40,9 +40,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
}
|
||||
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() ||
|
||||
deps.getCurrentVideoPath()?.trim() ||
|
||||
'__unknown__';
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
@@ -85,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient?.connected) {
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
deps.schedule(
|
||||
() => attemptRelease(playbackGeneration, attempt + 1),
|
||||
releaseRetryDelayMs,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
assert.equal(handled, false);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () =>
|
||||
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
|
||||
@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
|
||||
export function composeCliStartupHandlers(
|
||||
options: CliStartupComposerOptions,
|
||||
): CliStartupComposerResult {
|
||||
const createCliCommandContext = createCliCommandContextFactory(
|
||||
options.cliCommandContextMainDeps,
|
||||
);
|
||||
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
|
||||
const handleCliCommand = createCliCommandRuntimeHandler({
|
||||
...options.cliCommandRuntimeHandlerMainDeps,
|
||||
createCliCommandContext: () => createCliCommandContext(),
|
||||
|
||||
@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
|
||||
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
|
||||
>;
|
||||
|
||||
export type HeadlessStartupComposerOptions<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps,
|
||||
> = ComposerInputs<{
|
||||
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps
|
||||
>;
|
||||
}>;
|
||||
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||
ComposerInputs<{
|
||||
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps
|
||||
>;
|
||||
}>;
|
||||
|
||||
export type HeadlessStartupComposerResult<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps,
|
||||
> = ComposerOutputs<
|
||||
Pick<
|
||||
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
||||
>
|
||||
>;
|
||||
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||
ComposerOutputs<
|
||||
Pick<
|
||||
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
||||
>
|
||||
>;
|
||||
|
||||
export function composeHeadlessStartupHandlers<
|
||||
TCliArgs,
|
||||
|
||||
@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
|
||||
|
||||
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
|
||||
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 composed = composeJellyfinRemoteHandlers({
|
||||
|
||||
@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => BASE_METRICS,
|
||||
setCurrentMetrics: () => {},
|
||||
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
|
||||
applyPatch: (
|
||||
current: MpvSubtitleRenderMetrics,
|
||||
patch: Partial<MpvSubtitleRenderMetrics>,
|
||||
) => ({
|
||||
next: { ...current, ...patch },
|
||||
changed: true,
|
||||
}),
|
||||
|
||||
@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
|
||||
options: OverlayVisibilityRuntimeComposerOptions,
|
||||
): OverlayVisibilityRuntimeComposerResult {
|
||||
return {
|
||||
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
|
||||
options.restorePreviousSecondarySubVisibilityMainDeps,
|
||||
|
||||
@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
|
||||
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
|
||||
return {
|
||||
login: () => client.login(),
|
||||
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
|
||||
setActivity: (activity) =>
|
||||
requireUser(client)
|
||||
.setActivity(activity)
|
||||
.then(() => undefined),
|
||||
clearActivity: () => requireUser(client).clearActivity(),
|
||||
destroy: () => client.destroy(),
|
||||
};
|
||||
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
|
||||
|
||||
export function createDiscordRpcClient(
|
||||
clientId: string,
|
||||
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
|
||||
deps?: {
|
||||
createClient?: (options: {
|
||||
clientId: string;
|
||||
transport: { type: 'ipc' };
|
||||
}) => DiscordRpcRawClient;
|
||||
},
|
||||
): DiscordRpcClient {
|
||||
const client =
|
||||
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??
|
||||
|
||||
@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
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({
|
||||
platform: 'linux',
|
||||
|
||||
@@ -187,7 +187,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
|
||||
const updated = rewriteInstalledPluginBinaryPath(
|
||||
installPaths.pluginConfigPath,
|
||||
options.binaryPath,
|
||||
);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,10 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -155,7 +158,10 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?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);
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ function pickBestTrackId(
|
||||
if (left.track.external !== right.track.external) {
|
||||
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;
|
||||
}
|
||||
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[];
|
||||
}): ManagedLocalSubtitleSelection {
|
||||
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(
|
||||
input.primaryLanguages,
|
||||
@@ -165,12 +169,10 @@ function normalizeLocalMediaPath(mediaPath: string | null | undefined): string |
|
||||
|
||||
export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getMpvClient: () =>
|
||||
| {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
| null;
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
getSecondarySubtitleLanguages: () => string[];
|
||||
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;
|
||||
|
||||
@@ -38,7 +38,8 @@ export function createPlaylistBrowserIpcRuntime(
|
||||
return {
|
||||
playlistBrowserRuntimeDeps,
|
||||
playlistBrowserMainDeps: {
|
||||
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
|
||||
getPlaylistBrowserSnapshot: () =>
|
||||
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
|
||||
appendPlaylistBrowserFile: (filePath: string) =>
|
||||
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
|
||||
playPlaylistBrowserIndex: (index: number) =>
|
||||
|
||||
@@ -102,7 +102,8 @@ function createFakeMpvClient(options: {
|
||||
if (removingCurrent) {
|
||||
syncFlags();
|
||||
this.currentVideoPath =
|
||||
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
|
||||
playlist.find((item) => item.current || item.playing)?.filename ??
|
||||
this.currentVideoPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -276,7 +277,10 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
]);
|
||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||
assert.deepEqual(
|
||||
scheduled.map((entry) => entry.delayMs),
|
||||
[400],
|
||||
);
|
||||
scheduled[0]?.callback();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
@@ -382,10 +386,7 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true },
|
||||
{ filename: episode2 },
|
||||
],
|
||||
playlist: [{ filename: episode1, current: true }, { filename: episode2 }],
|
||||
});
|
||||
|
||||
const deps = {
|
||||
@@ -486,17 +487,14 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
|
||||
scheduled[1]?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(
|
||||
mpvClient.getCommands().slice(-6),
|
||||
[
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 2],
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-6), [
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 2],
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => {
|
||||
|
||||
@@ -63,7 +63,10 @@ async function resolveCurrentFilePath(
|
||||
|
||||
function resolveDirectorySnapshot(
|
||||
currentFilePath: string | null,
|
||||
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
|
||||
): Pick<
|
||||
PlaylistBrowserSnapshot,
|
||||
'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'
|
||||
> {
|
||||
if (!currentFilePath) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
|
||||
@@ -34,10 +34,7 @@ export function getConfiguredWindowsMpvPathStatus(
|
||||
return fileExists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(
|
||||
deps: WindowsMpvLaunchDeps,
|
||||
configuredMpvPath = '',
|
||||
): string {
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
@@ -178,9 +175,7 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists:
|
||||
options.fileExists ??
|
||||
defaultWindowsMpvFileExists,
|
||||
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
|
||||
spawnDetached: (command, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
|
||||
@@ -92,7 +92,9 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
]);
|
||||
launchedWindowsMpv = launchResult.ok;
|
||||
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) {
|
||||
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||
|
||||
@@ -358,12 +358,10 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] {
|
||||
return (
|
||||
Array.isArray(command) &&
|
||||
command[0] === 'sub-seek' &&
|
||||
typeof command[1] === 'number'
|
||||
);
|
||||
function isSubtitleSeekCommand(
|
||||
command: (string | number)[] | undefined,
|
||||
): command is [string, number] {
|
||||
return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
|
||||
}
|
||||
|
||||
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
PlaylistBrowserDirectoryItem,
|
||||
PlaylistBrowserQueueItem,
|
||||
} from '../../types';
|
||||
import type { PlaylistBrowserDirectoryItem, PlaylistBrowserQueueItem } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
|
||||
type PlaylistBrowserRowRenderActions = {
|
||||
@@ -55,7 +52,7 @@ export function renderPlaylistBrowserDirectoryRow(
|
||||
? item.episodeLabel
|
||||
? `${item.episodeLabel} · Current file`
|
||||
: 'Current file'
|
||||
: item.episodeLabel ?? 'Video file';
|
||||
: (item.episodeLabel ?? 'Video file');
|
||||
main.append(label, meta);
|
||||
|
||||
const trailing = document.createElement('div');
|
||||
|
||||
@@ -236,9 +236,17 @@ function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): Ele
|
||||
notifyOverlayModalClosed: () => {},
|
||||
focusMainWindow: async () => {},
|
||||
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() }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
removePlaylistBrowserIndex: async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
snapshot: createSnapshot(),
|
||||
}),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
...overrides,
|
||||
} as ElectronAPI;
|
||||
@@ -348,15 +356,13 @@ test('playlist browser modal action buttons stop double-click propagation', asyn
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
const row =
|
||||
env.dom.playlistBrowserDirectoryList.children[0] as
|
||||
| ReturnType<typeof createPlaylistRow>
|
||||
| undefined;
|
||||
const row = env.dom.playlistBrowserDirectoryList.children[0] as
|
||||
| ReturnType<typeof createPlaylistRow>
|
||||
| undefined;
|
||||
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
|
||||
const button =
|
||||
trailing?.children?.at(-1) as
|
||||
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
|
||||
| undefined;
|
||||
const button = trailing?.children?.at(-1) as
|
||||
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
|
||||
| undefined;
|
||||
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
|
||||
|
||||
assert.equal(typeof dblclickHandler, 'function');
|
||||
|
||||
@@ -31,7 +31,8 @@ function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): n
|
||||
|
||||
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -225,7 +226,10 @@ export function createPlaylistBrowserModal(
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -453,8 +453,7 @@ body {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(110, 115, 141, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
|
||||
background: 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);
|
||||
}
|
||||
|
||||
@@ -496,8 +495,12 @@ body {
|
||||
}
|
||||
|
||||
.playlist-browser-row.current {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(138, 173, 244, 0.12), rgba(138, 173, 244, 0.03) 28%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(138, 173, 244, 0.12),
|
||||
rgba(138, 173, 244, 0.03) 28%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 3px 0 0 #8aadf4;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,8 +222,12 @@ export function resolveRendererDom(): RendererDom {
|
||||
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
|
||||
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
|
||||
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
|
||||
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
|
||||
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
|
||||
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>(
|
||||
'playlistBrowserDirectoryList',
|
||||
),
|
||||
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>(
|
||||
'playlistBrowserPlaylistList',
|
||||
),
|
||||
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,13 +47,10 @@ test('RuntimeOptionsManager returns detached effective Anki config copies', () =
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new RuntimeOptionsManager(
|
||||
() => structuredClone(baseConfig),
|
||||
{
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
},
|
||||
);
|
||||
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
});
|
||||
|
||||
const effective = manager.getEffectiveAnkiConnectConfig();
|
||||
effective.tags!.push('mutated');
|
||||
|
||||
Reference in New Issue
Block a user