feat(jellyfin): add remote playback and config plumbing

This commit is contained in:
2026-02-17 19:00:18 -08:00
parent a6a28f52f3
commit e38a1c945e
42 changed files with 5608 additions and 1013 deletions

View File

@@ -262,6 +262,40 @@
"accessToken": "" "accessToken": ""
}, },
// ==========================================
// Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch.
// Access token is stored in config and should be treated as a secret.
// ==========================================
"jellyfin": {
"enabled": false,
"serverUrl": "",
"username": "",
"accessToken": "",
"userId": "",
"deviceId": "subminer",
"clientName": "SubMiner",
"clientVersion": "0.1.0",
"defaultLibraryId": "",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
"autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true,
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
],
"transcodeVideoCodec": "h264"
},
// ========================================== // ==========================================
// Immersion Tracking // Immersion Tracking
// Enable/disable immersion tracking. // Enable/disable immersion tracking.
@@ -269,6 +303,18 @@
// ========================================== // ==========================================
"immersionTracking": { "immersionTracking": {
"enabled": true, "enabled": true,
"dbPath": "" "dbPath": "",
"batchSize": 25,
"flushIntervalMs": 500,
"queueCap": 1000,
"payloadCapBytes": 256,
"maintenanceIntervalMs": 86400000,
"retention": {
"eventsDays": 7,
"telemetryDays": 30,
"dailyRollupsDays": 365,
"monthlyRollupsDays": 1825,
"vacuumIntervalDays": 7
}
} }
} }

View File

@@ -176,22 +176,6 @@ features:
<div class="demo-section"> <div class="demo-section">
## CLI Quick Reference
```bash
subminer # Default picker + playback workflow
subminer jellyfin -d # Jellyfin cast discovery mode (foreground)
subminer jellyfin -p # Jellyfin play picker
subminer yt -o ~/subs URL # YouTube subcommand with output dir shortcut
subminer doctor # Dependency/config/socket health checks
subminer config path # Active config file path
subminer config show # Print active config
subminer mpv status # MPV socket readiness
subminer texthooker # Texthooker-only mode
```
See [Usage](/usage) for full command and option coverage.
## See It in Action ## See It in Action
<video controls playsinline preload="metadata" poster="/assets/demo-poster.jpg"> <video controls playsinline preload="metadata" poster="/assets/demo-poster.jpg">

View File

@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { Command } from "commander";
import { parse as parseJsonc } from "jsonc-parser"; import { parse as parseJsonc } from "jsonc-parser";
import type { import type {
LogLevel, YoutubeSubgenMode, Backend, Args, LogLevel, YoutubeSubgenMode, Backend, Args,
@@ -17,72 +18,6 @@ import {
inferWhisperLanguage, inferWhisperLanguage,
} from "./util.js"; } from "./util.js";
export function usage(scriptName: string): string {
return `subminer - Launch MPV with SubMiner sentence mining overlay
Usage: ${scriptName} [OPTIONS] [FILE|DIRECTORY|URL]
Options:
-b, --backend BACKEND Display backend to use: auto, hyprland, x11, macos (default: auto)
-d, --directory DIR Directory to browse for videos (default: current directory)
-r, --recursive Search for videos recursively
-p, --profile PROFILE MPV profile to use (default: subminer)
--start Explicitly start SubMiner overlay
--yt-subgen-mode MODE
YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
--whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription)
--whisper-model PATH
whisper model file path (used for fallback transcription)
--yt-subgen-out-dir DIR
Output directory for generated YouTube subtitles (default: ${DEFAULT_YOUTUBE_SUBGEN_OUT_DIR})
--yt-subgen-audio-format FORMAT
Audio format for extraction (default: m4a)
--yt-subgen-keep-temp
Keep YouTube subtitle temp directory
--log-level LEVEL Set log level: debug, info, warn, error
-R, --rofi Use rofi file browser instead of fzf for video selection
-S, --start-overlay Auto-start SubMiner overlay after MPV socket is ready
-T, --no-texthooker Disable texthooker-ui server
--texthooker Launch only texthooker page (no MPV/overlay workflow)
--jellyfin Open Jellyfin setup window in the app
--jellyfin-login Login via app CLI (requires server/username/password)
--jellyfin-logout Clear Jellyfin token/session via app CLI
--jellyfin-play Pick Jellyfin library/item and play in mpv
--jellyfin-server URL
Jellyfin server URL (for login/play menu API)
--jellyfin-username NAME
Jellyfin username (for login)
--jellyfin-password PASS
Jellyfin password (for login)
-h, --help Show this help message
Environment:
SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override)
SUBMINER_ROFI_THEME Path to rofi theme file (optional override)
SUBMINER_YT_SUBGEN_MODE automatic, preprocess, off (optional default)
SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback)
SUBMINER_WHISPER_MODEL whisper model path (optional fallback)
SUBMINER_YT_SUBGEN_OUT_DIR Generated subtitle output directory
Examples:
${scriptName} # Browse current directory with fzf
${scriptName} -R # Browse current directory with rofi
${scriptName} -d ~/Videos # Browse ~/Videos
${scriptName} -r -d ~/Anime # Recursively browse ~/Anime
${scriptName} video.mkv # Play specific file
${scriptName} https://youtu.be/... # Play a YouTube URL
${scriptName} ytsearch:query # Play first YouTube search result
${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/...
${scriptName} video.mkv # Play with subminer profile
${scriptName} -p gpu-hq video.mkv # Play with gpu-hq profile
${scriptName} -b x11 video.mkv # Force x11 backend
${scriptName} -S video.mkv # Start overlay immediately after MPV launch
${scriptName} --texthooker # Launch only texthooker page
${scriptName} --jellyfin # Open Jellyfin setup form window
${scriptName} --jellyfin-play # Pick Jellyfin library/item and play
`;
}
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
const configDir = path.join(os.homedir(), ".config", "SubMiner"); const configDir = path.join(os.homedir(), ".config", "SubMiner");
const jsoncPath = path.join(configDir, "config.jsonc"); const jsoncPath = path.join(configDir, "config.jsonc");
@@ -307,6 +242,47 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
return runtimeConfig; return runtimeConfig;
} }
function ensureTarget(target: string, parsed: Args): void {
if (isUrlTarget(target)) {
parsed.target = target;
parsed.targetKind = "url";
return;
}
const resolved = resolvePathMaybe(target);
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
parsed.target = resolved;
parsed.targetKind = "file";
return;
}
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
parsed.directory = resolved;
return;
}
fail(`Not a file, directory, or supported URL: ${target}`);
}
function parseLogLevel(value: string): LogLevel {
if (value === "debug" || value === "info" || value === "warn" || value === "error") {
return value;
}
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
}
function parseYoutubeMode(value: string): YoutubeSubgenMode {
const normalized = value.toLowerCase();
if (normalized === "automatic" || normalized === "preprocess" || normalized === "off") {
return normalized as YoutubeSubgenMode;
}
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
}
function parseBackend(value: string): Backend {
if (value === "auto" || value === "hyprland" || value === "x11" || value === "macos") {
return value as Backend;
}
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
}
export function parseArgs( export function parseArgs(
argv: string[], argv: string[],
scriptName: string, scriptName: string,
@@ -362,6 +338,13 @@ export function parseArgs(
jellyfinLogin: false, jellyfinLogin: false,
jellyfinLogout: false, jellyfinLogout: false,
jellyfinPlay: false, jellyfinPlay: false,
jellyfinDiscovery: false,
doctor: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
jellyfinServer: "", jellyfinServer: "",
jellyfinUsername: "", jellyfinUsername: "",
jellyfinPassword: "", jellyfinPassword: "",
@@ -388,307 +371,277 @@ export function parseArgs(
if (launcherConfig.jimakuMaxEntryResults !== undefined) if (launcherConfig.jimakuMaxEntryResults !== undefined)
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
const isValidLogLevel = (value: string): value is LogLevel => let jellyfinInvocation:
value === "debug" || | {
value === "info" || action?: string;
value === "warn" || discovery?: boolean;
value === "error"; play?: boolean;
const isValidYoutubeSubgenMode = (value: string): value is YoutubeSubgenMode => login?: boolean;
value === "automatic" || value === "preprocess" || value === "off"; logout?: boolean;
setup?: boolean;
server?: string;
username?: string;
password?: string;
logLevel?: string;
}
| null = null;
let ytInvocation:
| {
target?: string;
mode?: string;
outDir?: string;
keepTemp?: boolean;
whisperBin?: string;
whisperModel?: string;
ytSubgenAudioFormat?: string;
logLevel?: string;
}
| null = null;
let configInvocation: { action: string; logLevel?: string } | null = null;
let mpvInvocation: { action: string; logLevel?: string } | null = null;
let doctorLogLevel: string | null = null;
let texthookerLogLevel: string | null = null;
let i = 0; const program = new Command();
while (i < argv.length) { program
const arg = argv[i]; .name(scriptName)
.description("Launch MPV with SubMiner sentence mining overlay")
.showHelpAfterError(true)
.enablePositionalOptions()
.allowExcessArguments(false)
.allowUnknownOption(false)
.exitOverride()
.argument("[target]", "file, directory, or URL")
.option("-b, --backend <backend>", "Display backend")
.option("-d, --directory <dir>", "Directory to browse")
.option("-r, --recursive", "Search directories recursively")
.option("-p, --profile <profile>", "MPV profile")
.option("--start", "Explicitly start overlay")
.option("--log-level <level>", "Log level")
.option("-R, --rofi", "Use rofi picker")
.option("-S, --start-overlay", "Auto-start overlay")
.option("-T, --no-texthooker", "Disable texthooker-ui server");
if (arg === "-b" || arg === "--backend") { program
const value = argv[i + 1]; .command("jellyfin")
if (!value) fail("--backend requires a value"); .alias("jf")
if (!["auto", "hyprland", "x11", "macos"].includes(value)) { .description("Jellyfin workflows")
fail( .argument("[action]", "setup|discovery|play|login|logout")
`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`, .option("-d, --discovery", "Cast discovery mode")
); .option("-p, --play", "Interactive play picker")
} .option("-l, --login", "Login flow")
parsed.backend = value as Backend; .option("--logout", "Clear token/session")
i += 2; .option("--setup", "Open setup window")
continue; .option("-s, --server <url>", "Jellyfin server URL")
} .option("-u, --username <name>", "Jellyfin username")
.option("-w, --password <pass>", "Jellyfin password")
.option("--log-level <level>", "Log level")
.action((action: string | undefined, options: Record<string, unknown>) => {
jellyfinInvocation = {
action,
discovery: options.discovery === true,
play: options.play === true,
login: options.login === true,
logout: options.logout === true,
setup: options.setup === true,
server: typeof options.server === "string" ? options.server : undefined,
username: typeof options.username === "string" ? options.username : undefined,
password: typeof options.password === "string" ? options.password : undefined,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
};
});
if (arg === "-d" || arg === "--directory") { program
const value = argv[i + 1]; .command("yt")
if (!value) fail("--directory requires a value"); .alias("youtube")
parsed.directory = value; .description("YouTube workflows")
i += 2; .argument("[target]", "YouTube URL or ytsearch: query")
continue; .option("-m, --mode <mode>", "Subtitle generation mode")
} .option("-o, --out-dir <dir>", "Subtitle output dir")
.option("--keep-temp", "Keep temp files")
.option("--whisper-bin <path>", "whisper.cpp CLI path")
.option("--whisper-model <path>", "whisper model path")
.option("--yt-subgen-audio-format <format>", "Audio extraction format")
.option("--log-level <level>", "Log level")
.action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = {
target,
mode: typeof options.mode === "string" ? options.mode : undefined,
outDir: typeof options.outDir === "string" ? options.outDir : undefined,
keepTemp: options.keepTemp === true,
whisperBin:
typeof options.whisperBin === "string" ? options.whisperBin : undefined,
whisperModel:
typeof options.whisperModel === "string" ? options.whisperModel : undefined,
ytSubgenAudioFormat:
typeof options.ytSubgenAudioFormat === "string"
? options.ytSubgenAudioFormat
: undefined,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
};
});
if (arg === "-r" || arg === "--recursive") { program
parsed.recursive = true; .command("doctor")
i += 1; .description("Run dependency and environment checks")
continue; .option("--log-level <level>", "Log level")
} .action((options: Record<string, unknown>) => {
parsed.doctor = true;
doctorLogLevel =
typeof options.logLevel === "string" ? options.logLevel : null;
});
if (arg === "-p" || arg === "--profile") { program
const value = argv[i + 1]; .command("config")
if (!value) fail("--profile requires a value"); .description("Config helpers")
parsed.profile = value; .argument("[action]", "path|show", "path")
i += 2; .option("--log-level <level>", "Log level")
continue; .action((action: string, options: Record<string, unknown>) => {
} configInvocation = {
action,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
};
});
if (arg === "--start") { program
parsed.startOverlay = true; .command("mpv")
i += 1; .description("MPV helpers")
continue; .argument("[action]", "status|socket|idle", "status")
} .option("--log-level <level>", "Log level")
.action((action: string, options: Record<string, unknown>) => {
mpvInvocation = {
action,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
};
});
if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") { program
const value = (argv[i + 1] || "").toLowerCase(); .command("texthooker")
if (!isValidYoutubeSubgenMode(value)) { .description("Launch texthooker-only mode")
fail( .option("--log-level <level>", "Log level")
`Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`, .action((options: Record<string, unknown>) => {
);
}
parsed.youtubeSubgenMode = value;
i += 2;
continue;
}
if (
arg.startsWith("--yt-subgen-mode=") ||
arg.startsWith("--youtube-subgen-mode=")
) {
const value = arg.split("=", 2)[1]?.toLowerCase() || "";
if (!isValidYoutubeSubgenMode(value)) {
fail(
`Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
);
}
parsed.youtubeSubgenMode = value;
i += 1;
continue;
}
if (arg === "--whisper-bin") {
const value = argv[i + 1];
if (!value) fail("--whisper-bin requires a value");
parsed.whisperBin = value;
i += 2;
continue;
}
if (arg.startsWith("--whisper-bin=")) {
const value = arg.slice("--whisper-bin=".length);
if (!value) fail("--whisper-bin requires a value");
parsed.whisperBin = value;
i += 1;
continue;
}
if (arg === "--whisper-model") {
const value = argv[i + 1];
if (!value) fail("--whisper-model requires a value");
parsed.whisperModel = value;
i += 2;
continue;
}
if (arg.startsWith("--whisper-model=")) {
const value = arg.slice("--whisper-model=".length);
if (!value) fail("--whisper-model requires a value");
parsed.whisperModel = value;
i += 1;
continue;
}
if (arg === "--yt-subgen-out-dir") {
const value = argv[i + 1];
if (!value) fail("--yt-subgen-out-dir requires a value");
parsed.youtubeSubgenOutDir = value;
i += 2;
continue;
}
if (arg.startsWith("--yt-subgen-out-dir=")) {
const value = arg.slice("--yt-subgen-out-dir=".length);
if (!value) fail("--yt-subgen-out-dir requires a value");
parsed.youtubeSubgenOutDir = value;
i += 1;
continue;
}
if (arg === "--yt-subgen-audio-format") {
const value = argv[i + 1];
if (!value) fail("--yt-subgen-audio-format requires a value");
parsed.youtubeSubgenAudioFormat = value;
i += 2;
continue;
}
if (arg.startsWith("--yt-subgen-audio-format=")) {
const value = arg.slice("--yt-subgen-audio-format=".length);
if (!value) fail("--yt-subgen-audio-format requires a value");
parsed.youtubeSubgenAudioFormat = value;
i += 1;
continue;
}
if (arg === "--yt-subgen-keep-temp") {
parsed.youtubeSubgenKeepTemp = true;
i += 1;
continue;
}
if (arg === "--log-level") {
const value = argv[i + 1];
if (!value || !isValidLogLevel(value)) {
fail(
`Invalid log level: ${value ?? ""} (must be debug, info, warn, or error)`,
);
}
parsed.logLevel = value;
i += 2;
continue;
}
if (arg.startsWith("--log-level=")) {
const value = arg.slice("--log-level=".length);
if (!isValidLogLevel(value)) {
fail(
`Invalid log level: ${value} (must be debug, info, warn, or error)`,
);
}
parsed.logLevel = value;
i += 1;
continue;
}
if (arg === "-R" || arg === "--rofi") {
parsed.useRofi = true;
i += 1;
continue;
}
if (arg === "-S" || arg === "--start-overlay") {
parsed.autoStartOverlay = true;
i += 1;
continue;
}
if (arg === "-T" || arg === "--no-texthooker") {
parsed.useTexthooker = false;
i += 1;
continue;
}
if (arg === "--texthooker") {
parsed.texthookerOnly = true; parsed.texthookerOnly = true;
i += 1; texthookerLogLevel =
continue; typeof options.logLevel === "string" ? options.logLevel : null;
} });
if (arg === "--jellyfin") { try {
parsed.jellyfin = true; program.parse(["node", scriptName, ...argv]);
i += 1; } catch (error) {
continue; const commanderError = error as { code?: string; message?: string };
} if (commanderError?.code === "commander.helpDisplayed") {
if (arg === "--jellyfin-login") {
parsed.jellyfinLogin = true;
i += 1;
continue;
}
if (arg === "--jellyfin-logout") {
parsed.jellyfinLogout = true;
i += 1;
continue;
}
if (arg === "--jellyfin-play") {
parsed.jellyfinPlay = true;
i += 1;
continue;
}
if (arg === "--jellyfin-server") {
const value = argv[i + 1];
if (!value) fail("--jellyfin-server requires a value");
parsed.jellyfinServer = value;
i += 2;
continue;
}
if (arg.startsWith("--jellyfin-server=")) {
parsed.jellyfinServer = arg.split("=", 2)[1] || "";
i += 1;
continue;
}
if (arg === "--jellyfin-username") {
const value = argv[i + 1];
if (!value) fail("--jellyfin-username requires a value");
parsed.jellyfinUsername = value;
i += 2;
continue;
}
if (arg.startsWith("--jellyfin-username=")) {
parsed.jellyfinUsername = arg.split("=", 2)[1] || "";
i += 1;
continue;
}
if (arg === "--jellyfin-password") {
const value = argv[i + 1];
if (!value) fail("--jellyfin-password requires a value");
parsed.jellyfinPassword = value;
i += 2;
continue;
}
if (arg.startsWith("--jellyfin-password=")) {
parsed.jellyfinPassword = arg.split("=", 2)[1] || "";
i += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
process.stdout.write(usage(scriptName));
process.exit(0); process.exit(0);
} }
fail(commanderError?.message || String(error));
if (arg === "--") {
i += 1;
break;
} }
if (arg.startsWith("-")) { const options = program.opts<Record<string, unknown>>();
fail(`Unknown option: ${arg}`); if (typeof options.backend === "string") {
parsed.backend = parseBackend(options.backend);
}
if (typeof options.directory === "string") {
parsed.directory = options.directory;
}
if (options.recursive === true) parsed.recursive = true;
if (typeof options.profile === "string") {
parsed.profile = options.profile;
}
if (options.start === true) parsed.startOverlay = true;
if (typeof options.logLevel === "string") {
parsed.logLevel = parseLogLevel(options.logLevel);
}
if (options.rofi === true) parsed.useRofi = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
const rootTarget = program.processedArgs[0];
if (typeof rootTarget === "string" && rootTarget) {
ensureTarget(rootTarget, parsed);
} }
break; if (jellyfinInvocation) {
if (jellyfinInvocation.logLevel) {
parsed.logLevel = parseLogLevel(jellyfinInvocation.logLevel);
}
const action = (jellyfinInvocation.action || "").toLowerCase();
if (action && !["setup", "discovery", "play", "login", "logout"].includes(action)) {
fail(`Unknown jellyfin action: ${jellyfinInvocation.action}`);
} }
const positional = argv.slice(i); parsed.jellyfinServer = jellyfinInvocation.server || "";
if (positional.length > 0) { parsed.jellyfinUsername = jellyfinInvocation.username || "";
const target = positional[0]; parsed.jellyfinPassword = jellyfinInvocation.password || "";
if (isUrlTarget(target)) {
parsed.target = target; const modeFlags = {
parsed.targetKind = "url"; setup: jellyfinInvocation.setup || action === "setup",
} else { discovery: jellyfinInvocation.discovery || action === "discovery",
const resolved = resolvePathMaybe(target); play: jellyfinInvocation.play || action === "play",
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { login: jellyfinInvocation.login || action === "login",
parsed.target = resolved; logout: jellyfinInvocation.logout || action === "logout",
parsed.targetKind = "file"; };
} else if ( if (!modeFlags.setup && !modeFlags.discovery && !modeFlags.play && !modeFlags.login && !modeFlags.logout) {
fs.existsSync(resolved) && modeFlags.setup = true;
fs.statSync(resolved).isDirectory() }
) {
parsed.directory = resolved; parsed.jellyfin = Boolean(modeFlags.setup);
} else { parsed.jellyfinDiscovery = Boolean(modeFlags.discovery);
fail(`Not a file, directory, or supported URL: ${target}`); parsed.jellyfinPlay = Boolean(modeFlags.play);
parsed.jellyfinLogin = Boolean(modeFlags.login);
parsed.jellyfinLogout = Boolean(modeFlags.logout);
}
if (ytInvocation) {
if (ytInvocation.logLevel) {
parsed.logLevel = parseLogLevel(ytInvocation.logLevel);
}
const mode = ytInvocation.mode;
if (mode) parsed.youtubeSubgenMode = parseYoutubeMode(mode);
const outDir = ytInvocation.outDir;
if (outDir) parsed.youtubeSubgenOutDir = outDir;
if (ytInvocation.keepTemp) {
parsed.youtubeSubgenKeepTemp = true;
}
if (ytInvocation.whisperBin) parsed.whisperBin = ytInvocation.whisperBin;
if (ytInvocation.whisperModel) parsed.whisperModel = ytInvocation.whisperModel;
if (ytInvocation.ytSubgenAudioFormat) {
parsed.youtubeSubgenAudioFormat = ytInvocation.ytSubgenAudioFormat;
}
if (ytInvocation.target) {
ensureTarget(ytInvocation.target, parsed);
} }
} }
if (doctorLogLevel) {
parsed.logLevel = parseLogLevel(doctorLogLevel);
}
if (texthookerLogLevel) {
parsed.logLevel = parseLogLevel(texthookerLogLevel);
}
if (configInvocation !== null) {
if (configInvocation.logLevel) {
parsed.logLevel = parseLogLevel(configInvocation.logLevel);
}
const action = (configInvocation.action || "path").toLowerCase();
if (action === "path") parsed.configPath = true;
else if (action === "show") parsed.configShow = true;
else fail(`Unknown config action: ${configInvocation.action}`);
}
if (mpvInvocation !== null) {
if (mpvInvocation.logLevel) {
parsed.logLevel = parseLogLevel(mpvInvocation.logLevel);
}
const action = (mpvInvocation.action || "status").toLowerCase();
if (action === "status") parsed.mpvStatus = true;
else if (action === "socket") parsed.mpvSocket = true;
else if (action === "idle" || action === "start") parsed.mpvIdle = true;
else fail(`Unknown mpv action: ${mpvInvocation.action}`);
} }
return parsed; return parsed;

View File

@@ -1,5 +1,5 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import fs from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js"; import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
import { log, fail } from "./log.js"; import { log, fail } from "./log.js";
@@ -115,6 +115,39 @@ export async function resolveJellyfinSelection(
session: JellyfinSessionConfig, session: JellyfinSessionConfig,
themePath: string | null = null, themePath: string | null = null,
): Promise<string> { ): Promise<string> {
const asNumberOrNull = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return value;
};
const compareByName = (left: string, right: string): number =>
left.localeCompare(right, undefined, { sensitivity: "base", numeric: true });
const sortEntries = (
entries: Array<{
type: string;
name: string;
parentIndex: number | null;
index: number | null;
display: string;
}>,
) =>
entries.sort((left, right) => {
if (left.type === "Episode" && right.type === "Episode") {
const leftSeason = left.parentIndex ?? Number.MAX_SAFE_INTEGER;
const rightSeason = right.parentIndex ?? Number.MAX_SAFE_INTEGER;
if (leftSeason !== rightSeason) return leftSeason - rightSeason;
const leftEpisode = left.index ?? Number.MAX_SAFE_INTEGER;
const rightEpisode = right.index ?? Number.MAX_SAFE_INTEGER;
if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode;
}
if (left.type !== right.type) {
const leftEpisodeLike = left.type === "Episode";
const rightEpisodeLike = right.type === "Episode";
if (leftEpisodeLike && !rightEpisodeLike) return -1;
if (!leftEpisodeLike && rightEpisodeLike) return 1;
}
return compareByName(left.display, right.display);
});
const libsPayload = await jellyfinApiRequest<{ Items?: Array<Record<string, unknown>> }>( const libsPayload = await jellyfinApiRequest<{ Items?: Array<Record<string, unknown>> }>(
session, session,
`/Users/${session.userId}/Views`, `/Users/${session.userId}/Views`,
@@ -194,6 +227,7 @@ export async function resolveJellyfinSelection(
.filter((entry) => entry.id.length > 0); .filter((entry) => entry.id.length > 0);
let contentParentId = libraryId; let contentParentId = libraryId;
let contentRecursive = true;
const selectedGroupId = pickGroup( const selectedGroupId = pickGroup(
session, session,
groups, groups,
@@ -222,6 +256,7 @@ export async function resolveJellyfinSelection(
}) })
.filter((entry) => entry.id.length > 0); .filter((entry) => entry.id.length > 0);
if (seasons.length > 0) { if (seasons.length > 0) {
const seasonsById = new Map(seasons.map((entry) => [entry.id, entry]));
const selectedSeasonId = pickGroup( const selectedSeasonId = pickGroup(
session, session,
seasons, seasons,
@@ -232,6 +267,10 @@ export async function resolveJellyfinSelection(
); );
if (!selectedSeasonId) fail("No Jellyfin season selected."); if (!selectedSeasonId) fail("No Jellyfin season selected.");
contentParentId = selectedSeasonId; contentParentId = selectedSeasonId;
const selectedSeason = seasonsById.get(selectedSeasonId);
if (selectedSeason?.type === "Season") {
contentRecursive = false;
}
} }
} }
@@ -241,7 +280,7 @@ export async function resolveJellyfinSelection(
TotalRecordCount?: number; TotalRecordCount?: number;
}>( }>(
session, session,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=true&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`, `/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? "true" : "false"}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
); );
const allEntries: Array<Record<string, unknown>> = []; const allEntries: Array<Record<string, unknown>> = [];
@@ -259,7 +298,8 @@ export async function resolveJellyfinSelection(
if (page.length < 500) break; if (page.length < 500) break;
} }
let items: JellyfinItemEntry[] = allEntries let items: JellyfinItemEntry[] = sortEntries(
allEntries
.filter((item) => { .filter((item) => {
const type = typeof item.Type === "string" ? item.Type : ""; const type = typeof item.Type === "string" ? item.Type : "";
return type === "Movie" || type === "Episode" || type === "Audio"; return type === "Movie" || type === "Episode" || type === "Audio";
@@ -268,12 +308,21 @@ export async function resolveJellyfinSelection(
id: typeof item.Id === "string" ? item.Id : "", id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "", name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item", type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item), display: formatJellyfinItemDisplay(item),
})) }))
.filter((item) => item.id.length > 0); .filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
type,
display,
}));
if (items.length === 0) { if (items.length === 0) {
items = allEntries items = sortEntries(
allEntries
.filter((item) => { .filter((item) => {
const type = typeof item.Type === "string" ? item.Type : ""; const type = typeof item.Type === "string" ? item.Type : "";
if (type === "Folder" || type === "CollectionFolder") return false; if (type === "Folder" || type === "CollectionFolder") return false;
@@ -292,9 +341,17 @@ export async function resolveJellyfinSelection(
id: typeof item.Id === "string" ? item.Id : "", id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "", name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item", type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item), display: formatJellyfinItemDisplay(item),
})) }))
.filter((item) => item.id.length > 0); .filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
type,
display,
}));
} }
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, "", themePath); const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, "", themePath);
@@ -335,19 +392,23 @@ export async function runJellyfinPlayMenu(
const itemId = await resolveJellyfinSelection(args, session, rofiTheme); const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
log("debug", args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); log("debug", args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
try {
fs.rmSync(mpvSocketPath, { force: true });
} catch {
// ignore cleanup errors
}
log("debug", args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); log("debug", args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
launchMpvIdleDetached(mpvSocketPath, appPath, args); let mpvReady = false;
const mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000); if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log( log(
"debug", "debug",
args.logLevel, args.logLevel,
`MPV socket ready check result: ${mpvReady ? "ready" : "not ready"}`, `MPV socket ready check result: ${mpvReady ? "ready" : "not ready"}`,
); );
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ["--start", "--jellyfin-play", "--jellyfin-item-id", itemId]; const forwarded = ["--start", "--jellyfin-play", "--jellyfin-item-id", itemId];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel); if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play"); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play");

View File

@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os";
import type { Args } from "./types.js"; import type { Args } from "./types.js";
import { log, fail } from "./log.js"; import { log, fail } from "./log.js";
import { import {
@@ -13,6 +14,7 @@ import { showRofiMenu, showFzfMenu, collectVideos } from "./picker.js";
import { import {
state, startMpv, startOverlay, stopOverlay, launchTexthookerOnly, state, startMpv, startOverlay, stopOverlay, launchTexthookerOnly,
findAppBinary, waitForSocket, loadSubtitleIntoMpv, runAppCommandWithInherit, findAppBinary, waitForSocket, loadSubtitleIntoMpv, runAppCommandWithInherit,
launchMpvIdleDetached, waitForUnixSocketReady,
} from "./mpv.js"; } from "./mpv.js";
import { generateYoutubeSubtitles } from "./youtube.js"; import { generateYoutubeSubtitles } from "./youtube.js";
import { runJellyfinPlayMenu } from "./jellyfin.js"; import { runJellyfinPlayMenu } from "./jellyfin.js";
@@ -94,6 +96,77 @@ function registerCleanup(args: Args): void {
}); });
} }
function resolveMainConfigPath(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const baseDirs = Array.from(new Set([xdgConfigHome, path.join(os.homedir(), ".config")]));
const appNames = ["SubMiner", "subminer"];
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const jsoncPath = path.join(baseDir, appName, "config.jsonc");
if (fs.existsSync(jsoncPath)) return jsoncPath;
const jsonPath = path.join(baseDir, appName, "config.json");
if (fs.existsSync(jsonPath)) return jsonPath;
}
}
return path.join(baseDirs[0], "SubMiner", "config.jsonc");
}
function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never {
const checks: Array<{ label: string; ok: boolean; detail: string }> = [
{
label: "app binary",
ok: Boolean(appPath),
detail: appPath || "not found (set SUBMINER_APPIMAGE_PATH)",
},
{
label: "mpv",
ok: commandExists("mpv"),
detail: commandExists("mpv") ? "found" : "missing",
},
{
label: "yt-dlp",
ok: commandExists("yt-dlp"),
detail: commandExists("yt-dlp") ? "found" : "missing (optional unless YouTube URLs)",
},
{
label: "ffmpeg",
ok: commandExists("ffmpeg"),
detail: commandExists("ffmpeg") ? "found" : "missing (optional unless subtitle generation)",
},
{
label: "fzf",
ok: commandExists("fzf"),
detail: commandExists("fzf") ? "found" : "missing (optional if using rofi)",
},
{
label: "rofi",
ok: commandExists("rofi"),
detail: commandExists("rofi") ? "found" : "missing (optional if using fzf)",
},
{
label: "config",
ok: fs.existsSync(resolveMainConfigPath()),
detail: resolveMainConfigPath(),
},
{
label: "mpv socket path",
ok: true,
detail: mpvSocketPath,
},
];
const hasHardFailure = checks.some(
(entry) => entry.label === "app binary" || entry.label === "mpv"
? !entry.ok
: false,
);
for (const check of checks) {
log(check.ok ? "info" : "warn", args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
}
process.exit(hasHardFailure ? 1 : 0);
}
async function main(): Promise<void> { async function main(): Promise<void> {
const scriptPath = process.argv[1] || "subminer"; const scriptPath = process.argv[1] || "subminer";
const scriptName = path.basename(scriptPath); const scriptName = path.basename(scriptPath);
@@ -106,6 +179,43 @@ async function main(): Promise<void> {
log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`); log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const appPath = findAppBinary(process.argv[1] || "subminer"); const appPath = findAppBinary(process.argv[1] || "subminer");
if (args.doctor) {
runDoctor(args, appPath, mpvSocketPath);
}
if (args.configPath) {
process.stdout.write(`${resolveMainConfigPath()}\n`);
return;
}
if (args.configShow) {
const configPath = resolveMainConfigPath();
if (!fs.existsSync(configPath)) {
fail(`Config file not found: ${configPath}`);
}
const contents = fs.readFileSync(configPath, "utf8");
process.stdout.write(contents);
if (!contents.endsWith("\n")) {
process.stdout.write("\n");
}
return;
}
if (args.mpvSocket) {
process.stdout.write(`${mpvSocketPath}\n`);
return;
}
if (args.mpvStatus) {
const ready = await waitForUnixSocketReady(mpvSocketPath, 500);
log(
ready ? "info" : "warn",
args.logLevel,
`[mpv] socket ${ready ? "ready" : "not ready"}: ${mpvSocketPath}`,
);
process.exit(ready ? 0 : 1);
}
if (!appPath) { if (!appPath) {
if (process.platform === "darwin") { if (process.platform === "darwin") {
fail( fail(
@@ -118,6 +228,16 @@ async function main(): Promise<void> {
} }
state.appPath = appPath; state.appPath = appPath;
if (args.mpvIdle) {
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
const ready = await waitForUnixSocketReady(mpvSocketPath, 8000);
if (!ready) {
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
}
log("info", args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
return;
}
if (args.texthookerOnly) { if (args.texthookerOnly) {
launchTexthookerOnly(appPath, args); launchTexthookerOnly(appPath, args);
} }
@@ -166,6 +286,12 @@ async function main(): Promise<void> {
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath); await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
} }
if (args.jellyfinDiscovery) {
const forwarded = ["--start"];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
runAppCommandWithInherit(appPath, forwarded);
}
if (!args.target) { if (!args.target) {
checkPickerDependencies(args); checkPickerDependencies(args);
} }

View File

@@ -20,6 +20,93 @@ export const state = {
stopRequested: false, stopRequested: false,
}; };
const DETACHED_IDLE_MPV_PID_FILE = path.join(
os.tmpdir(),
"subminer-idle-mpv.pid",
);
function readTrackedDetachedMpvPid(): number | null {
try {
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, "utf8").trim();
const pid = Number.parseInt(raw, 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function clearTrackedDetachedMpvPid(): void {
try {
fs.rmSync(DETACHED_IDLE_MPV_PID_FILE, { force: true });
} catch {
// ignore
}
}
function trackDetachedMpvPid(pid: number): void {
try {
fs.writeFileSync(DETACHED_IDLE_MPV_PID_FILE, String(pid), "utf8");
} catch {
// ignore
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function processLooksLikeMpv(pid: number): boolean {
if (process.platform !== "linux") return true;
try {
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
return cmdline.includes("mpv");
} catch {
return false;
}
}
async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
const pid = readTrackedDetachedMpvPid();
if (!pid) return;
if (!isProcessAlive(pid)) {
clearTrackedDetachedMpvPid();
return;
}
if (!processLooksLikeMpv(pid)) {
clearTrackedDetachedMpvPid();
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
clearTrackedDetachedMpvPid();
return;
}
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
clearTrackedDetachedMpvPid();
return;
}
await sleep(100);
}
try {
process.kill(pid, "SIGKILL");
} catch {
// ignore
}
clearTrackedDetachedMpvPid();
log("debug", logLevel, `Terminated stale detached mpv pid=${pid}`);
}
export function makeTempDir(prefix: string): string { export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
} }
@@ -525,6 +612,8 @@ export function stopOverlay(args: Args): void {
} }
} }
state.youtubeSubgenChildren.clear(); state.youtubeSubgenChildren.clear();
void terminateTrackedDetachedMpv(args.logLevel);
} }
function buildAppEnv(): NodeJS.ProcessEnv { function buildAppEnv(): NodeJS.ProcessEnv {
@@ -595,7 +684,15 @@ export function launchMpvIdleDetached(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
args: Args, args: Args,
): void { ): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
const mpvArgs: string[] = []; const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`); if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
@@ -609,7 +706,11 @@ export function launchMpvIdleDetached(
stdio: "ignore", stdio: "ignore",
detached: true, detached: true,
}); });
if (typeof proc.pid === "number" && proc.pid > 0) {
trackDetachedMpvPid(proc.pid);
}
proc.unref(); proc.unref();
})();
} }
async function sleepMs(ms: number): Promise<void> { async function sleepMs(ms: number): Promise<void> {

View File

@@ -185,11 +185,14 @@ function showRofiIconMenu(
if (initialQuery) rofiArgs.push("-filter", initialQuery); if (initialQuery) rofiArgs.push("-filter", initialQuery);
if (themePath) { if (themePath) {
rofiArgs.push("-theme", themePath); rofiArgs.push("-theme", themePath);
rofiArgs.push("-theme-str", "configuration { show-icons: true; }");
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
} else { } else {
rofiArgs.push( rofiArgs.push(
"-theme-str", "-theme-str",
'configuration { font: "Noto Sans CJK JP Regular 8";}', 'configuration { font: "Noto Sans CJK JP Regular 8"; show-icons: true; }',
); );
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
} }
const lines = entries.map((entry) => const lines = entries.map((entry) =>
@@ -197,11 +200,12 @@ function showRofiIconMenu(
? `${entry.label}\u0000icon\u001f${entry.iconPath}` ? `${entry.label}\u0000icon\u001f${entry.iconPath}`
: entry.label : entry.label
); );
const input = Buffer.from(`${lines.join("\n")}\n`, "utf8");
const result = spawnSync( const result = spawnSync(
"rofi", "rofi",
rofiArgs, rofiArgs,
{ {
input: `${lines.join("\n")}\n`, input,
encoding: "utf8", encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"], stdio: ["pipe", "pipe", "ignore"],
}, },

View File

@@ -48,7 +48,8 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
"--sid=auto", "--sid=auto",
"--secondary-sid=auto", "--secondary-sid=auto",
"--secondary-sub-visibility=no", "--secondary-sub-visibility=no",
"--slang=ja,jpn,en,eng", "--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us",
"--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us",
] as const; ] as const;
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";
@@ -88,6 +89,13 @@ export interface Args {
jellyfinLogin: boolean; jellyfinLogin: boolean;
jellyfinLogout: boolean; jellyfinLogout: boolean;
jellyfinPlay: boolean; jellyfinPlay: boolean;
jellyfinDiscovery: boolean;
doctor: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
mpvSocket: boolean;
mpvStatus: boolean;
jellyfinServer: string; jellyfinServer: string;
jellyfinUsername: string; jellyfinUsername: string;
jellyfinPassword: string; jellyfinPassword: string;

View File

@@ -4,8 +4,8 @@
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"get-frequency": "bun run scripts/get_frequency.ts", "get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"get-frequency:electron": "bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js", "get-frequency:electron": "bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts", "test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
"test-yomitan-parser:electron": "bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js", "test-yomitan-parser:electron": "bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
"build": "tsc && pnpm run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && bash scripts/build-macos-helper.sh", "build": "tsc && pnpm run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && bash scripts/build-macos-helper.sh",
@@ -14,7 +14,7 @@
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
"test:config:dist": "node --test dist/config/config.test.js", "test:config:dist": "node --test dist/config/config.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "pnpm run test:config && pnpm run test:core", "test": "pnpm run test:config && pnpm run test:core",
"test:config": "pnpm run build && pnpm run test:config:dist", "test:config": "pnpm run build && pnpm run test:config:dist",
@@ -46,6 +46,7 @@
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"ws": "^8.19.0" "ws": "^8.19.0"

9
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
axios: axios:
specifier: ^1.13.5 specifier: ^1.13.5
version: 1.13.5 version: 1.13.5
commander:
specifier: ^14.0.3
version: 14.0.3
jsonc-parser: jsonc-parser:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
@@ -1220,6 +1223,10 @@ packages:
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@5.1.0: commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3901,6 +3908,8 @@ snapshots:
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
commander@14.0.3: {}
commander@5.1.0: {} commander@5.1.0: {}
commander@7.2.0: {} commander@7.2.0: {}

View File

@@ -13,6 +13,13 @@ test("parseArgs parses booleans and value flags", () => {
"--log-level", "--log-level",
"warn", "warn",
"--debug", "--debug",
"--jellyfin-play",
"--jellyfin-server",
"http://jellyfin.local:8096",
"--jellyfin-item-id",
"item-123",
"--jellyfin-audio-stream-index",
"2",
]); ]);
assert.equal(args.start, true); assert.equal(args.start, true);
@@ -21,6 +28,10 @@ test("parseArgs parses booleans and value flags", () => {
assert.equal(args.texthookerPort, 6000); assert.equal(args.texthookerPort, 6000);
assert.equal(args.logLevel, "warn"); assert.equal(args.logLevel, "warn");
assert.equal(args.debug, true); assert.equal(args.debug, true);
assert.equal(args.jellyfinPlay, true);
assert.equal(args.jellyfinServer, "http://jellyfin.local:8096");
assert.equal(args.jellyfinItemId, "item-123");
assert.equal(args.jellyfinAudioStreamIndex, 2);
}); });
test("parseArgs ignores missing value after --log-level", () => { test("parseArgs ignores missing value after --log-level", () => {
@@ -56,4 +67,33 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
assert.equal(anilistRetryQueue.anilistRetryQueue, true); assert.equal(anilistRetryQueue.anilistRetryQueue, true);
assert.equal(hasExplicitCommand(anilistRetryQueue), true); assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false); assert.equal(shouldStartApp(anilistRetryQueue), false);
const jellyfinLibraries = parseArgs(["--jellyfin-libraries"]);
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
assert.equal(shouldStartApp(jellyfinLibraries), false);
const jellyfinSetup = parseArgs(["--jellyfin"]);
assert.equal(jellyfinSetup.jellyfin, true);
assert.equal(hasExplicitCommand(jellyfinSetup), true);
assert.equal(shouldStartApp(jellyfinSetup), true);
const jellyfinPlay = parseArgs(["--jellyfin-play"]);
assert.equal(jellyfinPlay.jellyfinPlay, true);
assert.equal(hasExplicitCommand(jellyfinPlay), true);
assert.equal(shouldStartApp(jellyfinPlay), true);
const jellyfinSubtitles = parseArgs([
"--jellyfin-subtitles",
"--jellyfin-subtitle-urls",
]);
assert.equal(jellyfinSubtitles.jellyfinSubtitles, true);
assert.equal(jellyfinSubtitles.jellyfinSubtitleUrlsOnly, true);
assert.equal(hasExplicitCommand(jellyfinSubtitles), true);
assert.equal(shouldStartApp(jellyfinSubtitles), false);
const jellyfinRemoteAnnounce = parseArgs(["--jellyfin-remote-announce"]);
assert.equal(jellyfinRemoteAnnounce.jellyfinRemoteAnnounce, true);
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
}); });

View File

@@ -26,6 +26,15 @@ export interface CliArgs {
anilistLogout: boolean; anilistLogout: boolean;
anilistSetup: boolean; anilistSetup: boolean;
anilistRetryQueue: boolean; anilistRetryQueue: boolean;
jellyfin: boolean;
jellyfinLogin: boolean;
jellyfinLogout: boolean;
jellyfinLibraries: boolean;
jellyfinItems: boolean;
jellyfinSubtitles: boolean;
jellyfinSubtitleUrlsOnly: boolean;
jellyfinPlay: boolean;
jellyfinRemoteAnnounce: boolean;
texthooker: boolean; texthooker: boolean;
help: boolean; help: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
@@ -35,6 +44,15 @@ export interface CliArgs {
socketPath?: string; socketPath?: string;
backend?: string; backend?: string;
texthookerPort?: number; texthookerPort?: number;
jellyfinServer?: string;
jellyfinUsername?: string;
jellyfinPassword?: string;
jellyfinLibraryId?: string;
jellyfinItemId?: string;
jellyfinSearch?: string;
jellyfinLimit?: number;
jellyfinAudioStreamIndex?: number;
jellyfinSubtitleStreamIndex?: number;
debug: boolean; debug: boolean;
logLevel?: "debug" | "info" | "warn" | "error"; logLevel?: "debug" | "info" | "warn" | "error";
} }
@@ -70,6 +88,15 @@ export function parseArgs(argv: string[]): CliArgs {
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
anilistRetryQueue: false, anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
@@ -105,9 +132,11 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === "--hide-invisible-overlay") else if (arg === "--hide-invisible-overlay")
args.hideInvisibleOverlay = true; args.hideInvisibleOverlay = true;
else if (arg === "--copy-subtitle") args.copySubtitle = true; else if (arg === "--copy-subtitle") args.copySubtitle = true;
else if (arg === "--copy-subtitle-multiple") args.copySubtitleMultiple = true; else if (arg === "--copy-subtitle-multiple")
args.copySubtitleMultiple = true;
else if (arg === "--mine-sentence") args.mineSentence = true; else if (arg === "--mine-sentence") args.mineSentence = true;
else if (arg === "--mine-sentence-multiple") args.mineSentenceMultiple = true; else if (arg === "--mine-sentence-multiple")
args.mineSentenceMultiple = true;
else if (arg === "--update-last-card-from-clipboard") else if (arg === "--update-last-card-from-clipboard")
args.updateLastCardFromClipboard = true; args.updateLastCardFromClipboard = true;
else if (arg === "--refresh-known-words") args.refreshKnownWords = true; else if (arg === "--refresh-known-words") args.refreshKnownWords = true;
@@ -121,6 +150,17 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === "--anilist-logout") args.anilistLogout = true; else if (arg === "--anilist-logout") args.anilistLogout = true;
else if (arg === "--anilist-setup") args.anilistSetup = true; else if (arg === "--anilist-setup") args.anilistSetup = true;
else if (arg === "--anilist-retry-queue") args.anilistRetryQueue = true; else if (arg === "--anilist-retry-queue") args.anilistRetryQueue = true;
else if (arg === "--jellyfin") args.jellyfin = true;
else if (arg === "--jellyfin-login") args.jellyfinLogin = true;
else if (arg === "--jellyfin-logout") args.jellyfinLogout = true;
else if (arg === "--jellyfin-libraries") args.jellyfinLibraries = true;
else if (arg === "--jellyfin-items") args.jellyfinItems = true;
else if (arg === "--jellyfin-subtitles") args.jellyfinSubtitles = true;
else if (arg === "--jellyfin-subtitle-urls") {
args.jellyfinSubtitles = true;
args.jellyfinSubtitleUrlsOnly = true;
} else if (arg === "--jellyfin-play") args.jellyfinPlay = true;
else if (arg === "--jellyfin-remote-announce") args.jellyfinRemoteAnnounce = true;
else if (arg === "--texthooker") args.texthooker = true; else if (arg === "--texthooker") args.texthooker = true;
else if (arg === "--auto-start-overlay") args.autoStartOverlay = true; else if (arg === "--auto-start-overlay") args.autoStartOverlay = true;
else if (arg === "--generate-config") args.generateConfig = true; else if (arg === "--generate-config") args.generateConfig = true;
@@ -171,6 +211,66 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === "--port") { } else if (arg === "--port") {
const value = Number(readValue(argv[i + 1])); const value = Number(readValue(argv[i + 1]));
if (!Number.isNaN(value)) args.texthookerPort = value; if (!Number.isNaN(value)) args.texthookerPort = value;
} else if (arg.startsWith("--jellyfin-server=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinServer = value;
} else if (arg === "--jellyfin-server") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinServer = value;
} else if (arg.startsWith("--jellyfin-username=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinUsername = value;
} else if (arg === "--jellyfin-username") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinUsername = value;
} else if (arg.startsWith("--jellyfin-password=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinPassword = value;
} else if (arg === "--jellyfin-password") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinPassword = value;
} else if (arg.startsWith("--jellyfin-library-id=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinLibraryId = value;
} else if (arg === "--jellyfin-library-id") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinLibraryId = value;
} else if (arg.startsWith("--jellyfin-item-id=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinItemId = value;
} else if (arg === "--jellyfin-item-id") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinItemId = value;
} else if (arg.startsWith("--jellyfin-search=")) {
const value = arg.split("=", 2)[1];
if (value) args.jellyfinSearch = value;
} else if (arg === "--jellyfin-search") {
const value = readValue(argv[i + 1]);
if (value) args.jellyfinSearch = value;
} else if (arg.startsWith("--jellyfin-limit=")) {
const value = Number(arg.split("=", 2)[1]);
if (Number.isFinite(value) && value > 0)
args.jellyfinLimit = Math.floor(value);
} else if (arg === "--jellyfin-limit") {
const value = Number(readValue(argv[i + 1]));
if (Number.isFinite(value) && value > 0)
args.jellyfinLimit = Math.floor(value);
} else if (arg.startsWith("--jellyfin-audio-stream-index=")) {
const value = Number(arg.split("=", 2)[1]);
if (Number.isInteger(value) && value >= 0)
args.jellyfinAudioStreamIndex = value;
} else if (arg === "--jellyfin-audio-stream-index") {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value) && value >= 0)
args.jellyfinAudioStreamIndex = value;
} else if (arg.startsWith("--jellyfin-subtitle-stream-index=")) {
const value = Number(arg.split("=", 2)[1]);
if (Number.isInteger(value) && value >= 0)
args.jellyfinSubtitleStreamIndex = value;
} else if (arg === "--jellyfin-subtitle-stream-index") {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value) && value >= 0)
args.jellyfinSubtitleStreamIndex = value;
} }
} }
@@ -206,6 +306,14 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.anilistLogout || args.anilistLogout ||
args.anilistSetup || args.anilistSetup ||
args.anilistRetryQueue || args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker || args.texthooker ||
args.generateConfig || args.generateConfig ||
args.help args.help
@@ -229,6 +337,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker args.texthooker
) { ) {
return true; return true;

View File

@@ -20,4 +20,8 @@ test("printHelp includes configured texthooker port", () => {
assert.match(output, /--refresh-known-words/); assert.match(output, /--refresh-known-words/);
assert.match(output, /--anilist-status/); assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/); assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
assert.match(output, /--jellyfin-login/);
assert.match(output, /--jellyfin-subtitles/);
assert.match(output, /--jellyfin-play/);
}); });

View File

@@ -1,44 +1,79 @@
export function printHelp(defaultTexthookerPort: number): void { export function printHelp(defaultTexthookerPort: number): void {
const tty = process.stdout?.isTTY ?? false;
const B = tty ? "\x1b[1m" : "";
const D = tty ? "\x1b[2m" : "";
const R = tty ? "\x1b[0m" : "";
console.log(` console.log(`
SubMiner CLI commands: ${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan
--start Start MPV IPC connection and overlay control loop
--stop Stop the running overlay app ${B}Usage:${R} subminer ${D}[command] [options]${R}
--toggle Toggle visible subtitle overlay visibility (legacy alias)
--toggle-visible-overlay Toggle visible subtitle overlay visibility ${B}Session${R}
--toggle-invisible-overlay Toggle invisible interactive overlay visibility --start Connect to mpv and launch overlay
--stop Stop the running instance
--texthooker Start texthooker server only ${D}(no overlay)${R}
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
--toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--show-invisible-overlay Show interactive overlay
--hide-invisible-overlay Hide interactive overlay
--settings Open Yomitan settings window --settings Open Yomitan settings window
--texthooker Launch texthooker only (no overlay window) --auto-start-overlay Auto-hide mpv subs, show overlay on connect
--show Force show visible overlay (legacy alias)
--hide Force hide visible overlay (legacy alias) ${B}Mining${R}
--show-visible-overlay Force show visible subtitle overlay --mine-sentence Create Anki card from current subtitle
--hide-visible-overlay Force hide visible subtitle overlay --mine-sentence-multiple Select multiple lines, then mine
--show-invisible-overlay Force show invisible interactive overlay --copy-subtitle Copy current subtitle to clipboard
--hide-invisible-overlay Force hide invisible interactive overlay --copy-subtitle-multiple Enter multi-line copy mode
--copy-subtitle Copy current subtitle text --update-last-card-from-clipboard Update last Anki card from clipboard
--copy-subtitle-multiple Start multi-copy mode --mark-audio-card Mark last card as audio-only
--mine-sentence Mine sentence card from current subtitle --trigger-field-grouping Run Kiku field grouping
--mine-sentence-multiple Start multi-mine sentence mode
--update-last-card-from-clipboard Update last card from clipboard
--refresh-known-words Refresh known words cache now
--toggle-secondary-sub Cycle secondary subtitle mode
--trigger-field-grouping Trigger Kiku field grouping
--trigger-subsync Run subtitle sync --trigger-subsync Run subtitle sync
--mark-audio-card Mark last card as audio card --toggle-secondary-sub Cycle secondary subtitle mode
--refresh-known-words Refresh known words cache
--open-runtime-options Open runtime options palette --open-runtime-options Open runtime options palette
--anilist-status Show AniList token and retry queue status
${B}AniList${R}
--anilist-setup Open AniList authentication flow
--anilist-status Show token and retry queue status
--anilist-logout Clear stored AniList token --anilist-logout Clear stored AniList token
--anilist-setup Open AniList setup flow in app/browser --anilist-retry-queue Retry next queued update
--anilist-retry-queue Retry next ready AniList queue item now
--auto-start-overlay Auto-hide mpv subtitles on connect (show overlay) ${B}Jellyfin${R}
--socket PATH Override MPV IPC socket/pipe path --jellyfin Open Jellyfin setup window
--backend BACKEND Override window tracker backend (auto, hyprland, sway, x11, macos) --jellyfin-login Authenticate and store session token
--port PORT Texthooker server port (default: ${defaultTexthookerPort}) --jellyfin-logout Clear stored session data
--debug Enable app/dev mode --jellyfin-libraries List available libraries
--log-level LEVEL Set log level: debug, info, warn, error --jellyfin-items List items from a library
--generate-config Generate default config.jsonc from centralized config registry --jellyfin-subtitles List subtitle tracks for an item
--config-path PATH Target config path for --generate-config --jellyfin-subtitle-urls Print subtitle download URLs only
--backup-overwrite With --generate-config, backup and overwrite existing file --jellyfin-play Stream an item in mpv
--dev Alias for --debug (app/dev mode) --jellyfin-remote-announce Broadcast cast-target capability
${D}Jellyfin options:${R}
--jellyfin-server ${D}URL${R} Server URL ${D}(overrides config)${R}
--jellyfin-username ${D}NAME${R} Username for login
--jellyfin-password ${D}PASS${R} Password for login
--jellyfin-library-id ${D}ID${R} Library to browse
--jellyfin-item-id ${D}ID${R} Item to play or inspect
--jellyfin-search ${D}QUERY${R} Filter items by search term
--jellyfin-limit ${D}N${R} Max items returned
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R}
--port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R}
--log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R}
--debug Enable debug mode ${D}(alias: --dev)${R}
--generate-config Write default config.jsonc
--config-path ${D}PATH${R} Target path for --generate-config
--backup-overwrite Backup existing config before overwrite
--help Show this help --help Show this help
`); `);
} }

View File

@@ -18,8 +18,22 @@ test("loads defaults when config is missing", () => {
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.equal(config.anilist.enabled, false); assert.equal(config.anilist.enabled, false);
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, "SubMiner");
assert.equal(config.immersionTracking.enabled, true); assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, undefined); assert.equal(config.immersionTracking.dbPath, "");
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
}); });
test("parses anilist.enabled and warns for invalid value", () => { test("parses anilist.enabled and warns for invalid value", () => {
@@ -45,6 +59,90 @@ test("parses anilist.enabled and warns for invalid value", () => {
assert.equal(service.getConfig().anilist.enabled, true); assert.equal(service.getConfig().anilist.enabled, true);
}); });
test("parses jellyfin remote control fields", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
"autoAnnounce": true,
"remoteControlDeviceName": "SubMiner"
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.jellyfin.enabled, true);
assert.equal(config.jellyfin.serverUrl, "http://127.0.0.1:8096");
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, "SubMiner");
});
test("parses jellyfin.enabled and remoteControlEnabled disabled combinations", () => {
const disabledDir = makeTempDir();
fs.writeFileSync(
path.join(disabledDir, "config.jsonc"),
`{
"jellyfin": {
"enabled": false,
"remoteControlEnabled": false
}
}`,
"utf-8",
);
const disabledService = new ConfigService(disabledDir);
const disabledConfig = disabledService.getConfig();
assert.equal(disabledConfig.jellyfin.enabled, false);
assert.equal(disabledConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
disabledService
.getWarnings()
.some(
(warning) =>
warning.path === "jellyfin.enabled" ||
warning.path === "jellyfin.remoteControlEnabled",
),
false,
);
const mixedDir = makeTempDir();
fs.writeFileSync(
path.join(mixedDir, "config.jsonc"),
`{
"jellyfin": {
"enabled": true,
"remoteControlEnabled": false
}
}`,
"utf-8",
);
const mixedService = new ConfigService(mixedDir);
const mixedConfig = mixedService.getConfig();
assert.equal(mixedConfig.jellyfin.enabled, true);
assert.equal(mixedConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
mixedService
.getWarnings()
.some(
(warning) =>
warning.path === "jellyfin.enabled" ||
warning.path === "jellyfin.remoteControlEnabled",
),
false,
);
});
test("accepts immersion tracking config values", () => { test("accepts immersion tracking config values", () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -52,7 +150,19 @@ test("accepts immersion tracking config values", () => {
`{ `{
"immersionTracking": { "immersionTracking": {
"enabled": false, "enabled": false,
"dbPath": "/tmp/immersions/custom.sqlite" "dbPath": "/tmp/immersions/custom.sqlite",
"batchSize": 50,
"flushIntervalMs": 750,
"queueCap": 2000,
"payloadCapBytes": 512,
"maintenanceIntervalMs": 3600000,
"retention": {
"eventsDays": 14,
"telemetryDays": 45,
"dailyRollupsDays": 730,
"monthlyRollupsDays": 3650,
"vacuumIntervalDays": 14
}
} }
}`, }`,
"utf-8", "utf-8",
@@ -62,7 +172,109 @@ test("accepts immersion tracking config values", () => {
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.immersionTracking.enabled, false); assert.equal(config.immersionTracking.enabled, false);
assert.equal(config.immersionTracking.dbPath, "/tmp/immersions/custom.sqlite"); assert.equal(
config.immersionTracking.dbPath,
"/tmp/immersions/custom.sqlite",
);
assert.equal(config.immersionTracking.batchSize, 50);
assert.equal(config.immersionTracking.flushIntervalMs, 750);
assert.equal(config.immersionTracking.queueCap, 2000);
assert.equal(config.immersionTracking.payloadCapBytes, 512);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000);
assert.equal(config.immersionTracking.retention.eventsDays, 14);
assert.equal(config.immersionTracking.retention.telemetryDays, 45);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14);
});
test("falls back for invalid immersion tracking tuning values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"immersionTracking": {
"batchSize": 0,
"flushIntervalMs": 1,
"queueCap": 5,
"payloadCapBytes": 16,
"maintenanceIntervalMs": 1000,
"retention": {
"eventsDays": 0,
"telemetryDays": 99999,
"dailyRollupsDays": 0,
"monthlyRollupsDays": 999999,
"vacuumIntervalDays": 0
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
assert.ok(
warnings.some((warning) => warning.path === "immersionTracking.batchSize"),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.flushIntervalMs",
),
);
assert.ok(
warnings.some((warning) => warning.path === "immersionTracking.queueCap"),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.payloadCapBytes",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.maintenanceIntervalMs",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.retention.eventsDays",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.retention.telemetryDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.dailyRollupsDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.monthlyRollupsDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.vacuumIntervalDays",
),
);
}); });
test("parses jsonc and warns/falls back on invalid value", () => { test("parses jsonc and warns/falls back on invalid value", () => {
@@ -117,9 +329,7 @@ test("falls back for invalid logging.level and reports warning", () => {
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level); assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
assert.ok( assert.ok(warnings.some((warning) => warning.path === "logging.level"));
warnings.some((warning) => warning.path === "logging.level"),
);
}); });
test("parses invisible overlay config and new global shortcuts", () => { test("parses invisible overlay config and new global shortcuts", () => {
@@ -150,7 +360,11 @@ test("parses invisible overlay config and new global shortcuts", () => {
assert.equal(config.shortcuts.openJimaku, "Ctrl+Alt+J"); assert.equal(config.shortcuts.openJimaku, "Ctrl+Alt+J");
assert.equal(config.invisibleOverlay.startupVisibility, "hidden"); assert.equal(config.invisibleOverlay.startupVisibility, "hidden");
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false); assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ["ja", "jpn", "jp"]); assert.deepEqual(config.youtubeSubgen.primarySubLanguages, [
"ja",
"jpn",
"jp",
]);
}); });
test("runtime options registry is centralized", () => { test("runtime options registry is centralized", () => {
@@ -295,8 +509,8 @@ test("validates ankiConnect n+1 match mode values", () => {
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
); );
assert.ok( assert.ok(
warnings.some((warning) => warnings.some(
warning.path === "ankiConnect.nPlusOne.matchMode", (warning) => warning.path === "ankiConnect.nPlusOne.matchMode",
), ),
); );
}); });
@@ -349,10 +563,14 @@ test("validates ankiConnect n+1 color values", () => {
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord, DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
); );
assert.ok( assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne"), warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne",
),
); );
assert.ok( assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.knownWord"), warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.knownWord",
),
); );
}); });

View File

@@ -201,13 +201,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
topX: 1000, topX: 1000,
mode: "single", mode: "single",
singleColor: "#f5a97f", singleColor: "#f5a97f",
bandedColors: [ bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4",
],
}, },
secondary: { secondary: {
fontSize: 24, fontSize: 24,
@@ -230,6 +224,26 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: false, enabled: false,
accessToken: "", accessToken: "",
}, },
jellyfin: {
enabled: false,
serverUrl: "",
username: "",
accessToken: "",
userId: "",
deviceId: "subminer",
clientName: "SubMiner",
clientVersion: "0.1.0",
defaultLibraryId: "",
remoteControlEnabled: true,
remoteControlAutoConnect: true,
autoAnnounce: false,
remoteControlDeviceName: "SubMiner",
pullPictures: false,
iconCacheDir: "/tmp/subminer-jellyfin-icons",
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
transcodeVideoCodec: "h264",
},
youtubeSubgen: { youtubeSubgen: {
mode: "automatic", mode: "automatic",
whisperBin: "", whisperBin: "",
@@ -241,6 +255,19 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
}, },
immersionTracking: { immersionTracking: {
enabled: true, enabled: true,
dbPath: "",
batchSize: 25,
flushIntervalMs: 500,
queueCap: 1000,
payloadCapBytes: 256,
maintenanceIntervalMs: 24 * 60 * 60 * 1000,
retention: {
eventsDays: 7,
telemetryDays: 30,
dailyRollupsDays: 365,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
},
}, },
}; };
@@ -324,8 +351,9 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "subtitleStyle.enableJlpt", path: "subtitleStyle.enableJlpt",
kind: "boolean", kind: "boolean",
defaultValue: DEFAULT_CONFIG.subtitleStyle.enableJlpt, defaultValue: DEFAULT_CONFIG.subtitleStyle.enableJlpt,
description: "Enable JLPT vocabulary level underlines. " description:
+ "When disabled, JLPT tagging lookup and underlines are skipped.", "Enable JLPT vocabulary level underlines. " +
"When disabled, JLPT tagging lookup and underlines are skipped.",
}, },
{ {
path: "subtitleStyle.frequencyDictionary.enabled", path: "subtitleStyle.frequencyDictionary.enabled",
@@ -339,14 +367,15 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
kind: "string", kind: "string",
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.sourcePath, defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.sourcePath,
description: description:
"Optional absolute path to a frequency dictionary directory." "Optional absolute path to a frequency dictionary directory." +
+ " If empty, built-in discovery search paths are used.", " If empty, built-in discovery search paths are used.",
}, },
{ {
path: "subtitleStyle.frequencyDictionary.topX", path: "subtitleStyle.frequencyDictionary.topX",
kind: "number", kind: "number",
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.topX, defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.topX,
description: "Only color tokens with frequency rank <= topX (default: 1000).", description:
"Only color tokens with frequency rank <= topX (default: 1000).",
}, },
{ {
path: "subtitleStyle.frequencyDictionary.mode", path: "subtitleStyle.frequencyDictionary.mode",
@@ -399,7 +428,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "ankiConnect.nPlusOne.highlightEnabled", path: "ankiConnect.nPlusOne.highlightEnabled",
kind: "boolean", kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
description: "Enable fast local highlighting for words already known in Anki.", description:
"Enable fast local highlighting for words already known in Anki.",
}, },
{ {
path: "ankiConnect.nPlusOne.refreshMinutes", path: "ankiConnect.nPlusOne.refreshMinutes",
@@ -486,6 +516,89 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
defaultValue: DEFAULT_CONFIG.anilist.accessToken, defaultValue: DEFAULT_CONFIG.anilist.accessToken,
description: "AniList access token used for post-watch updates.", description: "AniList access token used for post-watch updates.",
}, },
{
path: "jellyfin.enabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.enabled,
description:
"Enable optional Jellyfin integration and CLI control commands.",
},
{
path: "jellyfin.serverUrl",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.serverUrl,
description:
"Base Jellyfin server URL (for example: http://localhost:8096).",
},
{
path: "jellyfin.username",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.username,
description: "Default Jellyfin username used during CLI login.",
},
{
path: "jellyfin.defaultLibraryId",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.defaultLibraryId,
description: "Optional default Jellyfin library ID for item listing.",
},
{
path: "jellyfin.remoteControlEnabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlEnabled,
description: "Enable Jellyfin remote cast control mode.",
},
{
path: "jellyfin.remoteControlAutoConnect",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlAutoConnect,
description: "Auto-connect to the configured remote control target.",
},
{
path: "jellyfin.autoAnnounce",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.autoAnnounce,
description:
"When enabled, automatically trigger remote announce/visibility check on websocket connect.",
},
{
path: "jellyfin.remoteControlDeviceName",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlDeviceName,
description: "Device name reported for Jellyfin remote control sessions.",
},
{
path: "jellyfin.pullPictures",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.pullPictures,
description: "Enable Jellyfin poster/icon fetching for launcher menus.",
},
{
path: "jellyfin.iconCacheDir",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.iconCacheDir,
description: "Directory used by launcher for cached Jellyfin poster icons.",
},
{
path: "jellyfin.directPlayPreferred",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.directPlayPreferred,
description:
"Try direct play before server-managed transcoding when possible.",
},
{
path: "jellyfin.directPlayContainers",
kind: "array",
defaultValue: DEFAULT_CONFIG.jellyfin.directPlayContainers,
description: "Container allowlist for direct play decisions.",
},
{
path: "jellyfin.transcodeVideoCodec",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.transcodeVideoCodec,
description:
"Preferred transcode video codec when direct play is unavailable.",
},
{ {
path: "youtubeSubgen.mode", path: "youtubeSubgen.mode",
kind: "enum", kind: "enum",
@@ -497,7 +610,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "youtubeSubgen.whisperBin", path: "youtubeSubgen.whisperBin",
kind: "string", kind: "string",
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin, defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin,
description: "Path to whisper.cpp CLI used as fallback transcription engine.", description:
"Path to whisper.cpp CLI used as fallback transcription engine.",
}, },
{ {
path: "youtubeSubgen.whisperModel", path: "youtubeSubgen.whisperModel",
@@ -525,6 +639,66 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
description: description:
"Optional SQLite database path for immersion tracking. Empty value uses the default app data path.", "Optional SQLite database path for immersion tracking. Empty value uses the default app data path.",
}, },
{
path: "immersionTracking.batchSize",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.batchSize,
description: "Buffered telemetry/event writes per SQLite transaction.",
},
{
path: "immersionTracking.flushIntervalMs",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.flushIntervalMs,
description: "Max delay before queue flush in milliseconds.",
},
{
path: "immersionTracking.queueCap",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.queueCap,
description: "In-memory write queue cap before overflow policy applies.",
},
{
path: "immersionTracking.payloadCapBytes",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.payloadCapBytes,
description: "Max JSON payload size per event before truncation.",
},
{
path: "immersionTracking.maintenanceIntervalMs",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.maintenanceIntervalMs,
description: "Maintenance cadence (prune + rollup + vacuum checks).",
},
{
path: "immersionTracking.retention.eventsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.eventsDays,
description: "Raw event retention window in days.",
},
{
path: "immersionTracking.retention.telemetryDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.telemetryDays,
description: "Telemetry retention window in days.",
},
{
path: "immersionTracking.retention.dailyRollupsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.dailyRollupsDays,
description: "Daily rollup retention window in days.",
},
{
path: "immersionTracking.retention.monthlyRollupsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.monthlyRollupsDays,
description: "Monthly rollup retention window in days.",
},
{
path: "immersionTracking.retention.vacuumIntervalDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.vacuumIntervalDays,
description: "Minimum days between VACUUM runs.",
},
]; ];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
@@ -637,11 +811,20 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ["Anilist API credentials and update behavior."], description: ["Anilist API credentials and update behavior."],
key: "anilist", key: "anilist",
}, },
{
title: "Jellyfin",
description: [
"Optional Jellyfin integration for auth, browsing, and playback launch.",
"Access token is stored in config and should be treated as a secret.",
],
key: "jellyfin",
},
{ {
title: "Immersion Tracking", title: "Immersion Tracking",
description: [ description: [
"Enable/disable immersion tracking.", "Enable/disable immersion tracking.",
"Set dbPath to override the default sqlite database location.", "Set dbPath to override the default sqlite database location.",
"Policy tuning is available for queue, flush, and retention values.",
], ],
key: "immersionTracking", key: "immersionTracking",
}, },

View File

@@ -213,7 +213,12 @@ export class ConfigService {
if (isObject(src.logging)) { if (isObject(src.logging)) {
const logLevel = asString(src.logging.level); const logLevel = asString(src.logging.level);
if (logLevel === "debug" || logLevel === "info" || logLevel === "warn" || logLevel === "error") { if (
logLevel === "debug" ||
logLevel === "info" ||
logLevel === "warn" ||
logLevel === "error"
) {
resolved.logging.level = logLevel; resolved.logging.level = logLevel;
} else if (src.logging.level !== undefined) { } else if (src.logging.level !== undefined) {
warn( warn(
@@ -469,11 +474,90 @@ export class ConfigService {
} }
} }
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {
resolved.jellyfin.enabled = enabled;
} else if (src.jellyfin.enabled !== undefined) {
warn(
"jellyfin.enabled",
src.jellyfin.enabled,
resolved.jellyfin.enabled,
"Expected boolean.",
);
}
const stringKeys = [
"serverUrl",
"username",
"accessToken",
"userId",
"deviceId",
"clientName",
"clientVersion",
"defaultLibraryId",
"iconCacheDir",
"transcodeVideoCodec",
] as const;
for (const key of stringKeys) {
const value = asString(src.jellyfin[key]);
if (value !== undefined) {
resolved.jellyfin[key] =
value as (typeof resolved.jellyfin)[typeof key];
} else if (src.jellyfin[key] !== undefined) {
warn(
`jellyfin.${key}`,
src.jellyfin[key],
resolved.jellyfin[key],
"Expected string.",
);
}
}
const booleanKeys = [
"remoteControlEnabled",
"remoteControlAutoConnect",
"autoAnnounce",
"directPlayPreferred",
"pullPictures",
] as const;
for (const key of booleanKeys) {
const value = asBoolean(src.jellyfin[key]);
if (value !== undefined) {
resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key];
} else if (src.jellyfin[key] !== undefined) {
warn(
`jellyfin.${key}`,
src.jellyfin[key],
resolved.jellyfin[key],
"Expected boolean.",
);
}
}
if (Array.isArray(src.jellyfin.directPlayContainers)) {
resolved.jellyfin.directPlayContainers =
src.jellyfin.directPlayContainers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
} else if (src.jellyfin.directPlayContainers !== undefined) {
warn(
"jellyfin.directPlayContainers",
src.jellyfin.directPlayContainers,
resolved.jellyfin.directPlayContainers,
"Expected string array.",
);
}
}
if (asBoolean(src.auto_start_overlay) !== undefined) { if (asBoolean(src.auto_start_overlay) !== undefined) {
resolved.auto_start_overlay = src.auto_start_overlay as boolean; resolved.auto_start_overlay = src.auto_start_overlay as boolean;
} }
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) { if (
asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined
) {
resolved.bind_visible_overlay_to_mpv_sub_visibility = resolved.bind_visible_overlay_to_mpv_sub_visibility =
src.bind_visible_overlay_to_mpv_sub_visibility as boolean; src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) { } else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
@@ -509,6 +593,191 @@ export class ConfigService {
"Expected string.", "Expected string.",
); );
} }
const batchSize = asNumber(src.immersionTracking.batchSize);
if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) {
resolved.immersionTracking.batchSize = Math.floor(batchSize);
} else if (src.immersionTracking.batchSize !== undefined) {
warn(
"immersionTracking.batchSize",
src.immersionTracking.batchSize,
resolved.immersionTracking.batchSize,
"Expected integer between 1 and 10000.",
);
}
const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs);
if (
flushIntervalMs !== undefined &&
flushIntervalMs >= 50 &&
flushIntervalMs <= 60_000
) {
resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs);
} else if (src.immersionTracking.flushIntervalMs !== undefined) {
warn(
"immersionTracking.flushIntervalMs",
src.immersionTracking.flushIntervalMs,
resolved.immersionTracking.flushIntervalMs,
"Expected integer between 50 and 60000.",
);
}
const queueCap = asNumber(src.immersionTracking.queueCap);
if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) {
resolved.immersionTracking.queueCap = Math.floor(queueCap);
} else if (src.immersionTracking.queueCap !== undefined) {
warn(
"immersionTracking.queueCap",
src.immersionTracking.queueCap,
resolved.immersionTracking.queueCap,
"Expected integer between 100 and 100000.",
);
}
const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes);
if (
payloadCapBytes !== undefined &&
payloadCapBytes >= 64 &&
payloadCapBytes <= 8192
) {
resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes);
} else if (src.immersionTracking.payloadCapBytes !== undefined) {
warn(
"immersionTracking.payloadCapBytes",
src.immersionTracking.payloadCapBytes,
resolved.immersionTracking.payloadCapBytes,
"Expected integer between 64 and 8192.",
);
}
const maintenanceIntervalMs = asNumber(
src.immersionTracking.maintenanceIntervalMs,
);
if (
maintenanceIntervalMs !== undefined &&
maintenanceIntervalMs >= 60_000 &&
maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000
) {
resolved.immersionTracking.maintenanceIntervalMs = Math.floor(
maintenanceIntervalMs,
);
} else if (src.immersionTracking.maintenanceIntervalMs !== undefined) {
warn(
"immersionTracking.maintenanceIntervalMs",
src.immersionTracking.maintenanceIntervalMs,
resolved.immersionTracking.maintenanceIntervalMs,
"Expected integer between 60000 and 604800000.",
);
}
if (isObject(src.immersionTracking.retention)) {
const eventsDays = asNumber(src.immersionTracking.retention.eventsDays);
if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) {
resolved.immersionTracking.retention.eventsDays =
Math.floor(eventsDays);
} else if (src.immersionTracking.retention.eventsDays !== undefined) {
warn(
"immersionTracking.retention.eventsDays",
src.immersionTracking.retention.eventsDays,
resolved.immersionTracking.retention.eventsDays,
"Expected integer between 1 and 3650.",
);
}
const telemetryDays = asNumber(
src.immersionTracking.retention.telemetryDays,
);
if (
telemetryDays !== undefined &&
telemetryDays >= 1 &&
telemetryDays <= 3650
) {
resolved.immersionTracking.retention.telemetryDays =
Math.floor(telemetryDays);
} else if (
src.immersionTracking.retention.telemetryDays !== undefined
) {
warn(
"immersionTracking.retention.telemetryDays",
src.immersionTracking.retention.telemetryDays,
resolved.immersionTracking.retention.telemetryDays,
"Expected integer between 1 and 3650.",
);
}
const dailyRollupsDays = asNumber(
src.immersionTracking.retention.dailyRollupsDays,
);
if (
dailyRollupsDays !== undefined &&
dailyRollupsDays >= 1 &&
dailyRollupsDays <= 36500
) {
resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(
dailyRollupsDays,
);
} else if (
src.immersionTracking.retention.dailyRollupsDays !== undefined
) {
warn(
"immersionTracking.retention.dailyRollupsDays",
src.immersionTracking.retention.dailyRollupsDays,
resolved.immersionTracking.retention.dailyRollupsDays,
"Expected integer between 1 and 36500.",
);
}
const monthlyRollupsDays = asNumber(
src.immersionTracking.retention.monthlyRollupsDays,
);
if (
monthlyRollupsDays !== undefined &&
monthlyRollupsDays >= 1 &&
monthlyRollupsDays <= 36500
) {
resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(
monthlyRollupsDays,
);
} else if (
src.immersionTracking.retention.monthlyRollupsDays !== undefined
) {
warn(
"immersionTracking.retention.monthlyRollupsDays",
src.immersionTracking.retention.monthlyRollupsDays,
resolved.immersionTracking.retention.monthlyRollupsDays,
"Expected integer between 1 and 36500.",
);
}
const vacuumIntervalDays = asNumber(
src.immersionTracking.retention.vacuumIntervalDays,
);
if (
vacuumIntervalDays !== undefined &&
vacuumIntervalDays >= 1 &&
vacuumIntervalDays <= 3650
) {
resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(
vacuumIntervalDays,
);
} else if (
src.immersionTracking.retention.vacuumIntervalDays !== undefined
) {
warn(
"immersionTracking.retention.vacuumIntervalDays",
src.immersionTracking.retention.vacuumIntervalDays,
resolved.immersionTracking.retention.vacuumIntervalDays,
"Expected integer between 1 and 3650.",
);
}
} else if (src.immersionTracking.retention !== undefined) {
warn(
"immersionTracking.retention",
src.immersionTracking.retention,
resolved.immersionTracking.retention,
"Expected object.",
);
}
} }
if (isObject(src.subtitleStyle)) { if (isObject(src.subtitleStyle)) {
@@ -524,10 +793,14 @@ export class ConfigService {
}, },
}; };
const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt); const enableJlpt = asBoolean(
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt,
);
if (enableJlpt !== undefined) { if (enableJlpt !== undefined) {
resolved.subtitleStyle.enableJlpt = enableJlpt; resolved.subtitleStyle.enableJlpt = enableJlpt;
} else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) { } else if (
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined
) {
warn( warn(
"subtitleStyle.enableJlpt", "subtitleStyle.enableJlpt",
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt, (src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt,
@@ -565,7 +838,8 @@ export class ConfigService {
if (sourcePath !== undefined) { if (sourcePath !== undefined) {
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
} else if ( } else if (
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined (frequencyDictionary as { sourcePath?: unknown }).sourcePath !==
undefined
) { ) {
warn( warn(
"subtitleStyle.frequencyDictionary.sourcePath", "subtitleStyle.frequencyDictionary.sourcePath",
@@ -576,13 +850,11 @@ export class ConfigService {
} }
const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX); const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX);
if ( if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
topX !== undefined &&
Number.isInteger(topX) &&
topX > 0
) {
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { } else if (
(frequencyDictionary as { topX?: unknown }).topX !== undefined
) {
warn( warn(
"subtitleStyle.frequencyDictionary.topX", "subtitleStyle.frequencyDictionary.topX",
(frequencyDictionary as { topX?: unknown }).topX, (frequencyDictionary as { topX?: unknown }).topX,
@@ -592,10 +864,7 @@ export class ConfigService {
} }
const frequencyMode = frequencyDictionary.mode; const frequencyMode = frequencyDictionary.mode;
if ( if (frequencyMode === "single" || frequencyMode === "banded") {
frequencyMode === "single" ||
frequencyMode === "banded"
) {
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
} else if (frequencyMode !== undefined) { } else if (frequencyMode !== undefined) {
warn( warn(
@@ -612,7 +881,8 @@ export class ConfigService {
if (singleColor !== undefined) { if (singleColor !== undefined) {
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
} else if ( } else if (
(frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined (frequencyDictionary as { singleColor?: unknown }).singleColor !==
undefined
) { ) {
warn( warn(
"subtitleStyle.frequencyDictionary.singleColor", "subtitleStyle.frequencyDictionary.singleColor",
@@ -628,7 +898,8 @@ export class ConfigService {
if (bandedColors !== undefined) { if (bandedColors !== undefined) {
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
} else if ( } else if (
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined (frequencyDictionary as { bandedColors?: unknown }).bandedColors !==
undefined
) { ) {
warn( warn(
"subtitleStyle.frequencyDictionary.bandedColors", "subtitleStyle.frequencyDictionary.bandedColors",
@@ -649,13 +920,17 @@ export class ConfigService {
: isObject(ac.openRouter) : isObject(ac.openRouter)
? ac.openRouter ? ac.openRouter
: {}; : {};
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = const {
ac as Record<string, unknown>; nPlusOne: _nPlusOneConfigFromAnkiConnect,
...ankiConnectWithoutNPlusOne
} = ac as Record<string, unknown>;
resolved.ankiConnect = { resolved.ankiConnect = {
...resolved.ankiConnect, ...resolved.ankiConnect,
...(isObject(ankiConnectWithoutNPlusOne) ...(isObject(ankiConnectWithoutNPlusOne)
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig["ankiConnect"]>) ? (ankiConnectWithoutNPlusOne as Partial<
ResolvedConfig["ankiConnect"]
>)
: {}), : {}),
fields: { fields: {
...resolved.ankiConnect.fields, ...resolved.ankiConnect.fields,
@@ -837,8 +1112,7 @@ export class ConfigService {
nPlusOneRefreshMinutes > 0; nPlusOneRefreshMinutes > 0;
if (nPlusOneRefreshMinutes !== undefined) { if (nPlusOneRefreshMinutes !== undefined) {
if (hasValidNPlusOneRefreshMinutes) { if (hasValidNPlusOneRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes = resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes;
nPlusOneRefreshMinutes;
} else { } else {
warn( warn(
"ankiConnect.nPlusOne.refreshMinutes", "ankiConnect.nPlusOne.refreshMinutes",
@@ -927,8 +1201,7 @@ export class ConfigService {
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) { } else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) { if (hasValidLegacyMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode = resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode;
legacyNPlusOneMatchMode;
warn( warn(
"ankiConnect.behavior.nPlusOneMatchMode", "ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode, behavior.nPlusOneMatchMode,
@@ -958,9 +1231,7 @@ export class ConfigService {
.filter((entry) => entry.length > 0); .filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) { if (normalizedDecks.length === nPlusOneDecks.length) {
resolved.ankiConnect.nPlusOne.decks = [ resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)];
...new Set(normalizedDecks),
];
} else if (nPlusOneDecks.length > 0) { } else if (nPlusOneDecks.length > 0) {
warn( warn(
"ankiConnect.nPlusOne.decks", "ankiConnect.nPlusOne.decks",

View File

@@ -11,11 +11,14 @@ function renderValue(value: unknown, indent = 0): string {
if (value === null) return "null"; if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value); if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) return "[]"; if (value.length === 0) return "[]";
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`); const items = value.map(
(item) => `${nextPad}${renderValue(item, indent + 2)}`,
);
return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]"); return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]");
} }
@@ -25,7 +28,8 @@ function renderValue(value: unknown, indent = 0): string {
); );
if (entries.length === 0) return "{}"; if (entries.length === 0) return "{}";
const lines = entries.map( const lines = entries.map(
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`, ([key, child]) =>
`${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
); );
return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}"); return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}");
} }
@@ -45,23 +49,33 @@ function renderSection(
lines.push(` // ${comment}`); lines.push(` // ${comment}`);
} }
lines.push(" // =========================================="); lines.push(" // ==========================================");
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`); lines.push(
` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`,
);
return lines.join("\n"); return lines.join("\n");
} }
export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG)): string { export function generateConfigTemplate(
config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG),
): string {
const lines: string[] = []; const lines: string[] = [];
lines.push("/**"); lines.push("/**");
lines.push(" * SubMiner Example Configuration File"); lines.push(" * SubMiner Example Configuration File");
lines.push(" *"); lines.push(" *");
lines.push(" * This file is auto-generated from src/config/definitions.ts."); lines.push(" * This file is auto-generated from src/config/definitions.ts.");
lines.push(" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed."); lines.push(
" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.",
);
lines.push(" */"); lines.push(" */");
lines.push("{"); lines.push("{");
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => { CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
lines.push(""); lines.push("");
const comments = [section.title, ...section.description, ...(section.notes ?? [])]; const comments = [
section.title,
...section.description,
...(section.notes ?? []),
];
lines.push( lines.push(
renderSection( renderSection(
section.key, section.key,

View File

@@ -32,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
anilistRetryQueue: false, anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
@@ -147,6 +156,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openAnilistSetup: () => { openAnilistSetup: () => {
calls.push("openAnilistSetup"); calls.push("openAnilistSetup");
}, },
openJellyfinSetup: () => {
calls.push("openJellyfinSetup");
},
getAnilistQueueStatus: () => ({ getAnilistQueueStatus: () => ({
pending: 2, pending: 2,
ready: 1, ready: 1,
@@ -158,6 +170,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push("retryAnilistQueue"); calls.push("retryAnilistQueue");
return { ok: true, message: "AniList retry processed." }; return { ok: true, message: "AniList retry processed." };
}, },
runJellyfinCommand: async () => {
calls.push("runJellyfinCommand");
},
printHelp: () => { printHelp: () => {
calls.push("printHelp"); calls.push("printHelp");
}, },
@@ -187,8 +202,13 @@ test("handleCliCommand ignores --start for second-instance without actions", ()
handleCliCommand(args, "second-instance", deps); handleCliCommand(args, "second-instance", deps);
assert.ok(calls.includes("log:Ignoring --start because SubMiner is already running.")); assert.ok(
assert.equal(calls.some((value) => value.includes("connectMpvClient")), false); calls.includes("log:Ignoring --start because SubMiner is already running."),
);
assert.equal(
calls.some((value) => value.includes("connectMpvClient")),
false,
);
}); });
test("handleCliCommand runs texthooker flow with browser open", () => { test("handleCliCommand runs texthooker flow with browser open", () => {
@@ -198,9 +218,7 @@ test("handleCliCommand runs texthooker flow with browser open", () => {
handleCliCommand(args, "initial", deps); handleCliCommand(args, "initial", deps);
assert.ok(calls.includes("ensureTexthookerRunning:5174")); assert.ok(calls.includes("ensureTexthookerRunning:5174"));
assert.ok( assert.ok(calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"));
calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"),
);
}); });
test("handleCliCommand reports async mine errors to OSD", async () => { test("handleCliCommand reports async mine errors to OSD", async () => {
@@ -213,7 +231,9 @@ test("handleCliCommand reports async mine errors to OSD", async () => {
handleCliCommand(makeArgs({ mineSentence: true }), "initial", deps); handleCliCommand(makeArgs({ mineSentence: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.some((value) => value.startsWith("error:mineSentenceCard failed:"))); assert.ok(
calls.some((value) => value.startsWith("error:mineSentenceCard failed:")),
);
assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom"))); assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom")));
}); });
@@ -247,7 +267,10 @@ test("handleCliCommand warns when texthooker port override used while running",
"warn:Ignoring --port override because the texthooker server is already running.", "warn:Ignoring --port override because the texthooker server is already running.",
), ),
); );
assert.equal(calls.some((value) => value === "setTexthookerPort:9999"), false); assert.equal(
calls.some((value) => value === "setTexthookerPort:9999"),
false,
);
}); });
test("handleCliCommand prints help and stops app when no window exists", () => { test("handleCliCommand prints help and stops app when no window exists", () => {
@@ -272,9 +295,13 @@ test("handleCliCommand reports async trigger-subsync errors to OSD", async () =>
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.ok( assert.ok(
calls.some((value) => value.startsWith("error:triggerSubsyncFromConfig failed:")), calls.some((value) =>
value.startsWith("error:triggerSubsyncFromConfig failed:"),
),
);
assert.ok(
osd.some((value) => value.includes("Subsync failed: subsync boom")),
); );
assert.ok(osd.some((value) => value.includes("Subsync failed: subsync boom")));
}); });
test("handleCliCommand stops app for --stop command", () => { test("handleCliCommand stops app for --stop command", () => {
@@ -292,7 +319,10 @@ test("handleCliCommand still runs non-start actions on second-instance", () => {
deps, deps,
); );
assert.ok(calls.includes("toggleVisibleOverlay")); assert.ok(calls.includes("toggleVisibleOverlay"));
assert.equal(calls.some((value) => value === "connectMpvClient"), true); assert.equal(
calls.some((value) => value === "connectMpvClient"),
true,
);
}); });
test("handleCliCommand handles visibility and utility command dispatches", () => { test("handleCliCommand handles visibility and utility command dispatches", () => {
@@ -300,22 +330,44 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
args: Partial<CliArgs>; args: Partial<CliArgs>;
expected: string; expected: string;
}> = [ }> = [
{ args: { toggleInvisibleOverlay: true }, expected: "toggleInvisibleOverlay" }, {
args: { toggleInvisibleOverlay: true },
expected: "toggleInvisibleOverlay",
},
{ args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" }, { args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" },
{ args: { showVisibleOverlay: true }, expected: "setVisibleOverlayVisible:true" }, {
{ args: { hideVisibleOverlay: true }, expected: "setVisibleOverlayVisible:false" }, args: { showVisibleOverlay: true },
{ args: { showInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:true" }, expected: "setVisibleOverlayVisible:true",
{ args: { hideInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:false" }, },
{
args: { hideVisibleOverlay: true },
expected: "setVisibleOverlayVisible:false",
},
{
args: { showInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:true",
},
{
args: { hideInvisibleOverlay: true },
expected: "setInvisibleOverlayVisible:false",
},
{ args: { copySubtitle: true }, expected: "copyCurrentSubtitle" }, { args: { copySubtitle: true }, expected: "copyCurrentSubtitle" },
{ args: { copySubtitleMultiple: true }, expected: "startPendingMultiCopy:2500" }, {
args: { copySubtitleMultiple: true },
expected: "startPendingMultiCopy:2500",
},
{ {
args: { mineSentenceMultiple: true }, args: { mineSentenceMultiple: true },
expected: "startPendingMineSentenceMultiple:2500", expected: "startPendingMineSentenceMultiple:2500",
}, },
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" }, { args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
{ args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" }, {
args: { openRuntimeOptions: true },
expected: "openRuntimeOptionsPalette",
},
{ args: { anilistLogout: true }, expected: "clearAnilistToken" }, { args: { anilistLogout: true }, expected: "clearAnilistToken" },
{ args: { anilistSetup: true }, expected: "openAnilistSetup" }, { args: { anilistSetup: true }, expected: "openAnilistSetup" },
{ args: { jellyfin: true }, expected: "openJellyfinSetup" },
]; ];
for (const entry of cases) { for (const entry of cases) {
@@ -331,7 +383,9 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
test("handleCliCommand logs AniList status details", () => { test("handleCliCommand logs AniList status details", () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), "initial", deps); handleCliCommand(makeArgs({ anilistStatus: true }), "initial", deps);
assert.ok(calls.some((value) => value.startsWith("log:AniList token status:"))); assert.ok(
calls.some((value) => value.startsWith("log:AniList token status:")),
);
assert.ok(calls.some((value) => value.startsWith("log:AniList queue:"))); assert.ok(calls.some((value) => value.startsWith("log:AniList queue:")));
}); });
@@ -342,6 +396,57 @@ test("handleCliCommand runs AniList retry command", async () => {
assert.ok(calls.includes("retryAnilistQueue")); assert.ok(calls.includes("retryAnilistQueue"));
assert.ok(calls.includes("log:AniList retry processed.")); assert.ok(calls.includes("log:AniList retry processed."));
}); });
test("handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands", () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },
{ copySubtitle: true },
{ toggleVisibleOverlay: true },
];
for (const args of nonJellyfinArgs) {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs(args), "initial", deps);
const runJellyfinCallCount = calls.filter(
(value) => value === "runJellyfinCommand",
).length;
assert.equal(
runJellyfinCallCount,
0,
`Unexpected Jellyfin dispatch for args ${JSON.stringify(args)}`,
);
}
});
test("handleCliCommand runs jellyfin command dispatcher", async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
const runJellyfinCallCount = calls.filter(
(value) => value === "runJellyfinCommand",
).length;
assert.equal(runJellyfinCallCount, 2);
});
test("handleCliCommand reports jellyfin command errors to OSD", async () => {
const { deps, calls, osd } = createDeps({
runJellyfinCommand: async () => {
throw new Error("server offline");
},
});
handleCliCommand(makeArgs({ jellyfinLibraries: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(
calls.some((value) => value.startsWith("error:runJellyfinCommand failed:")),
);
assert.ok(
osd.some((value) => value.includes("Jellyfin command failed: server offline")),
);
});
test("handleCliCommand runs refresh-known-words command", () => { test("handleCliCommand runs refresh-known-words command", () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
@@ -363,5 +468,9 @@ test("handleCliCommand reports async refresh-known-words errors to OSD", async (
assert.ok( assert.ok(
calls.some((value) => value.startsWith("error:refreshKnownWords failed:")), calls.some((value) => value.startsWith("error:refreshKnownWords failed:")),
); );
assert.ok(osd.some((value) => value.includes("Refresh known words failed: refresh boom"))); assert.ok(
osd.some((value) =>
value.includes("Refresh known words failed: refresh boom"),
),
);
}); });

View File

@@ -49,6 +49,7 @@ export interface CliCommandServiceDeps {
}; };
clearAnilistToken: () => void; clearAnilistToken: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
openJellyfinSetup: () => void;
getAnilistQueueStatus: () => { getAnilistQueueStatus: () => {
pending: number; pending: number;
ready: number; ready: number;
@@ -57,6 +58,7 @@ export interface CliCommandServiceDeps {
lastError: string | null; lastError: string | null;
}; };
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void; printHelp: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
@@ -138,6 +140,10 @@ export interface CliCommandDepsRuntimeOptions {
overlay: OverlayCliRuntime; overlay: OverlayCliRuntime;
mining: MiningCliRuntime; mining: MiningCliRuntime;
anilist: AnilistCliRuntime; anilist: AnilistCliRuntime;
jellyfin: {
openSetup: () => void;
runCommand: (args: CliArgs) => Promise<void>;
};
ui: UiCliRuntime; ui: UiCliRuntime;
app: AppCliRuntime; app: AppCliRuntime;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
@@ -201,8 +207,10 @@ export function createCliCommandDepsRuntime(
getAnilistStatus: options.anilist.getStatus, getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken, clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup, openAnilistSetup: options.anilist.openSetup,
openJellyfinSetup: options.jellyfin.openSetup,
getAnilistQueueStatus: options.anilist.getQueueStatus, getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow, retryAnilistQueue: options.anilist.retryQueueNow,
runJellyfinCommand: options.jellyfin.runCommand,
printHelp: options.ui.printHelp, printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow, hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -262,9 +270,18 @@ export function handleCliCommand(
args.anilistLogout || args.anilistLogout ||
args.anilistSetup || args.anilistSetup ||
args.anilistRetryQueue || args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker || args.texthooker ||
args.help; args.help;
const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction; const ignoreStartOnly =
source === "second-instance" && args.start && !hasNonStartAction;
if (ignoreStartOnly) { if (ignoreStartOnly) {
deps.log("Ignoring --start because SubMiner is already running."); deps.log("Ignoring --start because SubMiner is already running.");
return; return;
@@ -402,6 +419,9 @@ export function handleCliCommand(
} else if (args.anilistSetup) { } else if (args.anilistSetup) {
deps.openAnilistSetup(); deps.openAnilistSetup();
deps.log("Opened AniList setup flow."); deps.log("Opened AniList setup flow.");
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log("Opened Jellyfin setup flow.");
} else if (args.anilistRetryQueue) { } else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus(); const queueStatus = deps.getAnilistQueueStatus();
deps.log( deps.log(
@@ -417,6 +437,21 @@ export function handleCliCommand(
"retryAnilistQueue", "retryAnilistQueue",
"AniList retry failed", "AniList retry failed",
); );
} else if (
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce
) {
runAsyncWithOsd(
() => deps.runJellyfinCommand(args),
deps,
"runJellyfinCommand",
"Jellyfin command failed",
);
} else if (args.texthooker) { } else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort(); const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort); deps.ensureTexthookerRunning(texthookerPort);

View File

@@ -20,10 +20,11 @@ export {
triggerFieldGrouping, triggerFieldGrouping,
updateLastCardFromClipboard, updateLastCardFromClipboard,
} from "./mining"; } from "./mining";
export { createAppLifecycleDepsRuntime, startAppLifecycle } from "./app-lifecycle";
export { export {
cycleSecondarySubMode, createAppLifecycleDepsRuntime,
} from "./subtitle-position"; startAppLifecycle,
} from "./app-lifecycle";
export { cycleSecondarySubMode } from "./subtitle-position";
export { export {
getInitialInvisibleOverlayVisibility, getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime, isAutoUpdateEnabledRuntime,
@@ -92,9 +93,24 @@ export { handleMpvCommandFromIpc } from "./ipc-command";
export { createFieldGroupingOverlayRuntime } from "./field-grouping-overlay"; export { createFieldGroupingOverlayRuntime } from "./field-grouping-overlay";
export { createNumericShortcutRuntime } from "./numeric-shortcut"; export { createNumericShortcutRuntime } from "./numeric-shortcut";
export { runStartupBootstrapRuntime } from "./startup"; export { runStartupBootstrapRuntime } from "./startup";
export { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from "./subsync-runner"; export {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
} from "./subsync-runner";
export { registerAnkiJimakuIpcRuntime } from "./anki-jimaku"; export { registerAnkiJimakuIpcRuntime } from "./anki-jimaku";
export { ImmersionTrackerService } from "./immersion-tracker-service"; export { ImmersionTrackerService } from "./immersion-tracker-service";
export {
authenticateWithPassword as authenticateWithPasswordRuntime,
listItems as listJellyfinItemsRuntime,
listLibraries as listJellyfinLibrariesRuntime,
listSubtitleTracks as listJellyfinSubtitleTracksRuntime,
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from "./jellyfin";
export {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
export { export {
broadcastRuntimeOptionsChangedRuntime, broadcastRuntimeOptionsChangedRuntime,
createOverlayManager, createOverlayManager,

View File

@@ -0,0 +1,334 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildJellyfinTimelinePayload,
JellyfinRemoteSessionService,
} from "./jellyfin-remote";
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(listener);
return this;
}
close(): void {
this.emit("close");
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners[event] ?? []) {
listener(...args);
}
}
}
test("Jellyfin remote service has no traffic until started", async () => {
let socketCreateCount = 0;
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-0",
deviceId: "device-0",
webSocketFactory: () => {
socketCreateCount += 1;
return new FakeWebSocket() as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(socketCreateCount, 0);
assert.equal(fetchCalls.length, 0);
assert.equal(service.isConnected(), false);
});
test("start posts capabilities on socket connect", async () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-1",
deviceId: "device-1",
webSocketFactory: (url) => {
assert.equal(url, "ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1");
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchCalls.length, 1);
assert.equal(
fetchCalls[0].input,
"http://jellyfin.local:8096/Sessions/Capabilities/Full",
);
assert.equal(service.isConnected(), true);
});
test("socket headers include jellyfin authorization metadata", () => {
const seenHeaders: Record<string, string>[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local:8096",
accessToken: "token-auth",
deviceId: "device-auth",
clientName: "SubMiner",
clientVersion: "0.1.0",
deviceName: "SubMiner",
socketHeadersFactory: (_url, headers) => {
seenHeaders.push(headers);
return new FakeWebSocket() as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
});
service.start();
assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0].Authorization.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0].Authorization.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]["X-Emby-Authorization"]);
});
test("dispatches inbound Play, Playstate, and GeneralCommand messages", () => {
const sockets: FakeWebSocket[] = [];
const playPayloads: unknown[] = [];
const playstatePayloads: unknown[] = [];
const commandPayloads: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-2",
deviceId: "device-2",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
onPlay: (payload) => playPayloads.push(payload),
onPlaystate: (payload) => playstatePayloads.push(payload),
onGeneralCommand: (payload) => commandPayloads.push(payload),
});
service.start();
const socket = sockets[0];
socket.emit(
"message",
JSON.stringify({ MessageType: "Play", Data: { ItemId: "movie-1" } }),
);
socket.emit(
"message",
JSON.stringify({ MessageType: "Playstate", Data: JSON.stringify({ Command: "Pause" }) }),
);
socket.emit(
"message",
Buffer.from(
JSON.stringify({
MessageType: "GeneralCommand",
Data: { Name: "DisplayMessage" },
}),
"utf8",
),
);
assert.deepEqual(playPayloads, [{ ItemId: "movie-1" }]);
assert.deepEqual(playstatePayloads, [{ Command: "Pause" }]);
assert.deepEqual(commandPayloads, [{ Name: "DisplayMessage" }]);
});
test("schedules reconnect with bounded exponential backoff", () => {
const sockets: FakeWebSocket[] = [];
const delays: number[] = [];
const pendingTimers: Array<() => void> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-3",
deviceId: "device-3",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
reconnectBaseDelayMs: 100,
reconnectMaxDelayMs: 400,
setTimer: ((handler: () => void, delay?: number) => {
pendingTimers.push(handler);
delays.push(Number(delay));
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => {
return;
}) as typeof clearTimeout,
});
service.start();
sockets[0].emit("close");
pendingTimers.shift()?.();
sockets[1].emit("close");
pendingTimers.shift()?.();
sockets[2].emit("close");
pendingTimers.shift()?.();
sockets[3].emit("close");
assert.deepEqual(delays, [100, 200, 400, 400]);
assert.equal(sockets.length, 4);
});
test("Jellyfin remote stop prevents further reconnect/network activity", () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const pendingTimers: Array<() => void> = [];
const clearedTimers: unknown[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-stop",
deviceId: "device-stop",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
setTimer: ((handler: () => void) => {
pendingTimers.push(handler);
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: ((timer) => {
clearedTimers.push(timer);
}) as typeof clearTimeout,
});
service.start();
assert.equal(sockets.length, 1);
sockets[0].emit("close");
assert.equal(pendingTimers.length, 1);
service.stop();
for (const reconnect of pendingTimers) reconnect();
assert.ok(clearedTimers.length >= 1);
assert.equal(sockets.length, 1);
assert.equal(fetchCalls.length, 0);
assert.equal(service.isConnected(), false);
});
test("reportProgress posts timeline payload and treats failure as non-fatal", async () => {
const sockets: FakeWebSocket[] = [];
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
let shouldFailTimeline = false;
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-4",
deviceId: "device-4",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
if (String(input).endsWith("/Sessions/Playing/Progress") && shouldFailTimeline) {
return new Response("boom", { status: 500 });
}
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 0));
const expectedPayload = buildJellyfinTimelinePayload({
itemId: "movie-2",
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
});
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload));
const ok = await service.reportProgress({
itemId: "movie-2",
positionTicks: 123456,
isPaused: true,
volumeLevel: 33,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
});
shouldFailTimeline = true;
const failed = await service.reportProgress({
itemId: "movie-2",
positionTicks: 999,
});
const timelineCall = fetchCalls.find((call) =>
call.input.endsWith("/Sessions/Playing/Progress"),
);
assert.ok(timelineCall);
assert.equal(ok, true);
assert.equal(failed, false);
assert.ok(typeof timelineCall.init.body === "string");
assert.deepEqual(
JSON.parse(String(timelineCall.init.body)),
expectedPostedPayload,
);
});
test("advertiseNow validates server registration using Sessions endpoint", async () => {
const sockets: FakeWebSocket[] = [];
const calls: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: "http://jellyfin.local",
accessToken: "token-5",
deviceId: "device-5",
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/Sessions")) {
return new Response(
JSON.stringify([{ DeviceId: "device-5" }]),
{ status: 200 },
);
}
return new Response(null, { status: 200 });
}) as typeof fetch,
});
service.start();
sockets[0].emit("open");
const ok = await service.advertiseNow();
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith("/Sessions")));
});

View File

@@ -0,0 +1,448 @@
import WebSocket from "ws";
export interface JellyfinRemoteSessionMessage {
MessageType?: string;
Data?: unknown;
}
export interface JellyfinTimelinePlaybackState {
itemId: string;
mediaSourceId?: string;
positionTicks?: number;
playbackStartTimeTicks?: number;
isPaused?: boolean;
isMuted?: boolean;
canSeek?: boolean;
volumeLevel?: number;
playbackRate?: number;
playMethod?: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playlistItemId?: string | null;
eventName?: string;
}
export interface JellyfinTimelinePayload {
ItemId: string;
MediaSourceId?: string;
PositionTicks: number;
PlaybackStartTimeTicks: number;
IsPaused: boolean;
IsMuted: boolean;
CanSeek: boolean;
VolumeLevel: number;
PlaybackRate: number;
PlayMethod: string;
AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null;
EventName: string;
}
interface JellyfinRemoteSocket {
on(event: "open", listener: () => void): this;
on(event: "close", listener: () => void): this;
on(event: "error", listener: (error: Error) => void): this;
on(event: "message", listener: (data: unknown) => void): this;
close(): void;
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
serverUrl: string;
accessToken: string;
deviceId: string;
capabilities?: {
PlayableMediaTypes?: string;
SupportedCommands?: string;
SupportsMediaControl?: boolean;
};
onPlay?: (payload: unknown) => void;
onPlaystate?: (payload: unknown) => void;
onGeneralCommand?: (payload: unknown) => void;
fetchImpl?: typeof fetch;
webSocketFactory?: (url: string) => JellyfinRemoteSocket;
socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
setTimer?: typeof setTimeout;
clearTimer?: typeof clearTimeout;
reconnectBaseDelayMs?: number;
reconnectMaxDelayMs?: number;
clientName?: string;
clientVersion?: string;
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
}
function normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, "");
}
function clampVolume(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeTicks(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value));
}
function parseMessageData(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | null {
const serialized =
typeof rawData === "string"
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString("utf8")
: null;
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as JellyfinRemoteSessionMessage;
if (!parsed || typeof parsed !== "object") return null;
return parsed;
} catch {
return null;
}
}
function asNullableInteger(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isInteger(value)) return null;
return value;
}
function createDefaultCapabilities(): {
PlayableMediaTypes: string;
SupportedCommands: string;
SupportsMediaControl: boolean;
} {
return {
PlayableMediaTypes: "Video,Audio",
SupportedCommands:
"Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent",
SupportsMediaControl: true,
};
}
function buildAuthorizationHeader(params: {
clientName: string;
deviceName: string;
clientVersion: string;
deviceId: string;
accessToken: string;
}): string {
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
}
export function buildJellyfinTimelinePayload(
state: JellyfinTimelinePlaybackState,
): JellyfinTimelinePayload {
return {
ItemId: state.itemId,
MediaSourceId: state.mediaSourceId,
PositionTicks: normalizeTicks(state.positionTicks),
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
IsPaused: state.isPaused === true,
IsMuted: state.isMuted === true,
CanSeek: state.canSeek !== false,
VolumeLevel: clampVolume(state.volumeLevel),
PlaybackRate:
typeof state.playbackRate === "number" && Number.isFinite(state.playbackRate)
? state.playbackRate
: 1,
PlayMethod: state.playMethod || "DirectPlay",
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId,
EventName: state.eventName || "timeupdate",
};
}
export class JellyfinRemoteSessionService {
private readonly serverUrl: string;
private readonly accessToken: string;
private readonly deviceId: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory?: (url: string) => JellyfinRemoteSocket;
private readonly socketHeadersFactory?: (
url: string,
headers: JellyfinRemoteSocketHeaders,
) => JellyfinRemoteSocket;
private readonly setTimer: typeof setTimeout;
private readonly clearTimer: typeof clearTimeout;
private readonly onPlay?: (payload: unknown) => void;
private readonly onPlaystate?: (payload: unknown) => void;
private readonly onGeneralCommand?: (payload: unknown) => void;
private readonly capabilities: {
PlayableMediaTypes: string;
SupportedCommands: string;
SupportsMediaControl: boolean;
};
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
private socket: JellyfinRemoteSocket | null = null;
private running = false;
private connected = false;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: JellyfinRemoteSessionServiceOptions) {
this.serverUrl = normalizeServerUrl(options.serverUrl);
this.accessToken = options.accessToken;
this.deviceId = options.deviceId;
this.fetchImpl = options.fetchImpl ?? fetch;
this.webSocketFactory = options.webSocketFactory;
this.socketHeadersFactory = options.socketHeadersFactory;
this.setTimer = options.setTimer ?? setTimeout;
this.clearTimer = options.clearTimer ?? clearTimeout;
this.onPlay = options.onPlay;
this.onPlaystate = options.onPlaystate;
this.onGeneralCommand = options.onGeneralCommand;
this.capabilities = {
...createDefaultCapabilities(),
...(options.capabilities ?? {}),
};
const clientName = options.clientName || "SubMiner";
const clientVersion = options.clientVersion || "0.1.0";
const deviceName = options.deviceName || clientName;
this.authHeader = buildAuthorizationHeader({
clientName,
deviceName,
clientVersion,
deviceId: this.deviceId,
accessToken: this.accessToken,
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
options.reconnectMaxDelayMs ?? 10_000,
);
}
public start(): void {
if (this.running) return;
this.running = true;
this.reconnectAttempt = 0;
this.connectSocket();
}
public stop(): void {
this.running = false;
this.connected = false;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
public isConnected(): boolean {
return this.connected;
}
public async advertiseNow(): Promise<boolean> {
await this.postCapabilities();
return this.isRegisteredOnServer();
}
public async reportPlaying(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline("/Sessions/Playing", {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || "start",
});
}
public async reportProgress(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline(
"/Sessions/Playing/Progress",
buildJellyfinTimelinePayload(state),
);
}
public async reportStopped(
state: JellyfinTimelinePlaybackState,
): Promise<boolean> {
return this.postTimeline("/Sessions/Playing/Stopped", {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || "stop",
});
}
private connectSocket(): void {
if (!this.running) return;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
const socket = this.createSocket(this.createSocketUrl());
this.socket = socket;
let disconnected = false;
socket.on("open", () => {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.onConnected?.();
void this.postCapabilities();
});
socket.on("message", (rawData) => {
this.handleInboundMessage(rawData);
});
const handleDisconnect = () => {
if (disconnected) return;
disconnected = true;
if (this.socket === socket) {
this.socket = null;
}
this.connected = false;
this.onDisconnected?.();
if (this.running) {
this.scheduleReconnect();
}
};
socket.on("close", handleDisconnect);
socket.on("error", handleDisconnect);
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
this.reconnectBaseDelayMs * 2 ** this.reconnectAttempt,
);
this.reconnectAttempt += 1;
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
}
this.reconnectTimer = this.setTimer(() => {
this.reconnectTimer = null;
this.connectSocket();
}, delay);
}
private createSocketUrl(): string {
const baseUrl = new URL(`${this.serverUrl}/`);
const socketUrl = new URL("/socket", baseUrl);
socketUrl.protocol = baseUrl.protocol === "https:" ? "wss:" : "ws:";
socketUrl.searchParams.set("api_key", this.accessToken);
socketUrl.searchParams.set("deviceId", this.deviceId);
return socketUrl.toString();
}
private createSocket(url: string): JellyfinRemoteSocket {
const headers: JellyfinRemoteSocketHeaders = {
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
};
if (this.socketHeadersFactory) {
return this.socketHeadersFactory(url, headers);
}
if (this.webSocketFactory) {
return this.webSocketFactory(url);
}
return new WebSocket(url, { headers }) as unknown as JellyfinRemoteSocket;
}
private async postCapabilities(): Promise<void> {
const payload = this.capabilities;
const fullEndpointOk = await this.postJson(
"/Sessions/Capabilities/Full",
payload,
);
if (fullEndpointOk) return;
await this.postJson("/Sessions/Capabilities", payload);
}
private async isRegisteredOnServer(): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}/Sessions`, {
method: "GET",
headers: {
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
},
});
if (!response.ok) return false;
const sessions = (await response.json()) as Array<Record<string, unknown>>;
return sessions.some(
(session) => String(session.DeviceId || "") === this.deviceId,
);
} catch {
return false;
}
}
private async postTimeline(
path: string,
payload: JellyfinTimelinePayload,
): Promise<boolean> {
return this.postJson(path, payload);
}
private async postJson(path: string, payload: unknown): Promise<boolean> {
try {
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: this.authHeader,
"X-Emby-Authorization": this.authHeader,
"X-Emby-Token": this.accessToken,
},
body: JSON.stringify(payload),
});
return response.ok;
} catch {
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
const payload = parseMessageData(message.Data);
if (messageType === "Play") {
this.onPlay?.(payload);
return;
}
if (messageType === "Playstate") {
this.onPlaystate?.(payload);
return;
}
if (messageType === "GeneralCommand") {
this.onGeneralCommand?.(payload);
}
}
}

View File

@@ -0,0 +1,702 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
authenticateWithPassword,
listItems,
listLibraries,
listSubtitleTracks,
resolvePlaybackPlan,
ticksToSeconds,
} from "./jellyfin";
const clientInfo = {
deviceId: "subminer-test",
clientName: "SubMiner",
clientVersion: "0.1.0-test",
};
test("authenticateWithPassword returns token and user", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /Users\/AuthenticateByName$/);
return new Response(
JSON.stringify({
AccessToken: "abc123",
User: { Id: "user-1" },
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const session = await authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
clientInfo,
);
assert.equal(session.serverUrl, "http://jellyfin.local:8096");
assert.equal(session.accessToken, "abc123");
assert.equal(session.userId, "user-1");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listLibraries maps server response", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Items: [
{
Id: "lib-1",
Name: "TV",
CollectionType: "tvshows",
Type: "CollectionFolder",
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const libraries = await listLibraries(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
);
assert.deepEqual(libraries, [
{
id: "lib-1",
name: "TV",
collectionType: "tvshows",
type: "CollectionFolder",
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("listItems supports search and formats title", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
assert.match(String(input), /SearchTerm=planet/);
return new Response(
JSON.stringify({
Items: [
{
Id: "ep-1",
Name: "Pilot",
Type: "Episode",
SeriesName: "Space Show",
ParentIndexNumber: 1,
IndexNumber: 2,
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const items = await listItems(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
libraryId: "lib-1",
searchTerm: "planet",
limit: 25,
},
);
assert.equal(items[0].title, "Space Show S01E02 Pilot");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan chooses direct play when allowed", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
Name: "Movie A",
UserData: { PlaybackPositionTicks: 20_000_000 },
MediaSources: [
{
Id: "ms-1",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 1,
DefaultSubtitleStreamIndex: 3,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
},
{ itemId: "movie-1" },
);
assert.equal(plan.mode, "direct");
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
assert.equal(plan.subtitleStreamIndex, null);
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-2",
Name: "Movie B",
UserData: { PlaybackPositionTicks: 10_000_000 },
MediaSources: [
{
Id: "ms-2",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 4,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: false,
directPlayContainers: ["mkv"],
transcodeVideoCodec: "h264",
},
{ itemId: "movie-2" },
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
assert.equal(url.searchParams.get("api_key"), "token");
assert.equal(url.searchParams.get("AudioStreamIndex"), "4");
assert.equal(url.searchParams.get("StartTimeTicks"), "10000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan falls back to transcode when direct container not allowed", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-3",
Name: "Movie C",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-3",
Container: "avi",
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
},
{
itemId: "movie-3",
audioStreamIndex: 2,
subtitleStreamIndex: 5,
},
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.equal(url.searchParams.get("VideoCodec"), "h265");
assert.equal(url.searchParams.get("AudioStreamIndex"), "2");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "5");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listSubtitleTracks returns all subtitle streams with delivery urls", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
MediaSources: [
{
Id: "ms-1",
MediaStreams: [
{
Type: "Subtitle",
Index: 2,
Language: "eng",
DisplayTitle: "English Full",
IsDefault: true,
DeliveryMethod: "Embed",
},
{
Type: "Subtitle",
Index: 3,
Language: "jpn",
Title: "Japanese Signs",
IsForced: true,
IsExternal: true,
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-1/ms-1/Subtitles/3/Stream.srt",
IsExternalUrl: false,
},
{
Type: "Subtitle",
Index: 4,
Language: "spa",
Title: "Spanish External",
DeliveryMethod: "External",
DeliveryUrl: "https://cdn.example.com/subs.srt",
IsExternalUrl: true,
},
],
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
"movie-1",
);
assert.equal(tracks.length, 3);
assert.deepEqual(
tracks.map((track) => track.index),
[2, 3, 4],
);
assert.equal(
tracks[0].deliveryUrl,
"http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token",
);
assert.equal(
tracks[1].deliveryUrl,
"http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token",
);
assert.equal(tracks[2].deliveryUrl, "https://cdn.example.com/subs.srt");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan falls back to transcode when direct play blocked", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-1",
Name: "Movie A",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-1",
Container: "avi",
SupportsDirectStream: true,
SupportsTranscoding: true,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4"],
transcodeVideoCodec: "h265",
},
{ itemId: "movie-1" },
);
assert.equal(plan.mode, "transcode");
assert.match(plan.url, /master\.m3u8\?/);
assert.match(plan.url, /VideoCodec=h265/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan reuses server transcoding url and appends missing params", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-4",
Name: "Movie D",
UserData: { PlaybackPositionTicks: 50_000_000 },
MediaSources: [
{
Id: "ms-4",
Container: "mkv",
SupportsDirectStream: false,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 3,
TranscodingUrl: "/Videos/movie-4/master.m3u8?VideoCodec=hevc",
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
},
{
itemId: "movie-4",
subtitleStreamIndex: 8,
},
);
assert.equal(plan.mode, "transcode");
const url = new URL(plan.url);
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
assert.equal(url.searchParams.get("VideoCodec"), "hevc");
assert.equal(url.searchParams.get("api_key"), "token");
assert.equal(url.searchParams.get("AudioStreamIndex"), "3");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "8");
assert.equal(url.searchParams.get("StartTimeTicks"), "50000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan preserves episode metadata, stream selection, and resume ticks", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "ep-2",
Type: "Episode",
Name: "A New Hope",
SeriesName: "Galaxy Quest",
ParentIndexNumber: 2,
IndexNumber: 7,
UserData: { PlaybackPositionTicks: 35_000_000 },
MediaSources: [
{
Id: "ms-ep-2",
Container: "mkv",
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: 6,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
const plan = await resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{
enabled: true,
directPlayPreferred: true,
directPlayContainers: ["mkv"],
},
{
itemId: "ep-2",
subtitleStreamIndex: 9,
},
);
assert.equal(plan.mode, "direct");
assert.equal(plan.title, "Galaxy Quest S02E07 A New Hope");
assert.equal(plan.audioStreamIndex, 6);
assert.equal(plan.subtitleStreamIndex, 9);
assert.equal(plan.startTimeTicks, 35_000_000);
const url = new URL(plan.url);
assert.equal(url.searchParams.get("AudioStreamIndex"), "6");
assert.equal(url.searchParams.get("SubtitleStreamIndex"), "9");
assert.equal(url.searchParams.get("StartTimeTicks"), "35000000");
} finally {
globalThis.fetch = originalFetch;
}
});
test("listSubtitleTracks falls back from PlaybackInfo to item media sources", async () => {
const originalFetch = globalThis.fetch;
let requestCount = 0;
globalThis.fetch = (async (input) => {
requestCount += 1;
if (requestCount === 1) {
assert.match(String(input), /\/Items\/movie-fallback\/PlaybackInfo\?/);
return new Response("Playback info unavailable", { status: 500 });
}
return new Response(
JSON.stringify({
Id: "movie-fallback",
MediaSources: [
{
Id: "ms-fallback",
MediaStreams: [
{
Type: "Subtitle",
Index: 11,
Language: "eng",
Title: "English",
DeliveryMethod: "External",
DeliveryUrl: "/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt",
IsExternalUrl: false,
},
],
},
],
}),
{ status: 200 },
);
}) as typeof fetch;
try {
const tracks = await listSubtitleTracks(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
"movie-fallback",
);
assert.equal(requestCount, 2);
assert.equal(tracks.length, 1);
assert.equal(tracks[0].index, 11);
assert.equal(
tracks[0].deliveryUrl,
"http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token",
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("authenticateWithPassword surfaces invalid credentials and server status failures", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"badpw",
clientInfo,
),
/Invalid Jellyfin username or password\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response("Oops", { status: 500, statusText: "Internal Server Error" })) as typeof fetch;
try {
await assert.rejects(
() =>
authenticateWithPassword(
"http://jellyfin.local:8096/",
"kyle",
"pw",
clientInfo,
),
/Jellyfin login failed \(500 Internal Server Error\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("listLibraries surfaces token-expiry auth errors", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("Forbidden", { status: 403, statusText: "Forbidden" })) as typeof fetch;
try {
await assert.rejects(
() =>
listLibraries(
{
serverUrl: "http://jellyfin.local",
accessToken: "expired",
userId: "u1",
username: "kyle",
},
clientInfo,
),
/Jellyfin authentication failed \(invalid or expired token\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("resolvePlaybackPlan surfaces no-source and no-stream fallback errors", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-empty",
Name: "Movie Empty",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{ enabled: true },
{ itemId: "movie-empty" },
),
/No playable media source found for Jellyfin item\./,
);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
Id: "movie-no-stream",
Name: "Movie No Stream",
UserData: { PlaybackPositionTicks: 0 },
MediaSources: [
{
Id: "ms-none",
Container: "avi",
SupportsDirectStream: false,
SupportsTranscoding: false,
},
],
}),
{ status: 200 },
)) as typeof fetch;
try {
await assert.rejects(
() =>
resolvePlaybackPlan(
{
serverUrl: "http://jellyfin.local",
accessToken: "token",
userId: "u1",
username: "kyle",
},
clientInfo,
{ enabled: true },
{ itemId: "movie-no-stream" },
),
/Jellyfin item cannot be streamed by direct play or transcoding\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,571 @@
import { JellyfinConfig } from "../../types";
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
export interface JellyfinAuthSession {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
}
export interface JellyfinLibrary {
id: string;
name: string;
collectionType: string;
type: string;
}
export interface JellyfinPlaybackSelection {
itemId: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}
export interface JellyfinPlaybackPlan {
mode: "direct" | "transcode";
url: string;
title: string;
startTimeTicks: number;
audioStreamIndex: number | null;
subtitleStreamIndex: number | null;
}
export interface JellyfinSubtitleTrack {
index: number;
language: string;
title: string;
codec: string;
isDefault: boolean;
isForced: boolean;
isExternal: boolean;
deliveryMethod: string;
deliveryUrl: string | null;
}
interface JellyfinAuthResponse {
AccessToken?: string;
User?: { Id?: string; Name?: string };
}
interface JellyfinMediaStream {
Index?: number;
Type?: string;
IsExternal?: boolean;
IsDefault?: boolean;
IsForced?: boolean;
Language?: string;
DisplayTitle?: string;
Title?: string;
Codec?: string;
DeliveryMethod?: string;
DeliveryUrl?: string;
IsExternalUrl?: boolean;
}
interface JellyfinMediaSource {
Id?: string;
Container?: string;
SupportsDirectStream?: boolean;
SupportsTranscoding?: boolean;
TranscodingUrl?: string;
DefaultAudioStreamIndex?: number;
DefaultSubtitleStreamIndex?: number;
MediaStreams?: JellyfinMediaStream[];
LiveStreamId?: string;
}
interface JellyfinItemUserData {
PlaybackPositionTicks?: number;
}
interface JellyfinItem {
Id?: string;
Name?: string;
Type?: string;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
UserData?: JellyfinItemUserData;
MediaSources?: JellyfinMediaSource[];
}
interface JellyfinItemsResponse {
Items?: JellyfinItem[];
}
interface JellyfinPlaybackInfoResponse {
MediaSources?: JellyfinMediaSource[];
}
export interface JellyfinClientInfo {
deviceId: string;
clientName: string;
clientVersion: string;
}
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
}
function ensureString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asIntegerOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
itemId: string,
mediaSourceId: string,
): string | null {
const deliveryUrl = ensureString(stream.DeliveryUrl).trim();
if (deliveryUrl) {
if (stream.IsExternalUrl === true) return deliveryUrl;
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
if (!resolved.searchParams.has("api_key")) {
resolved.searchParams.set("api_key", session.accessToken);
}
return resolved.toString();
}
const streamIndex = asIntegerOrNull(stream.Index);
if (streamIndex === null || !itemId || !mediaSourceId) return null;
const codec = ensureString(stream.Codec).toLowerCase();
const ext =
codec === "subrip"
? "srt"
: codec === "webvtt"
? "vtt"
: codec === "vtt"
? "vtt"
: codec === "ass"
? "ass"
: codec === "ssa"
? "ssa"
: "srt";
const fallback = new URL(
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
`${session.serverUrl}/`,
);
if (!fallback.searchParams.has("api_key")) {
fallback.searchParams.set("api_key", session.accessToken);
}
return fallback.toString();
}
function createAuthorizationHeader(
client: JellyfinClientInfo,
token?: string,
): string {
const parts = [
`Client="${client.clientName}"`,
`Device="${client.clientName}"`,
`DeviceId="${client.deviceId}"`,
`Version="${client.clientVersion}"`,
];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(", ")}`;
}
async function jellyfinRequestJson<T>(
path: string,
init: RequestInit,
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<T> {
const headers = new Headers(init.headers ?? {});
headers.set("Content-Type", "application/json");
headers.set(
"Authorization",
createAuthorizationHeader(client, session.accessToken),
);
headers.set("X-Emby-Token", session.accessToken);
const response = await fetch(`${session.serverUrl}${path}`, {
...init,
headers,
});
if (response.status === 401 || response.status === 403) {
throw new Error(
"Jellyfin authentication failed (invalid or expired token).",
);
}
if (!response.ok) {
throw new Error(
`Jellyfin request failed (${response.status} ${response.statusText}).`,
);
}
return response.json() as Promise<T>;
}
function createDirectPlayUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
): string {
const query = new URLSearchParams({
static: "true",
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
});
if (mediaSource.LiveStreamId) {
query.set("LiveStreamId", mediaSource.LiveStreamId);
}
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
}
function createTranscodeUrl(
session: JellyfinAuthSession,
itemId: string,
mediaSource: JellyfinMediaSource,
plan: JellyfinPlaybackPlan,
config: JellyfinConfig,
): string {
if (mediaSource.TranscodingUrl) {
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
if (!url.searchParams.has("api_key")) {
url.searchParams.set("api_key", session.accessToken);
}
if (
!url.searchParams.has("AudioStreamIndex") &&
plan.audioStreamIndex !== null
) {
url.searchParams.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (
!url.searchParams.has("SubtitleStreamIndex") &&
plan.subtitleStreamIndex !== null
) {
url.searchParams.set(
"SubtitleStreamIndex",
String(plan.subtitleStreamIndex),
);
}
if (!url.searchParams.has("StartTimeTicks") && plan.startTimeTicks > 0) {
url.searchParams.set("StartTimeTicks", String(plan.startTimeTicks));
}
return url.toString();
}
const query = new URLSearchParams({
api_key: session.accessToken,
MediaSourceId: ensureString(mediaSource.Id),
VideoCodec: ensureString(config.transcodeVideoCodec, "h264"),
TranscodingContainer: "ts",
});
if (plan.audioStreamIndex !== null) {
query.set("AudioStreamIndex", String(plan.audioStreamIndex));
}
if (plan.subtitleStreamIndex !== null) {
query.set("SubtitleStreamIndex", String(plan.subtitleStreamIndex));
}
if (plan.startTimeTicks > 0) {
query.set("StartTimeTicks", String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`;
}
function getStreamDefaults(source: JellyfinMediaSource): {
audioStreamIndex: number | null;
} {
const audioDefault = asIntegerOrNull(source.DefaultAudioStreamIndex);
if (audioDefault !== null) return { audioStreamIndex: audioDefault };
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const defaultAudio = streams.find(
(stream) => stream.Type === "Audio" && stream.IsDefault === true,
);
return {
audioStreamIndex: asIntegerOrNull(defaultAudio?.Index),
};
}
function getDisplayTitle(item: JellyfinItem): string {
if (item.Type === "Episode") {
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
const prefix = item.SeriesName ? `${item.SeriesName} ` : "";
return `${prefix}S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")} ${ensureString(item.Name).trim()}`.trim();
}
return ensureString(item.Name).trim() || "Jellyfin Item";
}
function shouldPreferDirectPlay(
source: JellyfinMediaSource,
config: JellyfinConfig,
): boolean {
if (source.SupportsDirectStream !== true) return false;
if (config.directPlayPreferred === false) return false;
const container = ensureString(source.Container).toLowerCase();
const allowlist = Array.isArray(config.directPlayContainers)
? config.directPlayContainers.map((entry) => entry.toLowerCase())
: [];
if (!container || allowlist.length === 0) return true;
return allowlist.includes(container);
}
export async function authenticateWithPassword(
serverUrl: string,
username: string,
password: string,
client: JellyfinClientInfo,
): Promise<JellyfinAuthSession> {
const normalizedUrl = normalizeBaseUrl(serverUrl);
if (!normalizedUrl) throw new Error("Missing Jellyfin server URL.");
if (!username.trim()) throw new Error("Missing Jellyfin username.");
if (!password) throw new Error("Missing Jellyfin password.");
const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
if (response.status === 401 || response.status === 403) {
throw new Error("Invalid Jellyfin username or password.");
}
if (!response.ok) {
throw new Error(
`Jellyfin login failed (${response.status} ${response.statusText}).`,
);
}
const payload = (await response.json()) as JellyfinAuthResponse;
const accessToken = ensureString(payload.AccessToken);
const userId = ensureString(payload.User?.Id);
if (!accessToken || !userId) {
throw new Error("Jellyfin login response missing token/user.");
}
return {
serverUrl: normalizedUrl,
accessToken,
userId,
username: username.trim(),
};
}
export async function listLibraries(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
): Promise<JellyfinLibrary[]> {
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Views`,
{ method: "GET" },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name, "Untitled"),
collectionType: ensureString(
(item as { CollectionType?: string }).CollectionType,
),
type: ensureString(item.Type),
}));
}
export async function listItems(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
options: {
libraryId: string;
searchTerm?: string;
limit?: number;
},
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
if (!options.libraryId) throw new Error("Missing Jellyfin library id.");
const query = new URLSearchParams({
ParentId: options.libraryId,
Recursive: "true",
IncludeItemTypes: "Movie,Episode,Audio",
Fields: "MediaSources,UserData",
SortBy: "SortName",
SortOrder: "Ascending",
Limit: String(options.limit ?? 100),
});
if (options.searchTerm?.trim()) {
query.set("SearchTerm", options.searchTerm.trim());
}
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
`/Users/${session.userId}/Items?${query.toString()}`,
{ method: "GET" },
session,
client,
);
const items = Array.isArray(payload.Items) ? payload.Items : [];
return items.map((item) => ({
id: ensureString(item.Id),
name: ensureString(item.Name),
type: ensureString(item.Type),
title: getDisplayTitle(item),
}));
}
export async function listSubtitleTracks(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
itemId: string,
): Promise<JellyfinSubtitleTrack[]> {
if (!itemId.trim()) throw new Error("Missing Jellyfin item id.");
let source: JellyfinMediaSource | undefined;
try {
const playbackInfo =
await jellyfinRequestJson<JellyfinPlaybackInfoResponse>(
`/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(session.userId)}`,
{
method: "POST",
body: JSON.stringify({ UserId: session.userId }),
},
session,
client,
);
source = Array.isArray(playbackInfo.MediaSources)
? playbackInfo.MediaSources[0]
: undefined;
} catch {}
if (!source) {
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${itemId}?Fields=MediaSources`,
{ method: "GET" },
session,
client,
);
source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
}
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
}
const mediaSourceId = ensureString(source.Id);
const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : [];
const tracks: JellyfinSubtitleTrack[] = [];
for (const stream of streams) {
if (stream.Type !== "Subtitle") continue;
const index = asIntegerOrNull(stream.Index);
if (index === null) continue;
tracks.push({
index,
language: ensureString(stream.Language),
title: ensureString(stream.DisplayTitle || stream.Title),
codec: ensureString(stream.Codec),
isDefault: stream.IsDefault === true,
isForced: stream.IsForced === true,
isExternal: stream.IsExternal === true,
deliveryMethod: ensureString(stream.DeliveryMethod),
deliveryUrl: resolveDeliveryUrl(session, stream, itemId, mediaSourceId),
});
}
return tracks;
}
export async function resolvePlaybackPlan(
session: JellyfinAuthSession,
client: JellyfinClientInfo,
config: JellyfinConfig,
selection: JellyfinPlaybackSelection,
): Promise<JellyfinPlaybackPlan> {
if (!selection.itemId) {
throw new Error("Missing Jellyfin item id.");
}
const item = await jellyfinRequestJson<JellyfinItem>(
`/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`,
{ method: "GET" },
session,
client,
);
const source = Array.isArray(item.MediaSources)
? item.MediaSources[0]
: undefined;
if (!source) {
throw new Error("No playable media source found for Jellyfin item.");
}
const defaults = getStreamDefaults(source);
const audioStreamIndex =
selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(
0,
asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0,
);
const basePlan: JellyfinPlaybackPlan = {
mode: "transcode",
url: "",
title: getDisplayTitle(item),
startTimeTicks,
audioStreamIndex,
subtitleStreamIndex,
};
if (shouldPreferDirectPlay(source, config)) {
return {
...basePlan,
mode: "direct",
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (
source.SupportsTranscoding !== true &&
source.SupportsDirectStream === true
) {
return {
...basePlan,
mode: "direct",
url: createDirectPlayUrl(session, selection.itemId, source, basePlan),
};
}
if (source.SupportsTranscoding !== true) {
throw new Error(
"Jellyfin item cannot be streamed by direct play or transcoding.",
);
}
return {
...basePlan,
mode: "transcode",
url: createTranscodeUrl(
session,
selection.itemId,
source,
basePlan,
config,
),
};
}
export function ticksToSeconds(ticks: number): number {
return Math.max(0, Math.floor(ticks / JELLYFIN_TICKS_PER_SECOND));
}

View File

@@ -51,7 +51,9 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
return { return {
state, state,
deps: { deps: {
getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }), getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ["ja"] },
}),
getSubtitleMetrics: () => metrics, getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false, isVisibleOverlayVisible: () => false,
emitSubtitleChange: (payload) => state.events.push(payload), emitSubtitleChange: (payload) => state.events.push(payload),
@@ -103,7 +105,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
...overrides, ...overrides,
}, },
}; };
} }
test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => { test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => {
const { deps, state } = createDeps(); const { deps, state } = createDeps();
@@ -131,7 +133,9 @@ test("dispatchMpvProtocolMessage sets secondary subtitle track based on track li
deps, deps,
); );
assert.deepEqual(state.commands, [{ command: ["set_property", "secondary-sid", 2] }]); assert.deepEqual(state.commands, [
{ command: ["set_property", "secondary-sid", 2] },
]);
}); });
test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => { test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => {
@@ -166,10 +170,9 @@ test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
assert.equal(pendingPauseAtSubEnd, false); assert.equal(pendingPauseAtSubEnd, false);
assert.equal(pauseAtTime, 42); assert.equal(pauseAtTime, 42);
assert.deepEqual(state.events, [{ text: "字幕", start: 0, end: 0 }]); assert.deepEqual(state.events, [{ text: "字幕", start: 0, end: 0 }]);
assert.deepEqual( assert.deepEqual(state.commands[state.commands.length - 1], {
state.commands[state.commands.length - 1], command: ["set_property", "pause", false],
{ command: ["set_property", "pause", false] }, });
);
}); });
test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => { test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => {
@@ -178,7 +181,7 @@ test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buf
); );
assert.equal(parsed.messages.length, 2); assert.equal(parsed.messages.length, 2);
assert.equal(parsed.nextBuffer, "{\"partial\""); assert.equal(parsed.nextBuffer, '{"partial"');
assert.equal(parsed.messages[0].event, "shutdown"); assert.equal(parsed.messages[0].event, "shutdown");
assert.equal(parsed.messages[1].name, "media-title"); assert.equal(parsed.messages[1].name, "media-title");
}); });
@@ -186,9 +189,13 @@ test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buf
test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => { test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => {
const errors: Array<{ line: string; error?: string }> = []; const errors: Array<{ line: string; error?: string }> = [];
splitMpvMessagesFromBuffer('{"event":"x"}\n{invalid}\n', undefined, (line, error) => { splitMpvMessagesFromBuffer(
'{"event":"x"}\n{invalid}\n',
undefined,
(line, error) => {
errors.push({ line, error: String(error) }); errors.push({ line, error: String(error) });
}); },
);
assert.equal(errors.length, 1); assert.equal(errors.length, 1);
assert.equal(errors[0].line, "{invalid}"); assert.equal(errors[0].line, "{invalid}");

View File

@@ -35,10 +35,7 @@ export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export type MpvMessageParser = (message: MpvMessage) => void; export type MpvMessageParser = (message: MpvMessage) => void;
export type MpvParseErrorHandler = ( export type MpvParseErrorHandler = (line: string, error: unknown) => void;
line: string,
error: unknown,
) => void;
export interface MpvProtocolParseResult { export interface MpvProtocolParseResult {
messages: MpvMessage[]; messages: MpvMessage[];
@@ -46,12 +43,21 @@ export interface MpvProtocolParseResult {
} }
export interface MpvProtocolHandleMessageDeps { export interface MpvProtocolHandleMessageDeps {
getResolvedConfig: () => { secondarySub?: { secondarySubLanguages?: Array<string> } }; getResolvedConfig: () => {
secondarySub?: { secondarySubLanguages?: Array<string> };
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics; getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; emitSubtitleChange: (payload: {
text: string;
isOverlayVisible: boolean;
}) => void;
emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSubtitleTiming: (payload: {
text: string;
start: number;
end: number;
}) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void; emitSecondarySubtitleChange: (payload: { text: string }) => void;
getCurrentSubText: () => string; getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
@@ -63,7 +69,9 @@ export interface MpvProtocolHandleMessageDeps {
emitMediaTitleChange: (payload: { title: string | null }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void; emitTimePosChange: (payload: { time: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void; emitPauseChange: (payload: { paused: boolean }) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void; emitSubtitleMetricsChange: (
payload: Partial<MpvSubtitleRenderMetrics>,
) => void;
setCurrentSecondarySubText: (text: string) => void; setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
setSecondarySubVisibility: (visible: boolean) => void; setSecondarySubVisibility: (visible: boolean) => void;
@@ -87,7 +95,10 @@ export interface MpvProtocolHandleMessageDeps {
"ff-index"?: number; "ff-index"?: number;
}>, }>,
) => void; ) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean; sendCommand: (payload: {
command: unknown[];
request_id?: number;
}) => boolean;
restorePreviousSecondarySubVisibility: () => void; restorePreviousSecondarySubVisibility: () => void;
} }
@@ -129,7 +140,10 @@ export async function dispatchMpvProtocolMessage(
if (msg.name === "sub-text") { if (msg.name === "sub-text") {
const nextSubText = (msg.data as string) || ""; const nextSubText = (msg.data as string) || "";
const overlayVisible = deps.isVisibleOverlayVisible(); const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({ text: nextSubText, isOverlayVisible: overlayVisible }); deps.emitSubtitleChange({
text: nextSubText,
isOverlayVisible: overlayVisible,
});
deps.setCurrentSubText(nextSubText); deps.setCurrentSubText(nextSubText);
} else if (msg.name === "sub-text-ass") { } else if (msg.name === "sub-text-ass") {
deps.emitSubtitleAssChange({ text: (msg.data as string) || "" }); deps.emitSubtitleAssChange({ text: (msg.data as string) || "" });
@@ -378,10 +392,7 @@ export async function dispatchMpvProtocolMessage(
} }
} }
export function asBoolean( export function asBoolean(value: unknown, fallback: boolean): boolean {
value: unknown,
fallback: boolean,
): boolean {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0; if (typeof value === "number") return value !== 0;
if (typeof value === "string") { if (typeof value === "string") {
@@ -392,10 +403,7 @@ export function asBoolean(
return fallback; return fallback;
} }
export function asFiniteNumber( export function asFiniteNumber(value: unknown, fallback: number): number {
value: unknown,
fallback: number,
): number {
const nextValue = Number(value); const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : fallback; return Number.isFinite(nextValue) ? nextValue : fallback;
} }

View File

@@ -29,8 +29,5 @@ test("resolveCurrentAudioStreamIndex prefers matching current audio track id", (
}); });
test("resolveCurrentAudioStreamIndex returns null when tracks are not an array", () => { test("resolveCurrentAudioStreamIndex returns null when tracks are not an array", () => {
assert.equal( assert.equal(resolveCurrentAudioStreamIndex(null, null), null);
resolveCurrentAudioStreamIndex(null, null),
null,
);
}); });

View File

@@ -60,7 +60,9 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
handler(); handler();
return 1 as unknown as ReturnType<typeof setTimeout>; return 1 as unknown as ReturnType<typeof setTimeout>;
}; };
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => { (globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
cleared.push(timer); cleared.push(timer);
}; };
@@ -205,14 +207,10 @@ test("MpvSocketTransport ignores connect requests while already connecting or co
test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => { test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => {
const transport = new MpvSocketTransport({ const transport = new MpvSocketTransport({
socketPath: "/tmp/mpv.sock", socketPath: "/tmp/mpv.sock",
onConnect: () => { onConnect: () => {},
}, onData: () => {},
onData: () => { onError: () => {},
}, onClose: () => {},
onError: () => {
},
onClose: () => {
},
socketFactory: () => new FakeSocket() as unknown as net.Socket, socketFactory: () => new FakeSocket() as unknown as net.Socket,
}); });

View File

@@ -38,9 +38,7 @@ export interface MpvReconnectSchedulerDeps {
connect: () => void; connect: () => void;
} }
export function scheduleMpvReconnect( export function scheduleMpvReconnect(deps: MpvReconnectSchedulerDeps): number {
deps: MpvReconnectSchedulerDeps,
): number {
const reconnectTimer = deps.getReconnectTimer(); const reconnectTimer = deps.getReconnectTimer();
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);

View File

@@ -12,7 +12,7 @@ function makeDeps(
overrides: Partial<MpvIpcClientProtocolDeps> = {}, overrides: Partial<MpvIpcClientProtocolDeps> = {},
): MpvIpcClientDeps { ): MpvIpcClientDeps {
return { return {
getResolvedConfig: () => ({} as any), getResolvedConfig: () => ({}) as any,
autoStartOverlay: false, autoStartOverlay: false,
setOverlayVisible: () => {}, setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false, shouldBindVisibleOverlayToMpvSubVisibility: () => false,
@@ -23,10 +23,13 @@ function makeDeps(
}; };
} }
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> { function invokeHandleMessage(
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage( client: MpvIpcClient,
msg, msg: unknown,
); ): Promise<void> {
return (
client as unknown as { handleMessage: (msg: unknown) => Promise<void> }
).handleMessage(msg);
} }
test("MpvIpcClient resolves pending request by request_id", async () => { test("MpvIpcClient resolves pending request by request_id", async () => {
@@ -67,14 +70,14 @@ test("MpvIpcClient parses JSON line protocol in processBuffer", () => {
seen.push(msg); seen.push(msg);
}; };
(client as any).buffer = (client as any).buffer =
"{\"event\":\"property-change\",\"name\":\"path\",\"data\":\"a\"}\n{\"request_id\":1,\"data\":\"ok\"}\n{\"partial\":"; '{"event":"property-change","name":"path","data":"a"}\n{"request_id":1,"data":"ok"}\n{"partial":';
(client as any).processBuffer(); (client as any).processBuffer();
assert.equal(seen.length, 2); assert.equal(seen.length, 2);
assert.equal(seen[0].name, "path"); assert.equal(seen[0].name, "path");
assert.equal(seen[1].request_id, 1); assert.equal(seen[1].request_id, 1);
assert.equal((client as any).buffer, "{\"partial\":"); assert.equal((client as any).buffer, '{"partial":');
}); });
test("MpvIpcClient request rejects when disconnected", async () => { test("MpvIpcClient request rejects when disconnected", async () => {
@@ -170,7 +173,9 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
handler(); handler();
return 1 as unknown as ReturnType<typeof setTimeout>; return 1 as unknown as ReturnType<typeof setTimeout>;
}; };
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => { (globalThis as any).clearTimeout = (
timer: ReturnType<typeof setTimeout> | null,
) => {
cleared.push(timer); cleared.push(timer);
}; };
@@ -245,7 +250,8 @@ test("MpvIpcClient reconnect replays property subscriptions and initial state re
(command) => (command) =>
Array.isArray((command as { command: unknown[] }).command) && Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === "set_property" && (command as { command: unknown[] }).command[0] === "set_property" &&
(command as { command: unknown[] }).command[1] === "secondary-sub-visibility" && (command as { command: unknown[] }).command[1] ===
"secondary-sub-visibility" &&
(command as { command: unknown[] }).command[2] === "no", (command as { command: unknown[] }).command[2] === "no",
); );
const hasTrackSubscription = commands.some( const hasTrackSubscription = commands.some(

View File

@@ -1,9 +1,5 @@
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { import { Config, MpvClient, MpvSubtitleRenderMetrics } from "../../types";
Config,
MpvClient,
MpvSubtitleRenderMetrics,
} from "../../types";
import { import {
dispatchMpvProtocolMessage, dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO, MPV_REQUEST_ID_TRACK_LIST_AUDIO,
@@ -12,11 +8,11 @@ import {
MpvProtocolHandleMessageDeps, MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer, splitMpvMessagesFromBuffer,
} from "./mpv-protocol"; } from "./mpv-protocol";
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
import { import {
scheduleMpvReconnect, requestMpvInitialState,
MpvSocketTransport, subscribeToMpvProperties,
} from "./mpv-transport"; } from "./mpv-properties";
import { scheduleMpvReconnect, MpvSocketTransport } from "./mpv-transport";
import { createLogger } from "../../logger"; import { createLogger } from "../../logger";
const logger = createLogger("main:mpv"); const logger = createLogger("main:mpv");
@@ -42,7 +38,9 @@ export function resolveCurrentAudioStreamIndex(
audioTracks.find((track) => track.selected === true); audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"]; const ffIndex = activeTrack?.["ff-index"];
return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0 return typeof ffIndex === "number" &&
Number.isInteger(ffIndex) &&
ffIndex >= 0
? ffIndex ? ffIndex
: null; : null;
} }
@@ -97,9 +95,7 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible); mpvClient.setSubVisibility(visible);
} }
export { export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-protocol";
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from "./mpv-protocol";
export interface MpvIpcClientProtocolDeps { export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config; getResolvedConfig: () => Config;
@@ -114,6 +110,7 @@ export interface MpvIpcClientProtocolDeps {
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap { export interface MpvIpcClientEventMap {
"connection-change": { connected: boolean };
"subtitle-change": { text: string; isOverlayVisible: boolean }; "subtitle-change": { text: string; isOverlayVisible: boolean };
"subtitle-ass-change": { text: string }; "subtitle-ass-change": { text: string };
"subtitle-timing": { text: string; start: number; end: number }; "subtitle-timing": { text: string; start: number; end: number };
@@ -171,10 +168,7 @@ export class MpvIpcClient implements MpvClient {
private nextDynamicRequestId = 1000; private nextDynamicRequestId = 1000;
private pendingRequests = new Map<number, (message: MpvMessage) => void>(); private pendingRequests = new Map<number, (message: MpvMessage) => void>();
constructor( constructor(socketPath: string, deps: MpvIpcClientDeps) {
socketPath: string,
deps: MpvIpcClientDeps,
) {
this.deps = deps; this.deps = deps;
this.transport = new MpvSocketTransport({ this.transport = new MpvSocketTransport({
@@ -184,6 +178,7 @@ export class MpvIpcClient implements MpvClient {
this.connected = true; this.connected = true;
this.connecting = false; this.connecting = false;
this.socket = this.transport.getSocket(); this.socket = this.transport.getSocket();
this.emit("connection-change", { connected: true });
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.hasConnectedOnce = true; this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false); this.setSecondarySubVisibility(false);
@@ -217,6 +212,7 @@ export class MpvIpcClient implements MpvClient {
this.connected = false; this.connected = false;
this.connecting = false; this.connecting = false;
this.socket = null; this.socket = null;
this.emit("connection-change", { connected: false });
this.failPendingRequests(); this.failPendingRequests();
this.scheduleReconnect(); this.scheduleReconnect();
}, },
@@ -512,7 +508,11 @@ export class MpvIpcClient implements MpvClient {
const previous = this.previousSecondarySubVisibility; const previous = this.previousSecondarySubVisibility;
if (previous === null) return; if (previous === null) return;
this.send({ this.send({
command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"], command: [
"set_property",
"secondary-sub-visibility",
previous ? "yes" : "no",
],
}); });
this.previousSecondarySubVisibility = null; this.previousSecondarySubVisibility = null;
} }

View File

@@ -1,8 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import { runStartupBootstrapRuntime } from "./startup";
runStartupBootstrapRuntime,
} from "./startup";
import { CliArgs } from "../../cli/args"; import { CliArgs } from "../../cli/args";
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
@@ -34,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
anilistRetryQueue: false, anilistRetryQueue: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
texthooker: false, texthooker: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
@@ -106,6 +113,35 @@ test("runStartupBootstrapRuntime keeps log-level precedence for repeated calls",
]); ]);
}); });
test("runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags", () => {
const calls: string[] = [];
const args = makeArgs({
jellyfin: true,
jellyfinLibraries: true,
socketPath: "/tmp/stable.sock",
texthookerPort: 8888,
});
const result = runStartupBootstrapRuntime({
argv: ["node", "main.ts", "--jellyfin", "--jellyfin-libraries"],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push("forceX11"),
enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"),
getDefaultSocketPath: () => "/tmp/default.sock",
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
});
assert.equal(result.mpvSocketPath, "/tmp/stable.sock");
assert.equal(result.texthookerPort, 8888);
assert.equal(result.backendOverride, null);
assert.equal(result.autoStartOverlay, false);
assert.equal(result.texthookerOnlyMode, false);
assert.deepEqual(calls, ["forceX11", "enforceWayland", "startLifecycle"]);
});
test("runStartupBootstrapRuntime keeps --debug separate from log verbosity", () => { test("runStartupBootstrapRuntime keeps --debug separate from log verbosity", () => {
const calls: string[] = []; const calls: string[] = [];
const args = makeArgs({ const args = makeArgs({
@@ -146,9 +182,5 @@ test("runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
assert.equal(result.mpvSocketPath, "/tmp/default.sock"); assert.equal(result.mpvSocketPath, "/tmp/default.sock");
assert.equal(result.texthookerPort, 5174); assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null); assert.equal(result.backendOverride, null);
assert.deepEqual(calls, [ assert.deepEqual(calls, ["setLog:warn:cli", "forceX11", "enforceWayland"]);
"setLog:warn:cli",
"forceX11",
"enforceWayland",
]);
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,12 @@
import { handleCliCommand, createCliCommandDepsRuntime } from "../core/services"; import {
handleCliCommand,
createCliCommandDepsRuntime,
} from "../core/services";
import type { CliArgs, CliCommandSource } from "../cli/args"; import type { CliArgs, CliCommandSource } from "../cli/args";
import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies"; import {
createCliCommandRuntimeServiceDeps,
CliCommandRuntimeServiceDepsParams,
} from "./dependencies";
export interface CliCommandRuntimeServiceContext { export interface CliCommandRuntimeServiceContext {
getSocketPath: () => string; getSocketPath: () => string;
@@ -31,6 +37,8 @@ export interface CliCommandRuntimeServiceContext {
openAnilistSetup: CliCommandRuntimeServiceDepsParams["anilist"]["openSetup"]; openAnilistSetup: CliCommandRuntimeServiceDepsParams["anilist"]["openSetup"];
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams["anilist"]["getQueueStatus"]; getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams["anilist"]["getQueueStatus"];
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams["anilist"]["retryQueueNow"]; retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams["anilist"]["retryQueueNow"];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams["jellyfin"]["openSetup"];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams["jellyfin"]["runCommand"];
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -49,7 +57,8 @@ export interface CliCommandRuntimeServiceContextHandlers {
} }
function createCliCommandDepsFromContext( function createCliCommandDepsFromContext(
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, context: CliCommandRuntimeServiceContext &
CliCommandRuntimeServiceContextHandlers,
): CliCommandRuntimeServiceDepsParams { ): CliCommandRuntimeServiceDepsParams {
return { return {
mpv: { mpv: {
@@ -77,7 +86,8 @@ function createCliCommandDepsFromContext(
copyCurrentSubtitle: context.copyCurrentSubtitle, copyCurrentSubtitle: context.copyCurrentSubtitle,
startPendingMultiCopy: context.startPendingMultiCopy, startPendingMultiCopy: context.startPendingMultiCopy,
mineSentenceCard: context.mineSentenceCard, mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple, startPendingMineSentenceMultiple:
context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard, updateLastCardFromClipboard: context.updateLastCardFromClipboard,
refreshKnownWords: context.refreshKnownWordCache, refreshKnownWords: context.refreshKnownWordCache,
triggerFieldGrouping: context.triggerFieldGrouping, triggerFieldGrouping: context.triggerFieldGrouping,
@@ -91,6 +101,10 @@ function createCliCommandDepsFromContext(
getQueueStatus: context.getAnilistQueueStatus, getQueueStatus: context.getAnilistQueueStatus,
retryQueueNow: context.retryAnilistQueueNow, retryQueueNow: context.retryAnilistQueueNow,
}, },
jellyfin: {
openSetup: context.openJellyfinSetup,
runCommand: context.runJellyfinCommand,
},
ui: { ui: {
openYomitanSettings: context.openYomitanSettings, openYomitanSettings: context.openYomitanSettings,
cycleSecondarySubMode: context.cycleSecondarySubMode, cycleSecondarySubMode: context.cycleSecondarySubMode,
@@ -123,7 +137,12 @@ export function handleCliCommandRuntimeService(
export function handleCliCommandRuntimeServiceWithContext( export function handleCliCommandRuntimeServiceWithContext(
args: CliArgs, args: CliArgs,
source: CliCommandSource, source: CliCommandSource,
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, context: CliCommandRuntimeServiceContext &
CliCommandRuntimeServiceContextHandlers,
): void { ): void {
handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context)); handleCliCommandRuntimeService(
args,
source,
createCliCommandDepsFromContext(context),
);
} }

View File

@@ -29,7 +29,9 @@ export interface SubsyncRuntimeDepsParams {
openManualPicker: (payload: SubsyncManualPayload) => void; openManualPicker: (payload: SubsyncManualPayload) => void;
} }
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): { export function createRuntimeOptionsIpcDeps(
params: RuntimeOptionsIpcDepsParams,
): {
setRuntimeOption: (id: string, value: unknown) => unknown; setRuntimeOption: (id: string, value: unknown) => unknown;
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
} { } {
@@ -51,7 +53,9 @@ export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams)
}; };
} }
export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): SubsyncRuntimeDeps { export function createSubsyncRuntimeDeps(
params: SubsyncRuntimeDepsParams,
): SubsyncRuntimeDeps {
return { return {
getMpvClient: params.getMpvClient, getMpvClient: params.getMpvClient,
getResolvedSubsyncConfig: params.getResolvedSubsyncConfig, getResolvedSubsyncConfig: params.getResolvedSubsyncConfig,
@@ -145,19 +149,14 @@ export interface CliCommandRuntimeServiceDepsParams {
}; };
mining: { mining: {
copyCurrentSubtitle: CliCommandDepsRuntimeOptions["mining"]["copyCurrentSubtitle"]; copyCurrentSubtitle: CliCommandDepsRuntimeOptions["mining"]["copyCurrentSubtitle"];
startPendingMultiCopy: startPendingMultiCopy: CliCommandDepsRuntimeOptions["mining"]["startPendingMultiCopy"];
CliCommandDepsRuntimeOptions["mining"]["startPendingMultiCopy"];
mineSentenceCard: CliCommandDepsRuntimeOptions["mining"]["mineSentenceCard"]; mineSentenceCard: CliCommandDepsRuntimeOptions["mining"]["mineSentenceCard"];
startPendingMineSentenceMultiple: startPendingMineSentenceMultiple: CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"]; updateLastCardFromClipboard: CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
updateLastCardFromClipboard:
CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
refreshKnownWords: CliCommandDepsRuntimeOptions["mining"]["refreshKnownWords"]; refreshKnownWords: CliCommandDepsRuntimeOptions["mining"]["refreshKnownWords"];
triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"]; triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"];
triggerSubsyncFromConfig: triggerSubsyncFromConfig: CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"]; markLastCardAsAudioCard: CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"];
markLastCardAsAudioCard:
CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"];
}; };
anilist: { anilist: {
getStatus: CliCommandDepsRuntimeOptions["anilist"]["getStatus"]; getStatus: CliCommandDepsRuntimeOptions["anilist"]["getStatus"];
@@ -166,11 +165,14 @@ export interface CliCommandRuntimeServiceDepsParams {
getQueueStatus: CliCommandDepsRuntimeOptions["anilist"]["getQueueStatus"]; getQueueStatus: CliCommandDepsRuntimeOptions["anilist"]["getQueueStatus"];
retryQueueNow: CliCommandDepsRuntimeOptions["anilist"]["retryQueueNow"]; retryQueueNow: CliCommandDepsRuntimeOptions["anilist"]["retryQueueNow"];
}; };
jellyfin: {
openSetup: CliCommandDepsRuntimeOptions["jellyfin"]["openSetup"];
runCommand: CliCommandDepsRuntimeOptions["jellyfin"]["runCommand"];
};
ui: { ui: {
openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"]; openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"]; cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"];
openRuntimeOptionsPalette: openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions["ui"]["openRuntimeOptionsPalette"];
CliCommandDepsRuntimeOptions["ui"]["openRuntimeOptionsPalette"];
printHelp: CliCommandDepsRuntimeOptions["ui"]["printHelp"]; printHelp: CliCommandDepsRuntimeOptions["ui"]["printHelp"];
}; };
app: { app: {
@@ -293,7 +295,8 @@ export function createCliCommandRuntimeServiceDeps(
copyCurrentSubtitle: params.mining.copyCurrentSubtitle, copyCurrentSubtitle: params.mining.copyCurrentSubtitle,
startPendingMultiCopy: params.mining.startPendingMultiCopy, startPendingMultiCopy: params.mining.startPendingMultiCopy,
mineSentenceCard: params.mining.mineSentenceCard, mineSentenceCard: params.mining.mineSentenceCard,
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple, startPendingMineSentenceMultiple:
params.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard, updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
refreshKnownWords: params.mining.refreshKnownWords, refreshKnownWords: params.mining.refreshKnownWords,
triggerFieldGrouping: params.mining.triggerFieldGrouping, triggerFieldGrouping: params.mining.triggerFieldGrouping,
@@ -307,6 +310,10 @@ export function createCliCommandRuntimeServiceDeps(
getQueueStatus: params.anilist.getQueueStatus, getQueueStatus: params.anilist.getQueueStatus,
retryQueueNow: params.anilist.retryQueueNow, retryQueueNow: params.anilist.retryQueueNow,
}, },
jellyfin: {
openSetup: params.jellyfin.openSetup,
runCommand: params.jellyfin.runCommand,
},
ui: { ui: {
openYomitanSettings: params.ui.openYomitanSettings, openYomitanSettings: params.ui.openYomitanSettings,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode, cycleSecondarySubMode: params.ui.cycleSecondarySubMode,

View File

@@ -14,6 +14,7 @@ import type { SubtitleTimingTracker } from "../subtitle-timing-tracker";
import type { AnkiIntegration } from "../anki-integration"; import type { AnkiIntegration } from "../anki-integration";
import type { ImmersionTrackerService } from "../core/services"; import type { ImmersionTrackerService } from "../core/services";
import type { MpvIpcClient } from "../core/services"; import type { MpvIpcClient } from "../core/services";
import type { JellyfinRemoteSessionService } from "../core/services";
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services"; import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services";
import type { RuntimeOptionsManager } from "../runtime-options"; import type { RuntimeOptionsManager } from "../runtime-options";
import type { MecabTokenizer } from "../mecab-tokenizer"; import type { MecabTokenizer } from "../mecab-tokenizer";
@@ -40,9 +41,11 @@ export interface AppState {
yomitanSettingsWindow: BrowserWindow | null; yomitanSettingsWindow: BrowserWindow | null;
yomitanParserWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null;
jellyfinSetupWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null; yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null; yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null; mpvClient: MpvIpcClient | null;
jellyfinRemoteSession: JellyfinRemoteSessionService | null;
reconnectTimer: ReturnType<typeof setTimeout> | null; reconnectTimer: ReturnType<typeof setTimeout> | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
@@ -104,9 +107,11 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanSettingsWindow: null, yomitanSettingsWindow: null,
yomitanParserWindow: null, yomitanParserWindow: null,
anilistSetupWindow: null, anilistSetupWindow: null,
jellyfinSetupWindow: null,
yomitanParserReadyPromise: null, yomitanParserReadyPromise: null,
yomitanParserInitPromise: null, yomitanParserInitPromise: null,
mpvClient: null, mpvClient: null,
jellyfinRemoteSession: null,
reconnectTimer: null, reconnectTimer: null,
currentSubText: "", currentSubText: "",
currentSubAssText: "", currentSubAssText: "",

View File

@@ -270,7 +270,9 @@ const electronAPI: ElectronAPI = {
callback(); callback();
}); });
}, },
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => { notifyOverlayModalClosed: (
modal: "runtime-options" | "subsync" | "jimaku",
) => {
ipcRenderer.send("overlay:modal-closed", modal); ipcRenderer.send("overlay:modal-closed", modal);
}, },
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {

View File

@@ -24,14 +24,18 @@ export function createRuntimeOptionsModal(
ctx.dom.runtimeOptionsStatus.classList.toggle("error", isError); ctx.dom.runtimeOptionsStatus.classList.toggle("error", isError);
} }
function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue { function getRuntimeOptionDisplayValue(
option: RuntimeOptionState,
): RuntimeOptionValue {
return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value; return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value;
} }
function getSelectedRuntimeOption(): RuntimeOptionState | null { function getSelectedRuntimeOption(): RuntimeOptionState | null {
if (ctx.state.runtimeOptions.length === 0) return null; if (ctx.state.runtimeOptions.length === 0) return null;
if (ctx.state.runtimeOptionSelectedIndex < 0) return null; if (ctx.state.runtimeOptionSelectedIndex < 0) return null;
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) { if (
ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length
) {
return null; return null;
} }
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex]; return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
@@ -42,7 +46,10 @@ export function createRuntimeOptionsModal(
ctx.state.runtimeOptions.forEach((option, index) => { ctx.state.runtimeOptions.forEach((option, index) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "runtime-options-item"; li.className = "runtime-options-item";
li.classList.toggle("active", index === ctx.state.runtimeOptionSelectedIndex); li.classList.toggle(
"active",
index === ctx.state.runtimeOptionSelectedIndex,
);
const label = document.createElement("div"); const label = document.createElement("div");
label.className = "runtime-options-label"; label.className = "runtime-options-label";
@@ -113,14 +120,20 @@ export function createRuntimeOptionsModal(
if (!option || option.allowedValues.length === 0) return; if (!option || option.allowedValues.length === 0) return;
const currentValue = getRuntimeOptionDisplayValue(option); const currentValue = getRuntimeOptionDisplayValue(option);
const currentIndex = option.allowedValues.findIndex((value) => value === currentValue); const currentIndex = option.allowedValues.findIndex(
(value) => value === currentValue,
);
const safeIndex = currentIndex >= 0 ? currentIndex : 0; const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = const nextIndex =
direction === 1 direction === 1
? (safeIndex + 1) % option.allowedValues.length ? (safeIndex + 1) % option.allowedValues.length
: (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length; : (safeIndex - 1 + option.allowedValues.length) %
option.allowedValues.length;
ctx.state.runtimeOptionDraftValues.set(option.id, option.allowedValues[nextIndex]); ctx.state.runtimeOptionDraftValues.set(
option.id,
option.allowedValues[nextIndex],
);
renderRuntimeOptionsList(); renderRuntimeOptionsList();
setRuntimeOptionsStatus( setRuntimeOptionsStatus(
`Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`, `Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`,
@@ -140,7 +153,10 @@ export function createRuntimeOptionsModal(
} }
if (result.option) { if (result.option) {
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value); ctx.state.runtimeOptionDraftValues.set(
result.option.id,
result.option.value,
);
} }
const latest = await window.electronAPI.getRuntimeOptions(); const latest = await window.electronAPI.getRuntimeOptions();
@@ -160,7 +176,10 @@ export function createRuntimeOptionsModal(
setRuntimeOptionsStatus(""); setRuntimeOptionsStatus("");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
} }
} }

View File

@@ -19,7 +19,10 @@ type SessionHelpSection = {
title: string; title: string;
rows: SessionHelpItem[]; rows: SessionHelpItem[];
}; };
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, "multiCopyTimeoutMs">; type RuntimeShortcutConfig = Omit<
Required<ShortcutsConfig>,
"multiCopyTimeoutMs"
>;
const HEX_COLOR_RE = const HEX_COLOR_RE =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
@@ -84,7 +87,10 @@ const OVERLAY_SHORTCUTS: Array<{
}> = [ }> = [
{ key: "copySubtitle", label: "Copy subtitle" }, { key: "copySubtitle", label: "Copy subtitle" },
{ key: "copySubtitleMultiple", label: "Copy subtitle (multi)" }, { key: "copySubtitleMultiple", label: "Copy subtitle (multi)" },
{ key: "updateLastCardFromClipboard", label: "Update last card from clipboard" }, {
key: "updateLastCardFromClipboard",
label: "Update last card from clipboard",
},
{ key: "triggerFieldGrouping", label: "Trigger field grouping" }, { key: "triggerFieldGrouping", label: "Trigger field grouping" },
{ key: "triggerSubsync", label: "Open subtitle sync controls" }, { key: "triggerSubsync", label: "Open subtitle sync controls" },
{ key: "mineSentence", label: "Mine sentence" }, { key: "mineSentence", label: "Mine sentence" },
@@ -128,10 +134,14 @@ function describeCommand(command: (string | number)[]): string {
if (first === "sub-seek" && typeof command[1] === "number") { if (first === "sub-seek" && typeof command[1] === "number") {
return `Shift subtitle by ${command[1]} ms`; return `Shift subtitle by ${command[1]} ms`;
} }
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return "Open subtitle sync controls"; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER)
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return "Open runtime options"; return "Open subtitle sync controls";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return "Replay current subtitle"; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN)
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return "Play next subtitle"; return "Open runtime options";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE)
return "Replay current subtitle";
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE)
return "Play next subtitle";
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(":"); const [, rawId, rawDirection] = first.split(":");
return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`; return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`;
@@ -154,7 +164,11 @@ function sectionForCommand(command: (string | number)[]): string {
return "Playback and navigation"; return "Playback and navigation";
} }
if (first === "show-text" || first === "show-progress" || first.startsWith("osd")) { if (
first === "show-text" ||
first === "show-progress" ||
first.startsWith("osd")
) {
return "Visual feedback"; return "Visual feedback";
} }
@@ -221,38 +235,80 @@ function buildColorSection(style: {
rows: [ rows: [
{ {
shortcut: "Known words", shortcut: "Known words",
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), action: normalizeColor(
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
color: normalizeColor(
style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
}, },
{ {
shortcut: "N+1 words", shortcut: "N+1 words",
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), action: normalizeColor(
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
color: normalizeColor(
style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
}, },
{ {
shortcut: "JLPT N1", shortcut: "JLPT N1",
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), action: normalizeColor(
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
color: normalizeColor(
style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
}, },
{ {
shortcut: "JLPT N2", shortcut: "JLPT N2",
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), action: normalizeColor(
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
color: normalizeColor(
style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
}, },
{ {
shortcut: "JLPT N3", shortcut: "JLPT N3",
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), action: normalizeColor(
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
color: normalizeColor(
style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
}, },
{ {
shortcut: "JLPT N4", shortcut: "JLPT N4",
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), action: normalizeColor(
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
color: normalizeColor(
style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
}, },
{ {
shortcut: "JLPT N5", shortcut: "JLPT N5",
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), action: normalizeColor(
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
color: normalizeColor(
style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
}, },
], ],
}; };
@@ -423,8 +479,7 @@ export function createSessionHelpModal(
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean { function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
return ( return (
target instanceof Element && target instanceof Element && ctx.dom.sessionHelpModal.contains(target)
ctx.dom.sessionHelpModal.contains(target)
); );
} }
@@ -493,7 +548,9 @@ export function createSessionHelpModal(
}); });
if (getItems().length === 0) { if (getItems().length === 0) {
ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results"); ctx.dom.sessionHelpContent.classList.add(
"session-help-content-no-results",
);
ctx.dom.sessionHelpContent.textContent = helpFilterValue ctx.dom.sessionHelpContent.textContent = helpFilterValue
? "No matching shortcuts found." ? "No matching shortcuts found."
: "No active session shortcuts found."; : "No active session shortcuts found.";
@@ -501,7 +558,9 @@ export function createSessionHelpModal(
return; return;
} }
ctx.dom.sessionHelpContent.classList.remove("session-help-content-no-results"); ctx.dom.sessionHelpContent.classList.remove(
"session-help-content-no-results",
);
if (isFilterInputFocused()) return; if (isFilterInputFocused()) return;
@@ -519,14 +578,23 @@ export function createSessionHelpModal(
requestOverlayFocus(); requestOverlayFocus();
enforceModalFocus(); enforceModalFocus();
}; };
ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard); ctx.dom.sessionHelpModal.addEventListener(
"pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard); ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
} }
function removePointerFocusListener(): void { function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return; if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard); ctx.dom.sessionHelpModal.removeEventListener(
ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard); "pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.removeEventListener(
"click",
modalPointerFocusGuard,
);
modalPointerFocusGuard = null; modalPointerFocusGuard = null;
} }
@@ -593,7 +661,9 @@ export function createSessionHelpModal(
} }
} }
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> { async function openSessionHelpModal(
opening: SessionHelpBindingInfo,
): Promise<void> {
openBinding = opening; openBinding = opening;
priorFocus = document.activeElement; priorFocus = document.activeElement;
@@ -604,7 +674,8 @@ export function createSessionHelpModal(
ctx.dom.sessionHelpWarning.textContent = ctx.dom.sessionHelpWarning.textContent =
"Both Y-H and Y-K are bound; Y-K remains the fallback for this session."; "Both Y-H and Y-K are bound; Y-K remains the fallback for this session.";
} else if (openBinding.fallbackUsed) { } else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = "Y-H is already bound; using Y-K as fallback."; ctx.dom.sessionHelpWarning.textContent =
"Y-H is already bound; using Y-K as fallback.";
} else { } else {
ctx.dom.sessionHelpWarning.textContent = ""; ctx.dom.sessionHelpWarning.textContent = "";
} }
@@ -655,7 +726,10 @@ export function createSessionHelpModal(
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add("hidden"); ctx.dom.sessionHelpModal.classList.add("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "true"); ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "true");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
} }
@@ -676,7 +750,10 @@ export function createSessionHelpModal(
ctx.dom.overlay.focus({ preventScroll: true }); ctx.dom.overlay.focus({ preventScroll: true });
} }
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else { } else {
window.electronAPI.setIgnoreMouseEvents(false); window.electronAPI.setIgnoreMouseEvents(false);
@@ -716,13 +793,7 @@ export function createSessionHelpModal(
const items = getItems(); const items = getItems();
if (items.length === 0) return true; if (items.length === 0) return true;
if ( if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.key === "/" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault(); e.preventDefault();
focusFilterInput(); focusFilterInput();
return true; return true;
@@ -730,21 +801,13 @@ export function createSessionHelpModal(
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if ( if (key === "arrowdown" || key === "j" || key === "l") {
key === "arrowdown" ||
key === "j" ||
key === "l"
) {
e.preventDefault(); e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex + 1); setSelected(ctx.state.sessionHelpSelectedIndex + 1);
return true; return true;
} }
if ( if (key === "arrowup" || key === "k" || key === "h") {
key === "arrowup" ||
key === "k" ||
key === "h"
) {
e.preventDefault(); e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex - 1); setSelected(ctx.state.sessionHelpSelectedIndex - 1);
return true; return true;
@@ -759,14 +822,19 @@ export function createSessionHelpModal(
applyFilterAndRender(); applyFilterAndRender();
}); });
ctx.dom.sessionHelpFilter.addEventListener("keydown", (event: KeyboardEvent) => { ctx.dom.sessionHelpFilter.addEventListener(
"keydown",
(event: KeyboardEvent) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
focusFallbackTarget(); focusFallbackTarget();
} }
}); },
);
ctx.dom.sessionHelpContent.addEventListener("click", (event: MouseEvent) => { ctx.dom.sessionHelpContent.addEventListener(
"click",
(event: MouseEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof Element)) return; if (!(target instanceof Element)) return;
const row = target.closest(".session-help-item") as HTMLElement | null; const row = target.closest(".session-help-item") as HTMLElement | null;
@@ -774,7 +842,8 @@ export function createSessionHelpModal(
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10); const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return; if (!Number.isFinite(index)) return;
setSelected(index); setSelected(index);
}); },
);
ctx.dom.sessionHelpClose.addEventListener("click", () => { ctx.dom.sessionHelpClose.addEventListener("click", () => {
closeSessionHelpModal(); closeSessionHelpModal();

View File

@@ -43,7 +43,11 @@ function getPathValue(source: Record<string, unknown>, path: string): unknown {
return current; return current;
} }
function setPathValue(target: Record<string, unknown>, path: string, value: unknown): void { function setPathValue(
target: Record<string, unknown>,
path: string,
value: unknown,
): void {
const parts = path.split("."); const parts = path.split(".");
let current = target; let current = target;
for (let i = 0; i < parts.length; i += 1) { for (let i = 0; i < parts.length; i += 1) {
@@ -62,7 +66,9 @@ function setPathValue(target: Record<string, unknown>, path: string, value: unkn
} }
} }
function allowedValues(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue[] { function allowedValues(
definition: RuntimeOptionRegistryEntry,
): RuntimeOptionValue[] {
return [...definition.allowedValues]; return [...definition.allowedValues];
} }
@@ -81,7 +87,10 @@ export class RuntimeOptionsManager {
private readonly applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void; private readonly applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void; private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void;
private runtimeOverrides: RuntimeOverrides = {}; private runtimeOverrides: RuntimeOverrides = {};
private readonly definitions = new Map<RuntimeOptionId, RuntimeOptionRegistryEntry>(); private readonly definitions = new Map<
RuntimeOptionId,
RuntimeOptionRegistryEntry
>();
constructor( constructor(
getAnkiConfig: () => AnkiConnectConfig, getAnkiConfig: () => AnkiConnectConfig,
@@ -98,7 +107,9 @@ export class RuntimeOptionsManager {
} }
} }
private getEffectiveValue(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue { private getEffectiveValue(
definition: RuntimeOptionRegistryEntry,
): RuntimeOptionValue {
const override = getPathValue(this.runtimeOverrides, definition.path); const override = getPathValue(this.runtimeOverrides, definition.path);
if (override !== undefined) return override as RuntimeOptionValue; if (override !== undefined) return override as RuntimeOptionValue;
@@ -135,7 +146,10 @@ export class RuntimeOptionsManager {
return this.getEffectiveValue(definition); return this.getEffectiveValue(definition);
} }
setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult { setOptionValue(
id: RuntimeOptionId,
value: RuntimeOptionValue,
): RuntimeOptionApplyResult {
const definition = this.definitions.get(id); const definition = this.definitions.get(id);
if (!definition) { if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` }; return { ok: false, error: `Unknown runtime option: ${id}` };
@@ -170,7 +184,10 @@ export class RuntimeOptionsManager {
}; };
} }
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult { cycleOption(
id: RuntimeOptionId,
direction: 1 | -1,
): RuntimeOptionApplyResult {
const definition = this.definitions.get(id); const definition = this.definitions.get(id);
if (!definition) { if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` }; return { ok: false, error: `Unknown runtime option: ${id}` };
@@ -191,7 +208,9 @@ export class RuntimeOptionsManager {
return this.setOptionValue(id, values[nextIndex]); return this.setOptionValue(id, values[nextIndex]);
} }
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig { getEffectiveAnkiConnectConfig(
baseConfig?: AnkiConnectConfig,
): AnkiConnectConfig {
const source = baseConfig ?? this.getAnkiConfig(); const source = baseConfig ?? this.getAnkiConfig();
const effective: AnkiConnectConfig = deepClone(source); const effective: AnkiConnectConfig = deepClone(source);
@@ -200,7 +219,11 @@ export class RuntimeOptionsManager {
if (override === undefined) continue; if (override === undefined) continue;
const subPath = definition.path.replace(/^ankiConnect\./, ""); const subPath = definition.path.replace(/^ankiConnect\./, "");
setPathValue(effective as unknown as Record<string, unknown>, subPath, override); setPathValue(
effective as unknown as Record<string, unknown>,
subPath,
override,
);
} }
return effective; return effective;

View File

@@ -338,6 +338,27 @@ export interface AnilistConfig {
accessToken?: string; accessToken?: string;
} }
export interface JellyfinConfig {
enabled?: boolean;
serverUrl?: string;
username?: string;
accessToken?: string;
userId?: string;
deviceId?: string;
clientName?: string;
clientVersion?: string;
defaultLibraryId?: string;
remoteControlEnabled?: boolean;
remoteControlAutoConnect?: boolean;
autoAnnounce?: boolean;
remoteControlDeviceName?: string;
pullPictures?: boolean;
iconCacheDir?: string;
directPlayPreferred?: boolean;
directPlayContainers?: string[];
transcodeVideoCodec?: string;
}
export interface InvisibleOverlayConfig { export interface InvisibleOverlayConfig {
startupVisibility?: "platform-default" | "visible" | "hidden"; startupVisibility?: "platform-default" | "visible" | "hidden";
} }
@@ -354,6 +375,18 @@ export interface YoutubeSubgenConfig {
export interface ImmersionTrackingConfig { export interface ImmersionTrackingConfig {
enabled?: boolean; enabled?: boolean;
dbPath?: string; dbPath?: string;
batchSize?: number;
flushIntervalMs?: number;
queueCap?: number;
payloadCapBytes?: number;
maintenanceIntervalMs?: number;
retention?: {
eventsDays?: number;
telemetryDays?: number;
dailyRollupsDays?: number;
monthlyRollupsDays?: number;
vacuumIntervalDays?: number;
};
} }
export interface Config { export interface Config {
@@ -370,6 +403,7 @@ export interface Config {
bind_visible_overlay_to_mpv_sub_visibility?: boolean; bind_visible_overlay_to_mpv_sub_visibility?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
jellyfin?: JellyfinConfig;
invisibleOverlay?: InvisibleOverlayConfig; invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig; youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig; immersionTracking?: ImmersionTrackingConfig;
@@ -480,6 +514,26 @@ export interface ResolvedConfig {
enabled: boolean; enabled: boolean;
accessToken: string; accessToken: string;
}; };
jellyfin: {
enabled: boolean;
serverUrl: string;
username: string;
accessToken: string;
userId: string;
deviceId: string;
clientName: string;
clientVersion: string;
defaultLibraryId: string;
remoteControlEnabled: boolean;
remoteControlAutoConnect: boolean;
autoAnnounce: boolean;
remoteControlDeviceName: string;
pullPictures: boolean;
iconCacheDir: string;
directPlayPreferred: boolean;
directPlayContainers: string[];
transcodeVideoCodec: string;
};
invisibleOverlay: Required<InvisibleOverlayConfig>; invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & { youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode; mode: YoutubeSubgenMode;
@@ -490,6 +544,18 @@ export interface ResolvedConfig {
immersionTracking: { immersionTracking: {
enabled: boolean; enabled: boolean;
dbPath?: string; dbPath?: string;
batchSize: number;
flushIntervalMs: number;
queueCap: number;
payloadCapBytes: number;
maintenanceIntervalMs: number;
retention: {
eventsDays: number;
telemetryDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
};
}; };
logging: { logging: {
level: "debug" | "info" | "warn" | "error"; level: "debug" | "info" | "warn" | "error";
@@ -719,7 +785,9 @@ export interface ElectronAPI {
) => void; ) => void;
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => void; notifyOverlayModalClosed: (
modal: "runtime-options" | "subsync" | "jimaku",
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
} }