Enhance AniList character dictionary sync and subtitle features (#15)

This commit is contained in:
2026-03-07 18:30:59 -08:00
committed by GitHub
parent 2f07c3407a
commit e18985fb14
696 changed files with 14297 additions and 173564 deletions

View File

@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
createDefaultSetupState,
ensureDefaultConfigBootstrap,
getDefaultConfigDir,
getDefaultConfigFilePaths,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
writeSetupState,
} from './setup-state';
function withTempDir(fn: (dir: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-setup-state-test-'));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
const dir = getDefaultConfigDir({
xdgConfigHome: '/tmp/xdg',
homeDir: '/tmp/home',
existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc',
});
assert.equal(dir, '/tmp/xdg/SubMiner');
});
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
withTempDir((root) => {
const configDir = path.join(root, 'SubMiner');
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => '{\n "logging": {}\n}\n',
});
assert.equal(fs.existsSync(configDir), true);
assert.equal(
fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'),
'{\n "logging": {}\n}\n',
);
fs.writeFileSync(path.join(configDir, 'config.json'), '{"keep":true}\n');
fs.rmSync(path.join(configDir, 'config.jsonc'));
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => 'should-not-write',
});
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
assert.equal(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8'), '{"keep":true}\n');
});
});
test('readSetupState ignores invalid files and round-trips valid state', () => {
withTempDir((root) => {
const statePath = getSetupStatePath(root);
fs.writeFileSync(statePath, '{invalid');
assert.equal(readSetupState(statePath), null);
const state = createDefaultSetupState();
state.status = 'completed';
state.completionSource = 'user';
state.lastSeenYomitanDictionaryCount = 2;
writeSetupState(statePath, state);
assert.deepEqual(readSetupState(statePath), state);
});
});
test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => {
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), {
supported: true,
mpvConfigDir: '/tmp/xdg/mpv',
scriptsDir: '/tmp/xdg/mpv/scripts',
scriptOptsDir: '/tmp/xdg/mpv/script-opts',
pluginDir: '/tmp/xdg/mpv/scripts/subminer',
pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf',
});
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), {
supported: true,
mpvConfigDir: '/Users/tester/Library/Application Support/mpv',
scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts',
scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts',
pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer',
pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf',
});
});

195
src/shared/setup-state.ts Normal file
View File

@@ -0,0 +1,195 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigDir } from '../config/path-resolution';
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
export interface SetupState {
version: 1;
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
lastSeenYomitanDictionaryCount: number;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
}
export interface ConfigFilePaths {
jsoncPath: string;
jsonPath: string;
}
export interface MpvInstallPaths {
supported: boolean;
mpvConfigDir: string;
scriptsDir: string;
scriptOptsDir: string;
pluginDir: string;
pluginConfigPath: string;
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function createDefaultSetupState(): SetupState {
return {
version: 1,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
};
}
export function normalizeSetupState(value: unknown): SetupState | null {
const record = asObject(value);
if (!record) return null;
const status = record.status;
const pluginInstallStatus = record.pluginInstallStatus;
const completionSource = record.completionSource;
if (
record.version !== 1 ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
status !== 'cancelled') ||
(pluginInstallStatus !== 'unknown' &&
pluginInstallStatus !== 'installed' &&
pluginInstallStatus !== 'skipped' &&
pluginInstallStatus !== 'failed') ||
(completionSource !== null &&
completionSource !== 'user' &&
completionSource !== 'legacy_auto_detected')
) {
return null;
}
return {
version: 1,
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
lastSeenYomitanDictionaryCount:
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
record.lastSeenYomitanDictionaryCount >= 0
? Math.floor(record.lastSeenYomitanDictionaryCount)
: 0,
pluginInstallStatus,
pluginInstallPathSummary:
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
};
}
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
return state?.status === 'completed';
}
export function getDefaultConfigDir(options?: {
xdgConfigHome?: string;
homeDir?: string;
existsSync?: (candidate: string) => boolean;
}): string {
return resolveConfigDir({
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync,
});
}
export function getDefaultConfigFilePaths(configDir: string): ConfigFilePaths {
return {
jsoncPath: path.join(configDir, 'config.jsonc'),
jsonPath: path.join(configDir, 'config.json'),
};
}
export function getSetupStatePath(configDir: string): string {
return path.join(configDir, 'setup-state.json');
}
export function readSetupState(
statePath: string,
deps?: {
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
},
): SetupState | null {
const existsSync = deps?.existsSync ?? fs.existsSync;
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
if (!existsSync(statePath)) return null;
try {
return normalizeSetupState(JSON.parse(readFileSync(statePath, 'utf8')));
} catch {
return null;
}
}
export function writeSetupState(
statePath: string,
state: SetupState,
deps?: {
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
},
): void {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
mkdirSync(path.dirname(statePath), { recursive: true });
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
export function ensureDefaultConfigBootstrap(options: {
configDir: string;
configFilePaths: ConfigFilePaths;
generateTemplate: () => string;
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
}): void {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
mkdirSync(options.configDir, { recursive: true });
if (
existsSync(options.configFilePaths.jsoncPath) ||
existsSync(options.configFilePaths.jsonPath)
) {
return;
}
writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8');
}
export function resolveDefaultMpvInstallPaths(
platform: NodeJS.Platform,
homeDir: string,
xdgConfigHome?: string,
): MpvInstallPaths {
const mpvConfigDir =
platform === 'darwin'
? path.join(homeDir, 'Library', 'Application Support', 'mpv')
: platform === 'linux'
? path.join(xdgConfigHome?.trim() || path.join(homeDir, '.config'), 'mpv')
: path.join(homeDir, 'AppData', 'Roaming', 'mpv');
return {
supported: platform === 'linux' || platform === 'darwin',
mpvConfigDir,
scriptsDir: path.join(mpvConfigDir, 'scripts'),
scriptOptsDir: path.join(mpvConfigDir, 'script-opts'),
pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'),
pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
};
}

View File

@@ -0,0 +1,17 @@
export const VIDEO_EXTENSIONS = new Set([
'mkv',
'mp4',
'avi',
'webm',
'mov',
'flv',
'wmv',
'm4v',
'ts',
'm2ts',
]);
export function hasVideoExtension(value: string): boolean {
const normalized = value.trim().toLowerCase().replace(/^\./, '');
return normalized.length > 0 && VIDEO_EXTENSIONS.has(normalized);
}