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 {
|
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')
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
43
src/main.ts
43
src/main.ts
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' } }) ??
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user