mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
Fix Windows mpv logging and add log export (#88)
This commit is contained in:
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' }) =>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user