Fix Windows mpv logging and add log export (#88)

This commit is contained in:
2026-05-26 00:31:38 -07:00
committed by GitHub
parent 43ebc7d371
commit 11c196821d
150 changed files with 2748 additions and 582 deletions
+4
View File
@@ -40,6 +40,8 @@ export interface AppReadyRuntimeDepsFactoryInput {
startTexthooker: AppReadyRuntimeDeps['startTexthooker'];
log: AppReadyRuntimeDeps['log'];
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
setLogRotation?: AppReadyRuntimeDeps['setLogRotation'];
setLogFileToggles?: AppReadyRuntimeDeps['setLogFileToggles'];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker'];
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
@@ -107,6 +109,8 @@ export function createAppReadyRuntimeDeps(
startTexthooker: params.startTexthooker,
log: params.log,
setLogLevel: params.setLogLevel,
setLogRotation: params.setLogRotation,
setLogFileToggles: params.setLogFileToggles,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker,
+1 -180
View File
@@ -1,24 +1,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeStoredZip } from '../../shared/stored-zip';
import { ensureDir } from './fs-utils';
import type { CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry } from './types';
type ZipEntry = {
name: string;
crc32: number;
size: number;
localHeaderOffset: number;
};
function writeUint32LE(buffer: Buffer, value: number, offset: number): number {
const normalized = value >>> 0;
buffer[offset] = normalized & 0xff;
buffer[offset + 1] = (normalized >>> 8) & 0xff;
buffer[offset + 2] = (normalized >>> 16) & 0xff;
buffer[offset + 3] = (normalized >>> 24) & 0xff;
return offset + 4;
}
export function buildDictionaryTitle(mediaId: number): string {
return `SubMiner Character Dictionary (AniList ${mediaId})`;
}
@@ -47,169 +31,6 @@ function createTagBank(): Array<[string, string, number, string, number]> {
];
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let crc = i;
for (let j = 0; j < 8; j += 1) {
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[i] = crc >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer {
const local = Buffer.alloc(30 + fileName.length);
let cursor = 0;
writeUint32LE(local, 0x04034b50, cursor);
cursor += 4;
local.writeUInt16LE(20, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(local, fileCrc32, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
local.writeUInt16LE(fileName.length, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
fileName.copy(local, cursor);
return local;
}
function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
const fileName = Buffer.from(entry.name, 'utf8');
const central = Buffer.alloc(46 + fileName.length);
let cursor = 0;
writeUint32LE(central, 0x02014b50, cursor);
cursor += 4;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, entry.crc32, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
central.writeUInt16LE(fileName.length, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, 0, cursor);
cursor += 4;
writeUint32LE(central, entry.localHeaderOffset, cursor);
cursor += 4;
fileName.copy(central, cursor);
return central;
}
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
writeUint32LE(end, centralSize, cursor);
cursor += 4;
writeUint32LE(end, centralStart, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
return end;
}
function writeBuffer(fd: number, buffer: Buffer): void {
let written = 0;
while (written < buffer.length) {
written += fs.writeSync(fd, buffer, written, buffer.length - written);
}
}
function writeStoredZip(outputPath: string, files: Iterable<{ name: string; data: Buffer }>): void {
const entries: ZipEntry[] = [];
let offset = 0;
const fd = fs.openSync(outputPath, 'w');
try {
for (const file of files) {
const fileName = Buffer.from(file.name, 'utf8');
const fileSize = file.data.length;
const fileCrc32 = crc32(file.data);
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
writeBuffer(fd, localHeader);
writeBuffer(fd, file.data);
entries.push({
name: file.name,
crc32: fileCrc32,
size: fileSize,
localHeaderOffset: offset,
});
offset += localHeader.length + fileSize;
}
const centralStart = offset;
for (const entry of entries) {
const centralHeader = createCentralDirectoryHeader(entry);
writeBuffer(fd, centralHeader);
offset += centralHeader.length;
}
const centralSize = offset - centralStart;
writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
} catch (error) {
fs.closeSync(fd);
fs.rmSync(outputPath, { force: true });
throw error;
}
fs.closeSync(fd);
}
export function buildDictionaryZip(
outputPath: string,
dictionaryTitle: string,
-4
View File
@@ -19,7 +19,6 @@ export interface OverlayShortcutRuntimeServiceInput {
isOverlayShortcutContextActive?: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
@@ -51,9 +50,6 @@ export function createOverlayShortcutsRuntimeService(
openRuntimeOptions: () => {
input.openRuntimeOptionsPalette();
},
openCharacterDictionary: () => {
input.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
input.openCharacterDictionaryManager();
},
+2
View File
@@ -22,6 +22,8 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
startTexthooker: deps.startTexthooker,
log: deps.log,
setLogLevel: deps.setLogLevel,
setLogRotation: deps.setLogRotation,
setLogFileToggles: deps.setLogFileToggles,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: deps.createSubtitleTimingTracker,
createImmersionTracker: deps.createImmersionTracker,
@@ -0,0 +1,55 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import {
CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
openCharacterDictionaryManagerWithConfigGate,
type CharacterDictionaryManagerNotificationType,
} from './character-dictionary-manager-gate';
function makeDeps(options: {
enabled?: boolean;
notificationType?: CharacterDictionaryManagerNotificationType;
}) {
const calls: string[] = [];
return {
calls,
deps: {
isCharacterDictionaryEnabled: () => options.enabled ?? false,
getNotificationType: () => options.notificationType ?? 'osd',
openManager: () => calls.push('open'),
showOsd: (message: string) => calls.push(`osd:${message}`),
showDesktopNotification: (title: string, opts: { body: string }) =>
calls.push(`system:${title}:${opts.body}`),
logWarn: (message: string) => calls.push(`warn:${message}`),
},
};
}
test('opens character dictionary manager when character dictionary is enabled', () => {
const { calls, deps } = makeDeps({ enabled: true, notificationType: 'both' });
openCharacterDictionaryManagerWithConfigGate(deps);
assert.deepEqual(calls, ['open']);
});
test('routes disabled manager notification to configured surfaces', () => {
for (const [type, expected] of [
['osd', [`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
[
'both',
[
`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
],
],
['none', []],
] as const) {
const { calls, deps } = makeDeps({ enabled: false, notificationType: type });
openCharacterDictionaryManagerWithConfigGate(deps);
assert.deepEqual(calls, expected);
}
});
@@ -0,0 +1,39 @@
export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE =
'Enable Name Match in Settings to use the character dictionary manager.';
export interface CharacterDictionaryManagerGateDeps {
isCharacterDictionaryEnabled: () => boolean;
getNotificationType: () => CharacterDictionaryManagerNotificationType;
openManager: () => void;
showOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logWarn?: (message: string, error?: unknown) => void;
}
function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void {
const type = deps.getNotificationType();
if (type === 'osd' || type === 'both') {
deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE);
}
if (type === 'system' || type === 'both') {
try {
deps.showDesktopNotification('SubMiner', {
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
});
} catch (error) {
deps.logWarn?.('Unable to show character dictionary manager notification.', error);
}
}
}
export function openCharacterDictionaryManagerWithConfigGate(
deps: CharacterDictionaryManagerGateDeps,
): void {
if (deps.isCharacterDictionaryEnabled()) {
deps.openManager();
return;
}
notifyManagerDisabled(deps);
}
@@ -53,17 +53,6 @@ type OpenCharacterDictionaryModalDeps = Omit<
'channel' | 'retryWarning'
>;
export async function openCharacterDictionaryModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
return await openCharacterDictionaryModalChannel({
...deps,
channel: IPC_CHANNELS.event.characterDictionaryOpen,
retryWarning:
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
});
}
export async function openCharacterDictionaryManagerModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
@@ -27,7 +27,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log',
getDefaultMpvLogPath: () => '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
@@ -73,6 +73,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
config.ankiConnect.isKiku.fieldGrouping = 'manual';
config.logging.level = 'debug';
config.logging.rotation = 14;
config.logging.files.mpv = true;
const calls: string[] = [];
const ankiPatches: unknown[] = [];
@@ -90,6 +92,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh:subtitle'),
setLogLevel: (level) => calls.push(`log:${level}`),
setLogRotation: (rotation) => calls.push(`rotation:${rotation}`),
setLogFileToggles: (files) => calls.push(`files:${files.mpv}`),
});
applyHotReload(
@@ -109,6 +113,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'logging.level',
'logging.rotation',
'logging.files.mpv',
],
restartRequiredFields: [],
},
@@ -135,6 +141,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
assert.ok(calls.includes('refresh:prefetch'));
assert.ok(calls.includes('refresh:subtitle'));
assert.ok(calls.includes('log:debug'));
assert.ok(calls.includes('rotation:14'));
assert.ok(calls.includes('files:true'));
assert.ok(calls.includes('broadcast:config:hot-reload'));
});
@@ -20,6 +20,8 @@ type ConfigHotReloadAppliedDeps = {
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
};
type ConfigHotReloadMessageDeps = {
@@ -158,6 +160,12 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
if (diff.hotReloadFields.includes('logging.level')) {
deps.setLogLevel?.(config.logging.level);
}
if (diff.hotReloadFields.includes('logging.rotation')) {
deps.setLogRotation?.(config.logging.rotation);
}
if (hasAnyHotReloadField(diff, ['logging.files'])) {
deps.setLogFileToggles?.(config.logging.files);
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
@@ -75,6 +75,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
@@ -93,6 +95,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
setLogRotation: (rotation: ResolvedConfig['logging']['rotation']) =>
deps.setLogRotation?.(rotation),
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
deps.setLogFileToggles?.(files),
});
}
@@ -111,15 +111,15 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const homeDir = path.posix.join(root, 'home');
const xdgConfigHome = path.posix.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
const directoryInstall = installPaths.pluginDir;
const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua');
const legacyScript = path.posix.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.posix.join(installPaths.scriptsDir, 'subminer-loader.lua');
fs.mkdirSync(directoryInstall, { recursive: true });
fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(path.posix.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(legacyScript, '-- legacy plugin');
fs.writeFileSync(legacyLoader, '-- legacy loader');
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
@@ -203,9 +203,15 @@ test('detectInstalledMpvPlugin prefers Windows portable plugin and parses versio
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
const homeDir = path.posix.join(root, 'home');
const legacyPath = path.posix.join(
homeDir,
'.config',
'mpv',
'scripts',
'subminer-loader.lua',
);
fs.mkdirSync(path.posix.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '-- legacy');
const detection = detectInstalledMpvPlugin({
@@ -56,7 +56,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -44,7 +44,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
source: null,
message: null,
}),
defaultMpvLogPath: '/tmp/mpv.log',
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => {
@@ -61,7 +61,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']);
@@ -23,7 +23,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
getInstalledPluginDetection: deps.getInstalledPluginDetection,
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
defaultMpvLogPath: deps.defaultMpvLogPath,
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
spawnMpv: (args: string[]) => deps.spawnMpv(args),
@@ -36,7 +36,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
getRuntimePluginEntrypoint: () =>
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -59,6 +59,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
),
);
assert.ok(spawnedArgs[0]!.includes('--log-file=/tmp/mp.log'));
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});
@@ -81,7 +82,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
aniskipEnabled: true,
aniskipButtonKey: 'F8',
}),
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -123,7 +124,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when in
source: 'default-config',
message: null,
}),
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -48,7 +48,7 @@ export type LaunchMpvForJellyfinDeps = {
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
defaultMpvLogPath: string;
getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
@@ -85,13 +85,14 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
if (installedPlugin?.installed && installedPlugin.path) {
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
}
const defaultMpvLogPath = deps.getDefaultMpvLogPath().trim();
const mpvArgs = [
...deps.defaultMpvArgs,
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
'--idle=yes',
scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`,
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { writeStoredZip } from '../../shared/stored-zip';
import { exportLogsArchive, maskUsernamesInLogText } from './log-export';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-export-'));
}
function cleanupDir(dirPath: string): void {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeLog(logsDir: string, name: string, content: string, mtime: string): string {
const logPath = path.join(logsDir, name);
fs.writeFileSync(logPath, content, 'utf8');
const date = new Date(mtime);
fs.utimesSync(logPath, date, date);
return logPath;
}
function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const archive = fs.readFileSync(zipPath);
const entries = new Map<string, Buffer>();
let cursor = 0;
while (cursor + 4 <= archive.length) {
const signature = archive.readUInt32LE(cursor);
if (signature === 0x02014b50 || signature === 0x06054b50) {
break;
}
assert.equal(signature, 0x04034b50);
const compressedSize = archive.readUInt32LE(cursor + 18);
const fileNameLength = archive.readUInt16LE(cursor + 26);
const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive
.subarray(fileNameStart, fileNameStart + fileNameLength)
.toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize;
}
return entries;
}
test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () => {
const masked = maskUsernamesInLogText(
[
'/home/kyle/.config/SubMiner',
'/Users/kyle/Library/Application Support/SubMiner',
'C:\\Users\\kyle\\AppData\\Roaming\\SubMiner',
'C:\\\\Users\\\\kyle\\\\AppData\\\\Roaming\\\\SubMiner',
].join('\n'),
);
assert.match(masked, /\/home\/<user>\/\.config/);
assert.match(masked, /\/Users\/<user>\/Library/);
assert.match(masked, /C:\\Users\\<user>\\AppData/);
assert.match(masked, /C:\\\\Users\\\\<user>\\\\AppData/);
assert.doesNotMatch(masked, /kyle/);
});
test('exportLogsArchive exports current-day logs and masks usernames', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
const currentLog = writeLog(
logsDir,
'app-2026-W21.log',
'opened /home/kyle/video.mkv and C:\\Users\\kyle\\AppData\\Roaming\\SubMiner\n',
'2026-05-26T12:00:00.000Z',
);
writeLog(logsDir, 'launcher-2026-W20.log', 'old /Users/kyle/Library\n', '2026-05-20T12:00:00Z');
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-26T16:00:00.000Z'),
});
assert.equal(result.mode, 'current-day');
assert.deepEqual(result.exportedFiles, [currentLog]);
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()], ['logs/app-2026-W21.log']);
const content = entries.get('logs/app-2026-W21.log')!.toString('utf8');
assert.match(content, /\/home\/<user>\/video\.mkv/);
assert.match(content, /C:\\Users\\<user>\\AppData/);
assert.doesNotMatch(content, /kyle/);
} finally {
cleanupDir(root);
}
});
test('writeStoredZip rejects names outside ZIP32 limits', () => {
const dir = makeTempDir();
const outputPath = path.join(dir, 'logs.zip');
try {
assert.throws(
() =>
writeStoredZip(outputPath, [
{
name: `${'a'.repeat(0x10000)}.log`,
data: Buffer.from('log\n', 'utf8'),
},
]),
/ZIP entry name too long/,
);
assert.equal(fs.existsSync(outputPath), false);
} finally {
cleanupDir(dir);
}
});
test('exportLogsArchive ignores older dated logs when current-day dated logs exist', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
const currentLog = writeLog(
logsDir,
'app-2026-05-25.log',
'current day\n',
'2026-05-25T18:00:00Z',
);
writeLog(logsDir, 'app-2026-05-24.log', 'previous day touched today\n', '2026-05-25T18:00:00Z');
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-25T20:00:00.000Z'),
});
assert.equal(result.mode, 'current-day');
assert.deepEqual(result.exportedFiles, [currentLog]);
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()], ['logs/app-2026-05-25.log']);
} finally {
cleanupDir(root);
}
});
test('exportLogsArchive falls back to newest log per kind', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
writeLog(logsDir, 'app-2026-W18.log', 'older app\n', '2026-05-01T12:00:00Z');
const appLog = writeLog(logsDir, 'app-2026-W19.log', 'newer app\n', '2026-05-12T12:00:00Z');
const mpvLog = writeLog(logsDir, 'mpv-2026-W17.log', 'latest mpv\n', '2026-05-10T12:00:00Z');
const launcherLog = writeLog(
logsDir,
'launcher-2026-W16.log',
'latest launcher\n',
'2026-05-09T12:00:00Z',
);
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-26T16:00:00.000Z'),
});
assert.equal(result.mode, 'most-recent');
assert.deepEqual(result.exportedFiles.sort(), [appLog, launcherLog, mpvLog].sort());
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()].sort(), [
'logs/app-2026-W19.log',
'logs/launcher-2026-W16.log',
'logs/mpv-2026-W17.log',
]);
} finally {
cleanupDir(root);
}
});
test('exportLogsArchive fails when no logs exist', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
assert.throws(
() => exportLogsArchive({ logsDir, outputDir: root }),
/No SubMiner log files found/,
);
} finally {
cleanupDir(root);
}
});
+184
View File
@@ -0,0 +1,184 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveLogBaseDir } from '../../shared/log-files';
import { writeStoredZip } from '../../shared/stored-zip';
type LogCandidate = {
path: string;
name: string;
kind: string;
mtimeMs: number;
mtimeDateKey: string;
fileDateKey: string | null;
};
export type ExportLogsResult = {
zipPath: string;
exportedFiles: string[];
mode: 'current-day' | 'most-recent';
};
export type ExportLogsOptions = {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
logsDir?: string;
outputDir?: string;
now?: Date;
};
const REDACTED_USER = '<user>';
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function localDateKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function filenameDateKey(fileName: string): string | null {
return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null;
}
function fileKind(fileName: string): string {
const match = fileName.match(/^([A-Za-z0-9_-]+)-/);
return match?.[1] ?? fileName;
}
function zipTimestamp(date: Date): string {
return `${localDateKey(date)}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(
date.getSeconds(),
)}`;
}
function resolveLogsDir(options: ExportLogsOptions): string {
if (options.logsDir) return options.logsDir;
return path.join(
resolveLogBaseDir({
platform: options.platform,
homeDir: options.homeDir,
appDataDir: options.appDataDir,
}),
'logs',
);
}
function buildCandidate(logsDir: string, entry: string): LogCandidate | null {
if (!entry.endsWith('.log')) return null;
const candidatePath = path.join(logsDir, entry);
let stats: fs.Stats;
try {
stats = fs.statSync(candidatePath);
} catch {
return null;
}
if (!stats.isFile()) return null;
return {
path: candidatePath,
name: entry,
kind: fileKind(entry),
mtimeMs: stats.mtimeMs,
mtimeDateKey: localDateKey(stats.mtime),
fileDateKey: filenameDateKey(entry),
};
}
function listLogCandidates(logsDir: string): LogCandidate[] {
let entries: string[];
try {
entries = fs.readdirSync(logsDir);
} catch {
return [];
}
return entries
.map((entry) => buildCandidate(logsDir, entry))
.filter((entry): entry is LogCandidate => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
function selectMostRecentPerKind(candidates: LogCandidate[]): LogCandidate[] {
const byKind = new Map<string, LogCandidate>();
for (const candidate of [...candidates].sort(
(left, right) => candidateFreshnessMs(right) - candidateFreshnessMs(left),
)) {
if (!byKind.has(candidate.kind)) {
byKind.set(candidate.kind, candidate);
}
}
return [...byKind.values()].sort((left, right) => left.name.localeCompare(right.name));
}
function candidateFreshnessMs(candidate: LogCandidate): number {
if (candidate.fileDateKey) {
return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`);
}
return candidate.mtimeMs;
}
function selectLogCandidates(
candidates: LogCandidate[],
now: Date,
): { mode: ExportLogsResult['mode']; selected: LogCandidate[] } {
const today = localDateKey(now);
const currentDated = candidates.filter((candidate) => candidate.fileDateKey === today);
if (currentDated.length > 0) {
return { mode: 'current-day', selected: currentDated };
}
const currentUndated = candidates.filter(
(candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today,
);
if (currentUndated.length > 0) {
return { mode: 'current-day', selected: currentUndated };
}
return { mode: 'most-recent', selected: selectMostRecentPerKind(candidates) };
}
export function maskUsernamesInLogText(text: string): string {
return text
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
}
export function exportLogsArchive(options: ExportLogsOptions = {}): ExportLogsResult {
const now = options.now ?? new Date();
const logsDir = resolveLogsDir(options);
const outputDir = options.outputDir ?? logsDir;
const candidates = listLogCandidates(logsDir);
const { mode, selected } = selectLogCandidates(candidates, now);
if (selected.length === 0) {
throw new Error(`No SubMiner log files found in ${logsDir}`);
}
fs.mkdirSync(outputDir, { recursive: true });
const zipPath = path.join(outputDir, `subminer-logs-${zipTimestamp(now)}.zip`);
writeStoredZip(
zipPath,
selected.map((candidate) => ({
name: `logs/${candidate.name}`,
data: Buffer.from(maskUsernamesInLogText(fs.readFileSync(candidate.path, 'utf8')), 'utf8'),
})),
);
return {
zipPath,
exportedFiles: selected.map((candidate) => candidate.path),
mode,
};
}
export function exportLogsArchiveForCurrentUser(now: Date = new Date()): ExportLogsResult {
return exportLogsArchive({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
now,
});
}
@@ -8,7 +8,7 @@ import {
test('append to mpv log main deps map filesystem functions and log path', async () => {
const calls: string[] = [];
const deps = createBuildAppendToMpvLogMainDepsHandler({
logPath: '/tmp/mpv.log',
getLogPath: () => '/tmp/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
@@ -22,7 +22,7 @@ test('append to mpv log main deps map filesystem functions and log path', async
now: () => new Date('2026-02-20T00:00:00.000Z'),
})();
assert.equal(deps.logPath, '/tmp/mpv.log');
assert.equal(deps.getLogPath(), '/tmp/mpv.log');
assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp');
await deps.mkdir('/tmp', { recursive: true });
await deps.appendFile('/tmp/mpv.log', 'line', { encoding: 'utf8' });
+1 -1
View File
@@ -5,7 +5,7 @@ type ShowMpvOsdMainDeps = Parameters<typeof createShowMpvOsdHandler>[0];
export function createBuildAppendToMpvLogMainDepsHandler(deps: AppendToMpvLogMainDeps) {
return (): AppendToMpvLogMainDeps => ({
logPath: deps.logPath,
getLogPath: () => deps.getLogPath(),
dirname: (targetPath: string) => deps.dirname(targetPath),
mkdir: (targetPath: string, options: { recursive: boolean }) => deps.mkdir(targetPath, options),
appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) =>
+24 -3
View File
@@ -5,7 +5,7 @@ import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-
test('append mpv log writes timestamped message', () => {
const writes: string[] = [];
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: (targetPath: string) => {
writes.push(`dirname:${targetPath}`);
return '/tmp/subminer';
@@ -29,10 +29,31 @@ test('append mpv log writes timestamped message', () => {
});
});
test('append mpv log observes path changes', async () => {
const writes: string[] = [];
let logPath = '';
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
getLogPath: () => logPath,
dirname: () => '/tmp/subminer',
mkdir: async () => {},
appendFile: async (targetPath: string, data: string) => {
writes.push(`${targetPath}:${data.trimEnd()}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
appendToMpvLog('disabled');
logPath = '/tmp/subminer/mpv.log';
appendToMpvLog('enabled');
await flushMpvLog();
assert.deepEqual(writes, ['/tmp/subminer/mpv.log:[2026-02-20T00:00:00.000Z] enabled']);
});
test('append mpv log queues multiple messages and flush waits for pending write', async () => {
const writes: string[] = [];
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {
writes.push('mkdir');
@@ -76,7 +97,7 @@ test('append mpv log queues multiple messages and flush waits for pending write'
test('append mpv log swallows async filesystem errors', async () => {
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {
throw new Error('disk error');
+10 -3
View File
@@ -1,7 +1,7 @@
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
export function createAppendToMpvLogHandler(deps: {
logPath: string;
getLogPath: () => string;
dirname: (targetPath: string) => string;
mkdir: (targetPath: string, options: { recursive: boolean }) => Promise<void>;
appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => Promise<void>;
@@ -13,9 +13,13 @@ export function createAppendToMpvLogHandler(deps: {
const drainPendingLines = async (): Promise<void> => {
while (pendingLines.length > 0) {
const chunk = pendingLines.splice(0, pendingLines.length).join('');
const logPath = deps.getLogPath();
if (!logPath.trim()) {
continue;
}
try {
await deps.mkdir(deps.dirname(deps.logPath), { recursive: true });
await deps.appendFile(deps.logPath, chunk, { encoding: 'utf8' });
await deps.mkdir(deps.dirname(logPath), { recursive: true });
await deps.appendFile(logPath, chunk, { encoding: 'utf8' });
} catch {
// best-effort logging
}
@@ -35,6 +39,9 @@ export function createAppendToMpvLogHandler(deps: {
};
const appendToMpvLog = (message: string): void => {
if (!deps.getLogPath().trim()) {
return;
}
pendingLines.push(`[${deps.now().toISOString()}] ${message}\n`);
void scheduleDrain();
};
@@ -6,7 +6,7 @@ test('mpv osd runtime handlers compose append and osd logging flow', async () =>
const calls: string[] = [];
const runtime = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {},
appendFile: async (_targetPath: string, data: string) => {
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSubtitleTrackDiagnostics } from './mpv-track-diagnostics';
test('buildSubtitleTrackDiagnostics summarizes subtitle tracks without dumping track list', () => {
const diagnostics = buildSubtitleTrackDiagnostics(3, [
{ type: 'video', id: 1, selected: true },
{ type: 'sub', id: 3, lang: 'ja', selected: true, external: false, codec: 'ass' },
{ type: 'sub', id: '4', title: 'English', external: true, codec: 'srt' },
{ type: 'audio', id: 2, lang: 'jpn' },
]);
assert.deepEqual(diagnostics, {
trackListReadable: true,
trackCount: 4,
subtitleTrackCount: 2,
activePrimarySid: 3,
selectedSubtitleIds: [3],
externalSubtitleCount: 1,
internalSubtitleCount: 1,
languages: ['ja'],
selectedSubtitleLabels: ['internal#3:ja'],
});
});
test('buildSubtitleTrackDiagnostics marks unreadable track list', () => {
assert.deepEqual(buildSubtitleTrackDiagnostics(null, null), {
trackListReadable: false,
trackCount: 0,
subtitleTrackCount: 0,
activePrimarySid: null,
selectedSubtitleIds: [],
externalSubtitleCount: 0,
internalSubtitleCount: 0,
languages: [],
selectedSubtitleLabels: [],
});
});
+113
View File
@@ -0,0 +1,113 @@
type MpvTrackDiagnosticEntry = {
id: number | null;
type: string | null;
selected: boolean;
external: boolean;
lang: string | null;
title: string | null;
codec: string | null;
};
export type SubtitleTrackDiagnostics = {
trackListReadable: boolean;
trackCount: number;
subtitleTrackCount: number;
activePrimarySid: number | null;
selectedSubtitleIds: number[];
externalSubtitleCount: number;
internalSubtitleCount: number;
languages: string[];
selectedSubtitleLabels: string[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object');
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length || trimmed === 'no' || trimmed === 'auto') {
return null;
}
const parsed = Number(trimmed);
if (Number.isInteger(parsed)) {
return parsed;
}
}
return null;
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeTrack(track: unknown): MpvTrackDiagnosticEntry | null {
if (!isRecord(track)) {
return null;
}
return {
id: parseTrackId(track.id),
type: readString(track.type),
selected: track.selected === true,
external: track.external === true,
lang: readString(track.lang),
title: readString(track.title),
codec: readString(track.codec),
};
}
function formatSubtitleTrackLabel(track: MpvTrackDiagnosticEntry): string {
const id = track.id === null ? '?' : String(track.id);
const source = track.external ? 'external' : 'internal';
const label = track.lang ?? track.title ?? track.codec ?? 'unknown';
return `${source}#${id}:${label}`;
}
export function buildSubtitleTrackDiagnostics(
activePrimarySid: number | null,
trackList: unknown[] | null,
): SubtitleTrackDiagnostics {
if (!Array.isArray(trackList)) {
return {
trackListReadable: false,
trackCount: 0,
subtitleTrackCount: 0,
activePrimarySid,
selectedSubtitleIds: [],
externalSubtitleCount: 0,
internalSubtitleCount: 0,
languages: [],
selectedSubtitleLabels: [],
};
}
const normalizedTracks = trackList.map(normalizeTrack).filter((track) => track !== null);
const subtitleTracks = normalizedTracks.filter((track) => track.type === 'sub');
const selectedSubtitleTracks = subtitleTracks.filter((track) => track.selected);
const languages = Array.from(
new Set(
subtitleTracks
.map((track) => track.lang)
.filter((language): language is string => language !== null),
),
).sort((left, right) => left.localeCompare(right));
return {
trackListReadable: true,
trackCount: normalizedTracks.length,
subtitleTrackCount: subtitleTracks.length,
activePrimarySid,
selectedSubtitleIds: selectedSubtitleTracks
.map((track) => track.id)
.filter((id): id is number => id !== null),
externalSubtitleCount: subtitleTracks.filter((track) => track.external).length,
internalSubtitleCount: subtitleTracks.filter((track) => !track.external).length,
languages,
selectedSubtitleLabels: selectedSubtitleTracks.map(formatSubtitleTrackLabel),
};
}
@@ -16,7 +16,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
isOverlayShortcutContextActive: () => false,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openJimaku: () => calls.push('jimaku'),
markAudioCard: async () => {
@@ -49,7 +48,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
assert.equal(shortcutsRegistered, true);
deps.showMpvOsd('x');
deps.openRuntimeOptionsPalette();
deps.openCharacterDictionary();
deps.openCharacterDictionaryManager();
deps.openJimaku();
await deps.markAudioCard();
@@ -67,7 +65,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
'registered:true',
'osd:x',
'runtime-options',
'character-dictionary',
'character-dictionary-manager',
'jimaku',
'mark-audio',
@@ -11,7 +11,6 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionary: () => deps.openCharacterDictionary(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(),
markAudioCard: () => deps.markAudioCard(),
@@ -64,7 +64,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});
test('tokenizer deps builder disables name matching when character dictionary is disabled', () => {
test('tokenizer deps builder disables name matching when character dictionary runtime is disabled', () => {
const deps = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
handlers.openAnilistSetup();
@@ -70,6 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -94,6 +96,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
'anilist',
@@ -121,6 +124,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
+5
View File
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -65,6 +66,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -101,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
exportLogs: () => {
deps.exportLogs();
},
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
+2
View File
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -56,6 +57,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
+3
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -77,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
+13 -5
View File
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
@@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 12);
assert.equal(template.length, 13);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -66,14 +67,17 @@ test('tray menu template contains expected entries and handlers', () => {
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.equal(template[6]!.label, 'Export Logs');
template[6]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'texthooker',
'export-logs',
'updates',
'separator',
'quit',
@@ -91,6 +95,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -118,6 +123,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -143,6 +149,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
@@ -169,6 +176,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
+5
View File
@@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -102,6 +103,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open SubMiner Settings',
click: handlers.openConfigSettings,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
},
{
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
+4 -3
View File
@@ -139,9 +139,10 @@ export async function updateAppImageFromRelease(options: {
};
}
const tempPath = path.join(
path.dirname(options.appImagePath),
`.${path.basename(options.appImagePath)}.update`,
const appImagePathApi = options.appImagePath.startsWith('/') ? path.posix : path;
const tempPath = appImagePathApi.join(
appImagePathApi.dirname(options.appImagePath),
`.${appImagePathApi.basename(options.appImagePath)}.update`,
);
try {
await fsDeps.writeFile(tempPath, data);
@@ -63,7 +63,7 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.join(xdgDataHome, 'SubMiner');
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
+6 -2
View File
@@ -30,8 +30,12 @@ export function detectSupportAssetDataDirs(options: {
xdgDataHome?: string;
}): string[] {
if (options.platform === 'linux') {
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
const xdgDataHome = options.xdgDataHome || path.posix.join(options.homeDir, '.local/share');
return [
path.posix.join(xdgDataHome, 'SubMiner'),
'/usr/local/share/SubMiner',
'/usr/share/SubMiner',
];
}
return [];
}
@@ -269,11 +269,13 @@ test('launchWindowsMpv reports missing mpv path', async () => {
test('launchWindowsMpv spawns detached mpv with targets', async () => {
const calls: string[] = [];
const logs: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
logInfo: (message) => logs.push(message),
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
@@ -290,6 +292,59 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
]);
assert.match(logs[0] ?? '', /mpvPath=C:\\mpv\\mpv\.exe/);
assert.match(logs[0] ?? '', /inputIpcServer=\\\\\.\\pipe\\subminer-socket/);
assert.match(
logs[0] ?? '',
/bundledPlugin=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/,
);
assert.match(logs[0] ?? '', /installedPlugin=none/);
});
test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async () => {
const calls: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args, env) => {
calls.push(command);
calls.push(args.join('|'));
calls.push(env?.SUBMINER_LOG_LEVEL ?? '');
calls.push(env?.SUBMINER_LOG_ROTATION ?? '');
},
}),
['--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26.log'],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
undefined,
{
socketPath: '\\\\.\\pipe\\subminer-socket',
binaryPath: '',
backend: 'windows',
logLevel: 'debug',
logRotation: 0,
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
);
assert.equal(result.ok, true);
assert.match(calls[1] ?? '', /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(calls[1] ?? '', /subminer-log_level=debug/);
assert.match(
calls[1] ?? '',
/--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26\.log/,
);
assert.equal(calls[2], 'debug');
assert.equal(calls[3], '0');
});
test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => {
+48 -12
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
@@ -10,8 +12,9 @@ export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => Promise<void>;
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
showError: (title: string, content: string) => void;
logInfo?: (message: string) => void;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
@@ -126,6 +129,13 @@ export function buildWindowsMpvLaunchArgs(
: shouldPassSubminerScriptOpts
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
const logLevel = pluginRuntimeConfig?.logLevel;
const hasMsgLevel = readExtraArgValue(extraArgs, '--msg-level') !== undefined;
const hasLogFile = readExtraArgValue(extraArgs, '--log-file') !== undefined;
const mpvLogLevelArg =
logLevel && !hasMsgLevel && (isLogFileEnabled('mpv') || hasLogFile)
? `--msg-level=${buildMpvMsgLevel(logLevel)}`
: null;
if (!pluginRuntimeConfig && hasBinaryPath) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
@@ -147,6 +157,7 @@ export function buildWindowsMpvLaunchArgs(
'--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []),
...buildMpvLaunchModeArgs(launchMode),
...(mpvLogLevelArg ? [mpvLogLevelArg] : []),
...extraArgs,
...targets,
];
@@ -197,17 +208,39 @@ export async function launchWindowsMpv(
if (installedPlugin?.installed && !installedPluginPrompted) {
runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin);
}
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
pluginRuntimeConfig,
),
const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined;
const hasLogRotation = pluginRuntimeConfig?.logRotation !== undefined;
const launchEnv =
hasLogLevel || hasLogRotation
? {
...(hasLogLevel
? { SUBMINER_LOG_LEVEL: pluginRuntimeConfig.logLevel }
: {}),
...(hasLogRotation
? { SUBMINER_LOG_ROTATION: String(pluginRuntimeConfig.logRotation) }
: {}),
}
: undefined;
const launchArgs = buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
pluginRuntimeConfig,
);
const inputIpcServer =
readExtraArgValue(launchArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
deps.logInfo?.(
[
`Launching mpv: mpvPath=${mpvPath}`,
`inputIpcServer=${inputIpcServer}`,
`bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`,
`installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`,
`targets=${targets.length}`,
].join('; '),
);
await deps.spawnDetached(mpvPath, launchArgs, launchEnv);
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -220,6 +253,7 @@ export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean;
showError: (title: string, content: string) => void;
logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
@@ -235,13 +269,15 @@ export function createWindowsMpvLaunchDeps(options: {
};
},
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
spawnDetached: (command, args) =>
logInfo: options.logInfo,
spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => {
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: env ? { ...process.env, ...env } : process.env,
});
let settled = false;
child.once('error', (error) => {
+2
View File
@@ -7,6 +7,7 @@ export interface StartupBootstrapRuntimeFactoryDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
shouldStartApp: (args: CliArgs) => boolean;
@@ -35,6 +36,7 @@ export function createStartupBootstrapRuntimeDeps(
argv: params.argv,
parseArgs: params.parseArgs,
setLogLevel: params.setLogLevel,
setLogRotation: params.setLogRotation,
forceX11Backend: (args: CliArgs) => params.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args: CliArgs) => params.enforceUnsupportedWaylandMode(args),
getDefaultSocketPath: params.getDefaultSocketPath,