mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(jellyfin): add remote playback and config plumbing
This commit is contained in:
@@ -262,6 +262,40 @@
|
||||
"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
|
||||
// Enable/disable immersion tracking.
|
||||
@@ -269,6 +303,18 @@
|
||||
// ==========================================
|
||||
"immersionTracking": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,22 +176,6 @@ features:
|
||||
|
||||
<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
|
||||
|
||||
<video controls playsinline preload="metadata" poster="/assets/demo-poster.jpg">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { parse as parseJsonc } from "jsonc-parser";
|
||||
import type {
|
||||
LogLevel, YoutubeSubgenMode, Backend, Args,
|
||||
@@ -17,72 +18,6 @@ import {
|
||||
inferWhisperLanguage,
|
||||
} 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 {
|
||||
const configDir = path.join(os.homedir(), ".config", "SubMiner");
|
||||
const jsoncPath = path.join(configDir, "config.jsonc");
|
||||
@@ -307,6 +242,47 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
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(
|
||||
argv: string[],
|
||||
scriptName: string,
|
||||
@@ -362,6 +338,13 @@ export function parseArgs(
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
jellyfinServer: "",
|
||||
jellyfinUsername: "",
|
||||
jellyfinPassword: "",
|
||||
@@ -388,307 +371,277 @@ export function parseArgs(
|
||||
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
||||
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
||||
|
||||
const isValidLogLevel = (value: string): value is LogLevel =>
|
||||
value === "debug" ||
|
||||
value === "info" ||
|
||||
value === "warn" ||
|
||||
value === "error";
|
||||
const isValidYoutubeSubgenMode = (value: string): value is YoutubeSubgenMode =>
|
||||
value === "automatic" || value === "preprocess" || value === "off";
|
||||
let jellyfinInvocation:
|
||||
| {
|
||||
action?: string;
|
||||
discovery?: boolean;
|
||||
play?: boolean;
|
||||
login?: boolean;
|
||||
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;
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i];
|
||||
const program = new Command();
|
||||
program
|
||||
.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") {
|
||||
const value = argv[i + 1];
|
||||
if (!value) fail("--backend requires a value");
|
||||
if (!["auto", "hyprland", "x11", "macos"].includes(value)) {
|
||||
fail(
|
||||
`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`,
|
||||
);
|
||||
}
|
||||
parsed.backend = value as Backend;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
program
|
||||
.command("jellyfin")
|
||||
.alias("jf")
|
||||
.description("Jellyfin workflows")
|
||||
.argument("[action]", "setup|discovery|play|login|logout")
|
||||
.option("-d, --discovery", "Cast discovery mode")
|
||||
.option("-p, --play", "Interactive play picker")
|
||||
.option("-l, --login", "Login flow")
|
||||
.option("--logout", "Clear token/session")
|
||||
.option("--setup", "Open setup window")
|
||||
.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") {
|
||||
const value = argv[i + 1];
|
||||
if (!value) fail("--directory requires a value");
|
||||
parsed.directory = value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
program
|
||||
.command("yt")
|
||||
.alias("youtube")
|
||||
.description("YouTube workflows")
|
||||
.argument("[target]", "YouTube URL or ytsearch: query")
|
||||
.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") {
|
||||
parsed.recursive = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Run dependency and environment checks")
|
||||
.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") {
|
||||
const value = argv[i + 1];
|
||||
if (!value) fail("--profile requires a value");
|
||||
parsed.profile = value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
program
|
||||
.command("config")
|
||||
.description("Config helpers")
|
||||
.argument("[action]", "path|show", "path")
|
||||
.option("--log-level <level>", "Log level")
|
||||
.action((action: string, options: Record<string, unknown>) => {
|
||||
configInvocation = {
|
||||
action,
|
||||
logLevel:
|
||||
typeof options.logLevel === "string" ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (arg === "--start") {
|
||||
parsed.startOverlay = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
program
|
||||
.command("mpv")
|
||||
.description("MPV helpers")
|
||||
.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") {
|
||||
const value = (argv[i + 1] || "").toLowerCase();
|
||||
if (!isValidYoutubeSubgenMode(value)) {
|
||||
fail(
|
||||
`Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
|
||||
);
|
||||
}
|
||||
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") {
|
||||
program
|
||||
.command("texthooker")
|
||||
.description("Launch texthooker-only mode")
|
||||
.option("--log-level <level>", "Log level")
|
||||
.action((options: Record<string, unknown>) => {
|
||||
parsed.texthookerOnly = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
texthookerLogLevel =
|
||||
typeof options.logLevel === "string" ? options.logLevel : null;
|
||||
});
|
||||
|
||||
if (arg === "--jellyfin") {
|
||||
parsed.jellyfin = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
try {
|
||||
program.parse(["node", scriptName, ...argv]);
|
||||
} catch (error) {
|
||||
const commanderError = error as { code?: string; message?: string };
|
||||
if (commanderError?.code === "commander.helpDisplayed") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === "--") {
|
||||
i += 1;
|
||||
break;
|
||||
fail(commanderError?.message || String(error));
|
||||
}
|
||||
|
||||
if (arg.startsWith("-")) {
|
||||
fail(`Unknown option: ${arg}`);
|
||||
const options = program.opts<Record<string, unknown>>();
|
||||
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);
|
||||
if (positional.length > 0) {
|
||||
const target = positional[0];
|
||||
if (isUrlTarget(target)) {
|
||||
parsed.target = target;
|
||||
parsed.targetKind = "url";
|
||||
} else {
|
||||
const resolved = resolvePathMaybe(target);
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
||||
parsed.target = resolved;
|
||||
parsed.targetKind = "file";
|
||||
} else if (
|
||||
fs.existsSync(resolved) &&
|
||||
fs.statSync(resolved).isDirectory()
|
||||
) {
|
||||
parsed.directory = resolved;
|
||||
} else {
|
||||
fail(`Not a file, directory, or supported URL: ${target}`);
|
||||
parsed.jellyfinServer = jellyfinInvocation.server || "";
|
||||
parsed.jellyfinUsername = jellyfinInvocation.username || "";
|
||||
parsed.jellyfinPassword = jellyfinInvocation.password || "";
|
||||
|
||||
const modeFlags = {
|
||||
setup: jellyfinInvocation.setup || action === "setup",
|
||||
discovery: jellyfinInvocation.discovery || action === "discovery",
|
||||
play: jellyfinInvocation.play || action === "play",
|
||||
login: jellyfinInvocation.login || action === "login",
|
||||
logout: jellyfinInvocation.logout || action === "logout",
|
||||
};
|
||||
if (!modeFlags.setup && !modeFlags.discovery && !modeFlags.play && !modeFlags.login && !modeFlags.logout) {
|
||||
modeFlags.setup = true;
|
||||
}
|
||||
|
||||
parsed.jellyfin = Boolean(modeFlags.setup);
|
||||
parsed.jellyfinDiscovery = Boolean(modeFlags.discovery);
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
|
||||
import { log, fail } from "./log.js";
|
||||
@@ -115,6 +115,39 @@ export async function resolveJellyfinSelection(
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): 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>> }>(
|
||||
session,
|
||||
`/Users/${session.userId}/Views`,
|
||||
@@ -194,6 +227,7 @@ export async function resolveJellyfinSelection(
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
|
||||
let contentParentId = libraryId;
|
||||
let contentRecursive = true;
|
||||
const selectedGroupId = pickGroup(
|
||||
session,
|
||||
groups,
|
||||
@@ -222,6 +256,7 @@ export async function resolveJellyfinSelection(
|
||||
})
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
if (seasons.length > 0) {
|
||||
const seasonsById = new Map(seasons.map((entry) => [entry.id, entry]));
|
||||
const selectedSeasonId = pickGroup(
|
||||
session,
|
||||
seasons,
|
||||
@@ -232,6 +267,10 @@ export async function resolveJellyfinSelection(
|
||||
);
|
||||
if (!selectedSeasonId) fail("No Jellyfin season selected.");
|
||||
contentParentId = selectedSeasonId;
|
||||
const selectedSeason = seasonsById.get(selectedSeasonId);
|
||||
if (selectedSeason?.type === "Season") {
|
||||
contentRecursive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +280,7 @@ export async function resolveJellyfinSelection(
|
||||
TotalRecordCount?: number;
|
||||
}>(
|
||||
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>> = [];
|
||||
@@ -259,7 +298,8 @@ export async function resolveJellyfinSelection(
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
|
||||
let items: JellyfinItemEntry[] = allEntries
|
||||
let items: JellyfinItemEntry[] = sortEntries(
|
||||
allEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
return type === "Movie" || type === "Episode" || type === "Audio";
|
||||
@@ -268,12 +308,21 @@ export async function resolveJellyfinSelection(
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name: typeof item.Name === "string" ? item.Name : "",
|
||||
type: typeof item.Type === "string" ? item.Type : "Item",
|
||||
parentIndex: asNumberOrNull(item.ParentIndexNumber),
|
||||
index: asNumberOrNull(item.IndexNumber),
|
||||
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) {
|
||||
items = allEntries
|
||||
items = sortEntries(
|
||||
allEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
if (type === "Folder" || type === "CollectionFolder") return false;
|
||||
@@ -292,9 +341,17 @@ export async function resolveJellyfinSelection(
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name: typeof item.Name === "string" ? item.Name : "",
|
||||
type: typeof item.Type === "string" ? item.Type : "Item",
|
||||
parentIndex: asNumberOrNull(item.ParentIndexNumber),
|
||||
index: asNumberOrNull(item.IndexNumber),
|
||||
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);
|
||||
@@ -335,19 +392,23 @@ export async function runJellyfinPlayMenu(
|
||||
|
||||
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||
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}`);
|
||||
launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
const mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
let mpvReady = false;
|
||||
if (fs.existsSync(mpvSocketPath)) {
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
}
|
||||
if (!mpvReady) {
|
||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
}
|
||||
log(
|
||||
"debug",
|
||||
args.logLevel,
|
||||
`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];
|
||||
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play");
|
||||
|
||||
126
launcher/main.ts
126
launcher/main.ts
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { Args } from "./types.js";
|
||||
import { log, fail } from "./log.js";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import { showRofiMenu, showFzfMenu, collectVideos } from "./picker.js";
|
||||
import {
|
||||
state, startMpv, startOverlay, stopOverlay, launchTexthookerOnly,
|
||||
findAppBinary, waitForSocket, loadSubtitleIntoMpv, runAppCommandWithInherit,
|
||||
launchMpvIdleDetached, waitForUnixSocketReady,
|
||||
} from "./mpv.js";
|
||||
import { generateYoutubeSubtitles } from "./youtube.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> {
|
||||
const scriptPath = process.argv[1] || "subminer";
|
||||
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}`);
|
||||
|
||||
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 (process.platform === "darwin") {
|
||||
fail(
|
||||
@@ -118,6 +228,16 @@ async function main(): Promise<void> {
|
||||
}
|
||||
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) {
|
||||
launchTexthookerOnly(appPath, args);
|
||||
}
|
||||
@@ -166,6 +286,12 @@ async function main(): Promise<void> {
|
||||
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) {
|
||||
checkPickerDependencies(args);
|
||||
}
|
||||
|
||||
103
launcher/mpv.ts
103
launcher/mpv.ts
@@ -20,6 +20,93 @@ export const state = {
|
||||
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 {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
@@ -525,6 +612,8 @@ export function stopOverlay(args: Args): void {
|
||||
}
|
||||
}
|
||||
state.youtubeSubgenChildren.clear();
|
||||
|
||||
void terminateTrackedDetachedMpv(args.logLevel);
|
||||
}
|
||||
|
||||
function buildAppEnv(): NodeJS.ProcessEnv {
|
||||
@@ -595,7 +684,15 @@ export function launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
await terminateTrackedDetachedMpv(args.logLevel);
|
||||
try {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
@@ -609,7 +706,11 @@ export function launchMpvIdleDetached(
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
if (typeof proc.pid === "number" && proc.pid > 0) {
|
||||
trackDetachedMpvPid(proc.pid);
|
||||
}
|
||||
proc.unref();
|
||||
})();
|
||||
}
|
||||
|
||||
async function sleepMs(ms: number): Promise<void> {
|
||||
|
||||
@@ -185,11 +185,14 @@ function showRofiIconMenu(
|
||||
if (initialQuery) rofiArgs.push("-filter", initialQuery);
|
||||
if (themePath) {
|
||||
rofiArgs.push("-theme", themePath);
|
||||
rofiArgs.push("-theme-str", "configuration { show-icons: true; }");
|
||||
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
|
||||
} else {
|
||||
rofiArgs.push(
|
||||
"-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) =>
|
||||
@@ -197,11 +200,12 @@ function showRofiIconMenu(
|
||||
? `${entry.label}\u0000icon\u001f${entry.iconPath}`
|
||||
: entry.label
|
||||
);
|
||||
const input = Buffer.from(`${lines.join("\n")}\n`, "utf8");
|
||||
const result = spawnSync(
|
||||
"rofi",
|
||||
rofiArgs,
|
||||
{
|
||||
input: `${lines.join("\n")}\n`,
|
||||
input,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
},
|
||||
|
||||
@@ -48,7 +48,8 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
"--sid=auto",
|
||||
"--secondary-sid=auto",
|
||||
"--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;
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
@@ -88,6 +89,13 @@ export interface Args {
|
||||
jellyfinLogin: boolean;
|
||||
jellyfinLogout: boolean;
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinDiscovery: boolean;
|
||||
doctor: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
mpvSocket: boolean;
|
||||
mpvStatus: boolean;
|
||||
jellyfinServer: string;
|
||||
jellyfinUsername: string;
|
||||
jellyfinPassword: string;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"get-frequency": "bun run scripts/get_frequency.ts",
|
||||
"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": "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 --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: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",
|
||||
@@ -14,7 +14,7 @@
|
||||
"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",
|
||||
"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": "pnpm run test:config && pnpm run test:core",
|
||||
"test:config": "pnpm run build && pnpm run test:config:dist",
|
||||
@@ -46,6 +46,7 @@
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
"axios": "^1.13.5",
|
||||
"commander": "^14.0.3",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"ws": "^8.19.0"
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.13.5
|
||||
version: 1.13.5
|
||||
commander:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
jsonc-parser:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
@@ -1220,6 +1223,10 @@ packages:
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@5.1.0:
|
||||
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3901,6 +3908,8 @@ snapshots:
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@5.1.0: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
|
||||
@@ -13,6 +13,13 @@ test("parseArgs parses booleans and value flags", () => {
|
||||
"--log-level",
|
||||
"warn",
|
||||
"--debug",
|
||||
"--jellyfin-play",
|
||||
"--jellyfin-server",
|
||||
"http://jellyfin.local:8096",
|
||||
"--jellyfin-item-id",
|
||||
"item-123",
|
||||
"--jellyfin-audio-stream-index",
|
||||
"2",
|
||||
]);
|
||||
|
||||
assert.equal(args.start, true);
|
||||
@@ -21,6 +28,10 @@ test("parseArgs parses booleans and value flags", () => {
|
||||
assert.equal(args.texthookerPort, 6000);
|
||||
assert.equal(args.logLevel, "warn");
|
||||
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", () => {
|
||||
@@ -56,4 +67,33 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
|
||||
assert.equal(anilistRetryQueue.anilistRetryQueue, true);
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
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);
|
||||
});
|
||||
|
||||
114
src/cli/args.ts
114
src/cli/args.ts
@@ -26,6 +26,15 @@ export interface CliArgs {
|
||||
anilistLogout: boolean;
|
||||
anilistSetup: boolean;
|
||||
anilistRetryQueue: boolean;
|
||||
jellyfin: boolean;
|
||||
jellyfinLogin: boolean;
|
||||
jellyfinLogout: boolean;
|
||||
jellyfinLibraries: boolean;
|
||||
jellyfinItems: boolean;
|
||||
jellyfinSubtitles: boolean;
|
||||
jellyfinSubtitleUrlsOnly: boolean;
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinRemoteAnnounce: boolean;
|
||||
texthooker: boolean;
|
||||
help: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
@@ -35,6 +44,15 @@ export interface CliArgs {
|
||||
socketPath?: string;
|
||||
backend?: string;
|
||||
texthookerPort?: number;
|
||||
jellyfinServer?: string;
|
||||
jellyfinUsername?: string;
|
||||
jellyfinPassword?: string;
|
||||
jellyfinLibraryId?: string;
|
||||
jellyfinItemId?: string;
|
||||
jellyfinSearch?: string;
|
||||
jellyfinLimit?: number;
|
||||
jellyfinAudioStreamIndex?: number;
|
||||
jellyfinSubtitleStreamIndex?: number;
|
||||
debug: boolean;
|
||||
logLevel?: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
@@ -70,6 +88,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
@@ -105,9 +132,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === "--hide-invisible-overlay")
|
||||
args.hideInvisibleOverlay = 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-multiple") args.mineSentenceMultiple = true;
|
||||
else if (arg === "--mine-sentence-multiple")
|
||||
args.mineSentenceMultiple = true;
|
||||
else if (arg === "--update-last-card-from-clipboard")
|
||||
args.updateLastCardFromClipboard = 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-setup") args.anilistSetup = 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 === "--auto-start-overlay") args.autoStartOverlay = true;
|
||||
else if (arg === "--generate-config") args.generateConfig = true;
|
||||
@@ -171,6 +211,66 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === "--port") {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
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.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.texthooker ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
@@ -229,6 +337,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -20,4 +20,8 @@ test("printHelp includes configured texthooker port", () => {
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
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/);
|
||||
});
|
||||
|
||||
105
src/cli/help.ts
105
src/cli/help.ts
@@ -1,44 +1,79 @@
|
||||
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(`
|
||||
SubMiner CLI commands:
|
||||
--start Start MPV IPC connection and overlay control loop
|
||||
--stop Stop the running overlay app
|
||||
--toggle Toggle visible subtitle overlay visibility (legacy alias)
|
||||
--toggle-visible-overlay Toggle visible subtitle overlay visibility
|
||||
--toggle-invisible-overlay Toggle invisible interactive overlay visibility
|
||||
${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan
|
||||
|
||||
${B}Usage:${R} subminer ${D}[command] [options]${R}
|
||||
|
||||
${B}Session${R}
|
||||
--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
|
||||
--texthooker Launch texthooker only (no overlay window)
|
||||
--show Force show visible overlay (legacy alias)
|
||||
--hide Force hide visible overlay (legacy alias)
|
||||
--show-visible-overlay Force show visible subtitle overlay
|
||||
--hide-visible-overlay Force hide visible subtitle overlay
|
||||
--show-invisible-overlay Force show invisible interactive overlay
|
||||
--hide-invisible-overlay Force hide invisible interactive overlay
|
||||
--copy-subtitle Copy current subtitle text
|
||||
--copy-subtitle-multiple Start multi-copy mode
|
||||
--mine-sentence Mine sentence card from current subtitle
|
||||
--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
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
${B}Mining${R}
|
||||
--mine-sentence Create Anki card from current subtitle
|
||||
--mine-sentence-multiple Select multiple lines, then mine
|
||||
--copy-subtitle Copy current subtitle to clipboard
|
||||
--copy-subtitle-multiple Enter multi-line copy mode
|
||||
--update-last-card-from-clipboard Update last Anki card from clipboard
|
||||
--mark-audio-card Mark last card as audio-only
|
||||
--trigger-field-grouping Run Kiku field grouping
|
||||
--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
|
||||
--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-setup Open AniList setup flow in app/browser
|
||||
--anilist-retry-queue Retry next ready AniList queue item now
|
||||
--auto-start-overlay Auto-hide mpv subtitles on connect (show overlay)
|
||||
--socket PATH Override MPV IPC socket/pipe path
|
||||
--backend BACKEND Override window tracker backend (auto, hyprland, sway, x11, macos)
|
||||
--port PORT Texthooker server port (default: ${defaultTexthookerPort})
|
||||
--debug Enable app/dev mode
|
||||
--log-level LEVEL Set log level: debug, info, warn, error
|
||||
--generate-config Generate default config.jsonc from centralized config registry
|
||||
--config-path PATH Target config path for --generate-config
|
||||
--backup-overwrite With --generate-config, backup and overwrite existing file
|
||||
--dev Alias for --debug (app/dev mode)
|
||||
--anilist-retry-queue Retry next queued update
|
||||
|
||||
${B}Jellyfin${R}
|
||||
--jellyfin Open Jellyfin setup window
|
||||
--jellyfin-login Authenticate and store session token
|
||||
--jellyfin-logout Clear stored session data
|
||||
--jellyfin-libraries List available libraries
|
||||
--jellyfin-items List items from a library
|
||||
--jellyfin-subtitles List subtitle tracks for an item
|
||||
--jellyfin-subtitle-urls Print subtitle download URLs only
|
||||
--jellyfin-play Stream an item in mpv
|
||||
--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
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,22 @@ test("loads defaults when config is missing", () => {
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
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.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", () => {
|
||||
@@ -45,6 +59,90 @@ test("parses anilist.enabled and warns for invalid value", () => {
|
||||
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", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -52,7 +150,19 @@ test("accepts immersion tracking config values", () => {
|
||||
`{
|
||||
"immersionTracking": {
|
||||
"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",
|
||||
@@ -62,7 +172,109 @@ test("accepts immersion tracking config values", () => {
|
||||
const config = service.getConfig();
|
||||
|
||||
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", () => {
|
||||
@@ -117,9 +329,7 @@ test("falls back for invalid logging.level and reports warning", () => {
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === "logging.level"),
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === "logging.level"));
|
||||
});
|
||||
|
||||
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.invisibleOverlay.startupVisibility, "hidden");
|
||||
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", () => {
|
||||
@@ -295,8 +509,8 @@ test("validates ankiConnect n+1 match mode values", () => {
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) =>
|
||||
warning.path === "ankiConnect.nPlusOne.matchMode",
|
||||
warnings.some(
|
||||
(warning) => warning.path === "ankiConnect.nPlusOne.matchMode",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -349,10 +563,14 @@ test("validates ankiConnect n+1 color values", () => {
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne"),
|
||||
warnings.some(
|
||||
(warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.knownWord"),
|
||||
warnings.some(
|
||||
(warning) => warning.path === "ankiConnect.nPlusOne.knownWord",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -201,13 +201,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#f5a97f",
|
||||
bandedColors: [
|
||||
"#ed8796",
|
||||
"#f5a97f",
|
||||
"#f9e2af",
|
||||
"#a6e3a1",
|
||||
"#8aadf4",
|
||||
],
|
||||
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||
},
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
@@ -230,6 +224,26 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
enabled: false,
|
||||
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: {
|
||||
mode: "automatic",
|
||||
whisperBin: "",
|
||||
@@ -241,6 +255,19 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
},
|
||||
immersionTracking: {
|
||||
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",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.enableJlpt,
|
||||
description: "Enable JLPT vocabulary level underlines. "
|
||||
+ "When disabled, JLPT tagging lookup and underlines are skipped.",
|
||||
description:
|
||||
"Enable JLPT vocabulary level underlines. " +
|
||||
"When disabled, JLPT tagging lookup and underlines are skipped.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.enabled",
|
||||
@@ -339,14 +367,15 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.sourcePath,
|
||||
description:
|
||||
"Optional absolute path to a frequency dictionary directory."
|
||||
+ " If empty, built-in discovery search paths are used.",
|
||||
"Optional absolute path to a frequency dictionary directory." +
|
||||
" If empty, built-in discovery search paths are used.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.topX",
|
||||
kind: "number",
|
||||
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",
|
||||
@@ -399,7 +428,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
path: "ankiConnect.nPlusOne.highlightEnabled",
|
||||
kind: "boolean",
|
||||
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",
|
||||
@@ -486,6 +516,89 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
defaultValue: DEFAULT_CONFIG.anilist.accessToken,
|
||||
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",
|
||||
kind: "enum",
|
||||
@@ -497,7 +610,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
path: "youtubeSubgen.whisperBin",
|
||||
kind: "string",
|
||||
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",
|
||||
@@ -525,6 +639,66 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
description:
|
||||
"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[] = [
|
||||
@@ -637,11 +811,20 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: ["Anilist API credentials and update behavior."],
|
||||
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",
|
||||
description: [
|
||||
"Enable/disable immersion tracking.",
|
||||
"Set dbPath to override the default sqlite database location.",
|
||||
"Policy tuning is available for queue, flush, and retention values.",
|
||||
],
|
||||
key: "immersionTracking",
|
||||
},
|
||||
|
||||
@@ -213,7 +213,12 @@ export class ConfigService {
|
||||
|
||||
if (isObject(src.logging)) {
|
||||
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;
|
||||
} else if (src.logging.level !== undefined) {
|
||||
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) {
|
||||
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 =
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
|
||||
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
|
||||
@@ -509,6 +593,191 @@ export class ConfigService {
|
||||
"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)) {
|
||||
@@ -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) {
|
||||
resolved.subtitleStyle.enableJlpt = enableJlpt;
|
||||
} else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) {
|
||||
} else if (
|
||||
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.enableJlpt",
|
||||
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt,
|
||||
@@ -565,7 +838,8 @@ export class ConfigService {
|
||||
if (sourcePath !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
|
||||
} else if (
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !==
|
||||
undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.sourcePath",
|
||||
@@ -576,13 +850,11 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX);
|
||||
if (
|
||||
topX !== undefined &&
|
||||
Number.isInteger(topX) &&
|
||||
topX > 0
|
||||
) {
|
||||
if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
|
||||
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
|
||||
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
|
||||
} else if (
|
||||
(frequencyDictionary as { topX?: unknown }).topX !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.topX",
|
||||
(frequencyDictionary as { topX?: unknown }).topX,
|
||||
@@ -592,10 +864,7 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const frequencyMode = frequencyDictionary.mode;
|
||||
if (
|
||||
frequencyMode === "single" ||
|
||||
frequencyMode === "banded"
|
||||
) {
|
||||
if (frequencyMode === "single" || frequencyMode === "banded") {
|
||||
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
|
||||
} else if (frequencyMode !== undefined) {
|
||||
warn(
|
||||
@@ -612,7 +881,8 @@ export class ConfigService {
|
||||
if (singleColor !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
|
||||
} else if (
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor !==
|
||||
undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.singleColor",
|
||||
@@ -628,7 +898,8 @@ export class ConfigService {
|
||||
if (bandedColors !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
|
||||
} else if (
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !==
|
||||
undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.bandedColors",
|
||||
@@ -649,13 +920,17 @@ export class ConfigService {
|
||||
: isObject(ac.openRouter)
|
||||
? ac.openRouter
|
||||
: {};
|
||||
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
|
||||
ac as Record<string, unknown>;
|
||||
const {
|
||||
nPlusOne: _nPlusOneConfigFromAnkiConnect,
|
||||
...ankiConnectWithoutNPlusOne
|
||||
} = ac as Record<string, unknown>;
|
||||
|
||||
resolved.ankiConnect = {
|
||||
...resolved.ankiConnect,
|
||||
...(isObject(ankiConnectWithoutNPlusOne)
|
||||
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig["ankiConnect"]>)
|
||||
? (ankiConnectWithoutNPlusOne as Partial<
|
||||
ResolvedConfig["ankiConnect"]
|
||||
>)
|
||||
: {}),
|
||||
fields: {
|
||||
...resolved.ankiConnect.fields,
|
||||
@@ -837,8 +1112,7 @@ export class ConfigService {
|
||||
nPlusOneRefreshMinutes > 0;
|
||||
if (nPlusOneRefreshMinutes !== undefined) {
|
||||
if (hasValidNPlusOneRefreshMinutes) {
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
nPlusOneRefreshMinutes;
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes;
|
||||
} else {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.refreshMinutes",
|
||||
@@ -927,8 +1201,7 @@ export class ConfigService {
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
|
||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyMatchMode) {
|
||||
resolved.ankiConnect.nPlusOne.matchMode =
|
||||
legacyNPlusOneMatchMode;
|
||||
resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode;
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneMatchMode",
|
||||
behavior.nPlusOneMatchMode,
|
||||
@@ -958,9 +1231,7 @@ export class ConfigService {
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (normalizedDecks.length === nPlusOneDecks.length) {
|
||||
resolved.ankiConnect.nPlusOne.decks = [
|
||||
...new Set(normalizedDecks),
|
||||
];
|
||||
resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)];
|
||||
} else if (nPlusOneDecks.length > 0) {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.decks",
|
||||
|
||||
@@ -11,11 +11,14 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
|
||||
if (value === null) return "null";
|
||||
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 (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("]");
|
||||
}
|
||||
|
||||
@@ -25,7 +28,8 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
);
|
||||
if (entries.length === 0) return "{}";
|
||||
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("}");
|
||||
}
|
||||
@@ -45,23 +49,33 @@ function renderSection(
|
||||
lines.push(` // ${comment}`);
|
||||
}
|
||||
lines.push(" // ==========================================");
|
||||
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`);
|
||||
lines.push(
|
||||
` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`,
|
||||
);
|
||||
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[] = [];
|
||||
lines.push("/**");
|
||||
lines.push(" * SubMiner Example Configuration File");
|
||||
lines.push(" *");
|
||||
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("{");
|
||||
|
||||
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
|
||||
lines.push("");
|
||||
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
|
||||
const comments = [
|
||||
section.title,
|
||||
...section.description,
|
||||
...(section.notes ?? []),
|
||||
];
|
||||
lines.push(
|
||||
renderSection(
|
||||
section.key,
|
||||
|
||||
@@ -32,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
@@ -147,6 +156,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openAnilistSetup: () => {
|
||||
calls.push("openAnilistSetup");
|
||||
},
|
||||
openJellyfinSetup: () => {
|
||||
calls.push("openJellyfinSetup");
|
||||
},
|
||||
getAnilistQueueStatus: () => ({
|
||||
pending: 2,
|
||||
ready: 1,
|
||||
@@ -158,6 +170,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
calls.push("retryAnilistQueue");
|
||||
return { ok: true, message: "AniList retry processed." };
|
||||
},
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push("runJellyfinCommand");
|
||||
},
|
||||
printHelp: () => {
|
||||
calls.push("printHelp");
|
||||
},
|
||||
@@ -187,8 +202,13 @@ test("handleCliCommand ignores --start for second-instance without actions", ()
|
||||
|
||||
handleCliCommand(args, "second-instance", deps);
|
||||
|
||||
assert.ok(calls.includes("log:Ignoring --start because SubMiner is already running."));
|
||||
assert.equal(calls.some((value) => value.includes("connectMpvClient")), false);
|
||||
assert.ok(
|
||||
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", () => {
|
||||
@@ -198,9 +218,7 @@ test("handleCliCommand runs texthooker flow with browser open", () => {
|
||||
handleCliCommand(args, "initial", deps);
|
||||
|
||||
assert.ok(calls.includes("ensureTexthookerRunning:5174"));
|
||||
assert.ok(
|
||||
calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"),
|
||||
);
|
||||
assert.ok(calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"));
|
||||
});
|
||||
|
||||
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);
|
||||
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")));
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
);
|
||||
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", () => {
|
||||
@@ -272,9 +295,13 @@ test("handleCliCommand reports async trigger-subsync errors to OSD", async () =>
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
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", () => {
|
||||
@@ -292,7 +319,10 @@ test("handleCliCommand still runs non-start actions on second-instance", () => {
|
||||
deps,
|
||||
);
|
||||
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", () => {
|
||||
@@ -300,22 +330,44 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
|
||||
args: Partial<CliArgs>;
|
||||
expected: string;
|
||||
}> = [
|
||||
{ args: { toggleInvisibleOverlay: true }, expected: "toggleInvisibleOverlay" },
|
||||
{
|
||||
args: { toggleInvisibleOverlay: true },
|
||||
expected: "toggleInvisibleOverlay",
|
||||
},
|
||||
{ args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" },
|
||||
{ args: { showVisibleOverlay: true }, expected: "setVisibleOverlayVisible:true" },
|
||||
{ args: { hideVisibleOverlay: true }, expected: "setVisibleOverlayVisible:false" },
|
||||
{ args: { showInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:true" },
|
||||
{ args: { hideInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:false" },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
expected: "setVisibleOverlayVisible:true",
|
||||
},
|
||||
{
|
||||
args: { hideVisibleOverlay: true },
|
||||
expected: "setVisibleOverlayVisible:false",
|
||||
},
|
||||
{
|
||||
args: { showInvisibleOverlay: true },
|
||||
expected: "setInvisibleOverlayVisible:true",
|
||||
},
|
||||
{
|
||||
args: { hideInvisibleOverlay: true },
|
||||
expected: "setInvisibleOverlayVisible:false",
|
||||
},
|
||||
{ args: { copySubtitle: true }, expected: "copyCurrentSubtitle" },
|
||||
{ args: { copySubtitleMultiple: true }, expected: "startPendingMultiCopy:2500" },
|
||||
{
|
||||
args: { copySubtitleMultiple: true },
|
||||
expected: "startPendingMultiCopy:2500",
|
||||
},
|
||||
{
|
||||
args: { mineSentenceMultiple: true },
|
||||
expected: "startPendingMineSentenceMultiple:2500",
|
||||
},
|
||||
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
|
||||
{ args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" },
|
||||
{
|
||||
args: { openRuntimeOptions: true },
|
||||
expected: "openRuntimeOptionsPalette",
|
||||
},
|
||||
{ args: { anilistLogout: true }, expected: "clearAnilistToken" },
|
||||
{ args: { anilistSetup: true }, expected: "openAnilistSetup" },
|
||||
{ args: { jellyfin: true }, expected: "openJellyfinSetup" },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
@@ -331,7 +383,9 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
|
||||
test("handleCliCommand logs AniList status details", () => {
|
||||
const { deps, calls } = createDeps();
|
||||
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:")));
|
||||
});
|
||||
|
||||
@@ -342,6 +396,57 @@ test("handleCliCommand runs AniList retry command", async () => {
|
||||
assert.ok(calls.includes("retryAnilistQueue"));
|
||||
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", () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
@@ -363,5 +468,9 @@ test("handleCliCommand reports async refresh-known-words errors to OSD", async (
|
||||
assert.ok(
|
||||
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"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface CliCommandServiceDeps {
|
||||
};
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
getAnilistQueueStatus: () => {
|
||||
pending: number;
|
||||
ready: number;
|
||||
@@ -57,6 +58,7 @@ export interface CliCommandServiceDeps {
|
||||
lastError: string | null;
|
||||
};
|
||||
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -138,6 +140,10 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
overlay: OverlayCliRuntime;
|
||||
mining: MiningCliRuntime;
|
||||
anilist: AnilistCliRuntime;
|
||||
jellyfin: {
|
||||
openSetup: () => void;
|
||||
runCommand: (args: CliArgs) => Promise<void>;
|
||||
};
|
||||
ui: UiCliRuntime;
|
||||
app: AppCliRuntime;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -201,8 +207,10 @@ export function createCliCommandDepsRuntime(
|
||||
getAnilistStatus: options.anilist.getStatus,
|
||||
clearAnilistToken: options.anilist.clearToken,
|
||||
openAnilistSetup: options.anilist.openSetup,
|
||||
openJellyfinSetup: options.jellyfin.openSetup,
|
||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
@@ -262,9 +270,18 @@ export function handleCliCommand(
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.texthooker ||
|
||||
args.help;
|
||||
const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction;
|
||||
const ignoreStartOnly =
|
||||
source === "second-instance" && args.start && !hasNonStartAction;
|
||||
if (ignoreStartOnly) {
|
||||
deps.log("Ignoring --start because SubMiner is already running.");
|
||||
return;
|
||||
@@ -402,6 +419,9 @@ export function handleCliCommand(
|
||||
} else if (args.anilistSetup) {
|
||||
deps.openAnilistSetup();
|
||||
deps.log("Opened AniList setup flow.");
|
||||
} else if (args.jellyfin) {
|
||||
deps.openJellyfinSetup();
|
||||
deps.log("Opened Jellyfin setup flow.");
|
||||
} else if (args.anilistRetryQueue) {
|
||||
const queueStatus = deps.getAnilistQueueStatus();
|
||||
deps.log(
|
||||
@@ -417,6 +437,21 @@ export function handleCliCommand(
|
||||
"retryAnilistQueue",
|
||||
"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) {
|
||||
const texthookerPort = deps.getTexthookerPort();
|
||||
deps.ensureTexthookerRunning(texthookerPort);
|
||||
|
||||
@@ -20,10 +20,11 @@ export {
|
||||
triggerFieldGrouping,
|
||||
updateLastCardFromClipboard,
|
||||
} from "./mining";
|
||||
export { createAppLifecycleDepsRuntime, startAppLifecycle } from "./app-lifecycle";
|
||||
export {
|
||||
cycleSecondarySubMode,
|
||||
} from "./subtitle-position";
|
||||
createAppLifecycleDepsRuntime,
|
||||
startAppLifecycle,
|
||||
} from "./app-lifecycle";
|
||||
export { cycleSecondarySubMode } from "./subtitle-position";
|
||||
export {
|
||||
getInitialInvisibleOverlayVisibility,
|
||||
isAutoUpdateEnabledRuntime,
|
||||
@@ -92,9 +93,24 @@ export { handleMpvCommandFromIpc } from "./ipc-command";
|
||||
export { createFieldGroupingOverlayRuntime } from "./field-grouping-overlay";
|
||||
export { createNumericShortcutRuntime } from "./numeric-shortcut";
|
||||
export { runStartupBootstrapRuntime } from "./startup";
|
||||
export { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from "./subsync-runner";
|
||||
export {
|
||||
runSubsyncManualFromIpcRuntime,
|
||||
triggerSubsyncFromConfigRuntime,
|
||||
} from "./subsync-runner";
|
||||
export { registerAnkiJimakuIpcRuntime } from "./anki-jimaku";
|
||||
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 {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayManager,
|
||||
|
||||
334
src/core/services/jellyfin-remote.test.ts
Normal file
334
src/core/services/jellyfin-remote.test.ts
Normal 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")));
|
||||
});
|
||||
448
src/core/services/jellyfin-remote.ts
Normal file
448
src/core/services/jellyfin-remote.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
702
src/core/services/jellyfin.test.ts
Normal file
702
src/core/services/jellyfin.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
571
src/core/services/jellyfin.ts
Normal file
571
src/core/services/jellyfin.ts
Normal 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));
|
||||
}
|
||||
@@ -51,7 +51,9 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
return {
|
||||
state,
|
||||
deps: {
|
||||
getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }),
|
||||
getResolvedConfig: () => ({
|
||||
secondarySub: { secondarySubLanguages: ["ja"] },
|
||||
}),
|
||||
getSubtitleMetrics: () => metrics,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
emitSubtitleChange: (payload) => state.events.push(payload),
|
||||
@@ -103,7 +105,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => {
|
||||
const { deps, state } = createDeps();
|
||||
@@ -131,7 +133,9 @@ test("dispatchMpvProtocolMessage sets secondary subtitle track based on track li
|
||||
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 () => {
|
||||
@@ -166,10 +170,9 @@ test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
||||
assert.equal(pendingPauseAtSubEnd, false);
|
||||
assert.equal(pauseAtTime, 42);
|
||||
assert.deepEqual(state.events, [{ text: "字幕", start: 0, end: 0 }]);
|
||||
assert.deepEqual(
|
||||
state.commands[state.commands.length - 1],
|
||||
{ command: ["set_property", "pause", false] },
|
||||
);
|
||||
assert.deepEqual(state.commands[state.commands.length - 1], {
|
||||
command: ["set_property", "pause", false],
|
||||
});
|
||||
});
|
||||
|
||||
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.nextBuffer, "{\"partial\"");
|
||||
assert.equal(parsed.nextBuffer, '{"partial"');
|
||||
assert.equal(parsed.messages[0].event, "shutdown");
|
||||
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", () => {
|
||||
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) });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].line, "{invalid}");
|
||||
|
||||
@@ -35,10 +35,7 @@ export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
|
||||
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
|
||||
|
||||
export type MpvMessageParser = (message: MpvMessage) => void;
|
||||
export type MpvParseErrorHandler = (
|
||||
line: string,
|
||||
error: unknown,
|
||||
) => void;
|
||||
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
|
||||
|
||||
export interface MpvProtocolParseResult {
|
||||
messages: MpvMessage[];
|
||||
@@ -46,12 +43,21 @@ export interface MpvProtocolParseResult {
|
||||
}
|
||||
|
||||
export interface MpvProtocolHandleMessageDeps {
|
||||
getResolvedConfig: () => { secondarySub?: { secondarySubLanguages?: Array<string> } };
|
||||
getResolvedConfig: () => {
|
||||
secondarySub?: { secondarySubLanguages?: Array<string> };
|
||||
};
|
||||
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
|
||||
emitSubtitleChange: (payload: {
|
||||
text: string;
|
||||
isOverlayVisible: boolean;
|
||||
}) => 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;
|
||||
getCurrentSubText: () => string;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
@@ -63,7 +69,9 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
emitPauseChange: (payload: { paused: boolean }) => void;
|
||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
emitSubtitleMetricsChange: (
|
||||
payload: Partial<MpvSubtitleRenderMetrics>,
|
||||
) => void;
|
||||
setCurrentSecondarySubText: (text: string) => void;
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
|
||||
setSecondarySubVisibility: (visible: boolean) => void;
|
||||
@@ -87,7 +95,10 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
"ff-index"?: number;
|
||||
}>,
|
||||
) => void;
|
||||
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
|
||||
sendCommand: (payload: {
|
||||
command: unknown[];
|
||||
request_id?: number;
|
||||
}) => boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
}
|
||||
|
||||
@@ -129,7 +140,10 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (msg.name === "sub-text") {
|
||||
const nextSubText = (msg.data as string) || "";
|
||||
const overlayVisible = deps.isVisibleOverlayVisible();
|
||||
deps.emitSubtitleChange({ text: nextSubText, isOverlayVisible: overlayVisible });
|
||||
deps.emitSubtitleChange({
|
||||
text: nextSubText,
|
||||
isOverlayVisible: overlayVisible,
|
||||
});
|
||||
deps.setCurrentSubText(nextSubText);
|
||||
} else if (msg.name === "sub-text-ass") {
|
||||
deps.emitSubtitleAssChange({ text: (msg.data as string) || "" });
|
||||
@@ -378,10 +392,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export function asBoolean(
|
||||
value: unknown,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
@@ -392,10 +403,7 @@ export function asBoolean(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function asFiniteNumber(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
): number {
|
||||
export function asFiniteNumber(value: unknown, fallback: number): number {
|
||||
const nextValue = Number(value);
|
||||
return Number.isFinite(nextValue) ? nextValue : fallback;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,5 @@ test("resolveCurrentAudioStreamIndex prefers matching current audio track id", (
|
||||
});
|
||||
|
||||
test("resolveCurrentAudioStreamIndex returns null when tracks are not an array", () => {
|
||||
assert.equal(
|
||||
resolveCurrentAudioStreamIndex(null, null),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveCurrentAudioStreamIndex(null, null), null);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,9 @@ test("scheduleMpvReconnect clears existing timer and increments attempt", () =>
|
||||
handler();
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -205,14 +207,10 @@ test("MpvSocketTransport ignores connect requests while already connecting or co
|
||||
test("MpvSocketTransport.shutdown clears socket and lifecycle flags", async () => {
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: "/tmp/mpv.sock",
|
||||
onConnect: () => {
|
||||
},
|
||||
onData: () => {
|
||||
},
|
||||
onError: () => {
|
||||
},
|
||||
onClose: () => {
|
||||
},
|
||||
onConnect: () => {},
|
||||
onData: () => {},
|
||||
onError: () => {},
|
||||
onClose: () => {},
|
||||
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ export interface MpvReconnectSchedulerDeps {
|
||||
connect: () => void;
|
||||
}
|
||||
|
||||
export function scheduleMpvReconnect(
|
||||
deps: MpvReconnectSchedulerDeps,
|
||||
): number {
|
||||
export function scheduleMpvReconnect(deps: MpvReconnectSchedulerDeps): number {
|
||||
const reconnectTimer = deps.getReconnectTimer();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
|
||||
@@ -12,7 +12,7 @@ function makeDeps(
|
||||
overrides: Partial<MpvIpcClientProtocolDeps> = {},
|
||||
): MpvIpcClientDeps {
|
||||
return {
|
||||
getResolvedConfig: () => ({} as any),
|
||||
getResolvedConfig: () => ({}) as any,
|
||||
autoStartOverlay: false,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
|
||||
@@ -23,10 +23,13 @@ function makeDeps(
|
||||
};
|
||||
}
|
||||
|
||||
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
|
||||
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
|
||||
msg,
|
||||
);
|
||||
function invokeHandleMessage(
|
||||
client: MpvIpcClient,
|
||||
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 () => {
|
||||
@@ -67,14 +70,14 @@ test("MpvIpcClient parses JSON line protocol in processBuffer", () => {
|
||||
seen.push(msg);
|
||||
};
|
||||
(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();
|
||||
|
||||
assert.equal(seen.length, 2);
|
||||
assert.equal(seen[0].name, "path");
|
||||
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 () => {
|
||||
@@ -170,7 +173,9 @@ test("MpvIpcClient scheduleReconnect clears existing reconnect timer", () => {
|
||||
handler();
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -245,7 +250,8 @@ test("MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(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",
|
||||
);
|
||||
const hasTrackSubscription = commands.some(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
Config,
|
||||
MpvClient,
|
||||
MpvSubtitleRenderMetrics,
|
||||
} from "../../types";
|
||||
import { Config, MpvClient, MpvSubtitleRenderMetrics } from "../../types";
|
||||
import {
|
||||
dispatchMpvProtocolMessage,
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
@@ -12,11 +8,11 @@ import {
|
||||
MpvProtocolHandleMessageDeps,
|
||||
splitMpvMessagesFromBuffer,
|
||||
} from "./mpv-protocol";
|
||||
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
|
||||
import {
|
||||
scheduleMpvReconnect,
|
||||
MpvSocketTransport,
|
||||
} from "./mpv-transport";
|
||||
requestMpvInitialState,
|
||||
subscribeToMpvProperties,
|
||||
} from "./mpv-properties";
|
||||
import { scheduleMpvReconnect, MpvSocketTransport } from "./mpv-transport";
|
||||
import { createLogger } from "../../logger";
|
||||
|
||||
const logger = createLogger("main:mpv");
|
||||
@@ -42,7 +38,9 @@ export function resolveCurrentAudioStreamIndex(
|
||||
audioTracks.find((track) => track.selected === true);
|
||||
|
||||
const ffIndex = activeTrack?.["ff-index"];
|
||||
return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
|
||||
return typeof ffIndex === "number" &&
|
||||
Number.isInteger(ffIndex) &&
|
||||
ffIndex >= 0
|
||||
? ffIndex
|
||||
: null;
|
||||
}
|
||||
@@ -97,9 +95,7 @@ export function setMpvSubVisibilityRuntime(
|
||||
mpvClient.setSubVisibility(visible);
|
||||
}
|
||||
|
||||
export {
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
} from "./mpv-protocol";
|
||||
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-protocol";
|
||||
|
||||
export interface MpvIpcClientProtocolDeps {
|
||||
getResolvedConfig: () => Config;
|
||||
@@ -114,6 +110,7 @@ export interface MpvIpcClientProtocolDeps {
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
|
||||
export interface MpvIpcClientEventMap {
|
||||
"connection-change": { connected: boolean };
|
||||
"subtitle-change": { text: string; isOverlayVisible: boolean };
|
||||
"subtitle-ass-change": { text: string };
|
||||
"subtitle-timing": { text: string; start: number; end: number };
|
||||
@@ -171,10 +168,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
private nextDynamicRequestId = 1000;
|
||||
private pendingRequests = new Map<number, (message: MpvMessage) => void>();
|
||||
|
||||
constructor(
|
||||
socketPath: string,
|
||||
deps: MpvIpcClientDeps,
|
||||
) {
|
||||
constructor(socketPath: string, deps: MpvIpcClientDeps) {
|
||||
this.deps = deps;
|
||||
|
||||
this.transport = new MpvSocketTransport({
|
||||
@@ -184,6 +178,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.socket = this.transport.getSocket();
|
||||
this.emit("connection-change", { connected: true });
|
||||
this.reconnectAttempt = 0;
|
||||
this.hasConnectedOnce = true;
|
||||
this.setSecondarySubVisibility(false);
|
||||
@@ -217,6 +212,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.socket = null;
|
||||
this.emit("connection-change", { connected: false });
|
||||
this.failPendingRequests();
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
@@ -512,7 +508,11 @@ export class MpvIpcClient implements MpvClient {
|
||||
const previous = this.previousSecondarySubVisibility;
|
||||
if (previous === null) return;
|
||||
this.send({
|
||||
command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"],
|
||||
command: [
|
||||
"set_property",
|
||||
"secondary-sub-visibility",
|
||||
previous ? "yes" : "no",
|
||||
],
|
||||
});
|
||||
this.previousSecondarySubVisibility = null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
runStartupBootstrapRuntime,
|
||||
} from "./startup";
|
||||
import { runStartupBootstrapRuntime } from "./startup";
|
||||
import { CliArgs } from "../../cli/args";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
@@ -34,6 +32,15 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
texthooker: false,
|
||||
help: 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", () => {
|
||||
const calls: string[] = [];
|
||||
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.texthookerPort, 5174);
|
||||
assert.equal(result.backendOverride, null);
|
||||
assert.deepEqual(calls, [
|
||||
"setLog:warn:cli",
|
||||
"forceX11",
|
||||
"enforceWayland",
|
||||
]);
|
||||
assert.deepEqual(calls, ["setLog:warn:cli", "forceX11", "enforceWayland"]);
|
||||
});
|
||||
|
||||
1260
src/main.ts
1260
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
import { handleCliCommand, createCliCommandDepsRuntime } from "../core/services";
|
||||
import {
|
||||
handleCliCommand,
|
||||
createCliCommandDepsRuntime,
|
||||
} from "../core/services";
|
||||
import type { CliArgs, CliCommandSource } from "../cli/args";
|
||||
import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies";
|
||||
import {
|
||||
createCliCommandRuntimeServiceDeps,
|
||||
CliCommandRuntimeServiceDepsParams,
|
||||
} from "./dependencies";
|
||||
|
||||
export interface CliCommandRuntimeServiceContext {
|
||||
getSocketPath: () => string;
|
||||
@@ -31,6 +37,8 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams["anilist"]["openSetup"];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams["anilist"]["getQueueStatus"];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams["anilist"]["retryQueueNow"];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams["jellyfin"]["openSetup"];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams["jellyfin"]["runCommand"];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -49,7 +57,8 @@ export interface CliCommandRuntimeServiceContextHandlers {
|
||||
}
|
||||
|
||||
function createCliCommandDepsFromContext(
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
context: CliCommandRuntimeServiceContext &
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
): CliCommandRuntimeServiceDepsParams {
|
||||
return {
|
||||
mpv: {
|
||||
@@ -77,7 +86,8 @@ function createCliCommandDepsFromContext(
|
||||
copyCurrentSubtitle: context.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: context.startPendingMultiCopy,
|
||||
mineSentenceCard: context.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
|
||||
startPendingMineSentenceMultiple:
|
||||
context.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
|
||||
refreshKnownWords: context.refreshKnownWordCache,
|
||||
triggerFieldGrouping: context.triggerFieldGrouping,
|
||||
@@ -91,6 +101,10 @@ function createCliCommandDepsFromContext(
|
||||
getQueueStatus: context.getAnilistQueueStatus,
|
||||
retryQueueNow: context.retryAnilistQueueNow,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
runCommand: context.runJellyfinCommand,
|
||||
},
|
||||
ui: {
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
@@ -123,7 +137,12 @@ export function handleCliCommandRuntimeService(
|
||||
export function handleCliCommandRuntimeServiceWithContext(
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
context: CliCommandRuntimeServiceContext &
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
): void {
|
||||
handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context));
|
||||
handleCliCommandRuntimeService(
|
||||
args,
|
||||
source,
|
||||
createCliCommandDepsFromContext(context),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface SubsyncRuntimeDepsParams {
|
||||
openManualPicker: (payload: SubsyncManualPayload) => void;
|
||||
}
|
||||
|
||||
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): {
|
||||
export function createRuntimeOptionsIpcDeps(
|
||||
params: RuntimeOptionsIpcDepsParams,
|
||||
): {
|
||||
setRuntimeOption: (id: string, value: unknown) => 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 {
|
||||
getMpvClient: params.getMpvClient,
|
||||
getResolvedSubsyncConfig: params.getResolvedSubsyncConfig,
|
||||
@@ -145,19 +149,14 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: CliCommandDepsRuntimeOptions["mining"]["copyCurrentSubtitle"];
|
||||
startPendingMultiCopy:
|
||||
CliCommandDepsRuntimeOptions["mining"]["startPendingMultiCopy"];
|
||||
startPendingMultiCopy: CliCommandDepsRuntimeOptions["mining"]["startPendingMultiCopy"];
|
||||
mineSentenceCard: CliCommandDepsRuntimeOptions["mining"]["mineSentenceCard"];
|
||||
startPendingMineSentenceMultiple:
|
||||
CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
|
||||
updateLastCardFromClipboard:
|
||||
CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
|
||||
startPendingMineSentenceMultiple: CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
|
||||
updateLastCardFromClipboard: CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
|
||||
refreshKnownWords: CliCommandDepsRuntimeOptions["mining"]["refreshKnownWords"];
|
||||
triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"];
|
||||
triggerSubsyncFromConfig:
|
||||
CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
|
||||
markLastCardAsAudioCard:
|
||||
CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"];
|
||||
triggerSubsyncFromConfig: CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
|
||||
markLastCardAsAudioCard: CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"];
|
||||
};
|
||||
anilist: {
|
||||
getStatus: CliCommandDepsRuntimeOptions["anilist"]["getStatus"];
|
||||
@@ -166,11 +165,14 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
getQueueStatus: CliCommandDepsRuntimeOptions["anilist"]["getQueueStatus"];
|
||||
retryQueueNow: CliCommandDepsRuntimeOptions["anilist"]["retryQueueNow"];
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions["jellyfin"]["openSetup"];
|
||||
runCommand: CliCommandDepsRuntimeOptions["jellyfin"]["runCommand"];
|
||||
};
|
||||
ui: {
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"];
|
||||
openRuntimeOptionsPalette:
|
||||
CliCommandDepsRuntimeOptions["ui"]["openRuntimeOptionsPalette"];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions["ui"]["openRuntimeOptionsPalette"];
|
||||
printHelp: CliCommandDepsRuntimeOptions["ui"]["printHelp"];
|
||||
};
|
||||
app: {
|
||||
@@ -293,7 +295,8 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
copyCurrentSubtitle: params.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: params.mining.startPendingMultiCopy,
|
||||
mineSentenceCard: params.mining.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
|
||||
startPendingMineSentenceMultiple:
|
||||
params.mining.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
|
||||
refreshKnownWords: params.mining.refreshKnownWords,
|
||||
triggerFieldGrouping: params.mining.triggerFieldGrouping,
|
||||
@@ -307,6 +310,10 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
getQueueStatus: params.anilist.getQueueStatus,
|
||||
retryQueueNow: params.anilist.retryQueueNow,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
runCommand: params.jellyfin.runCommand,
|
||||
},
|
||||
ui: {
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SubtitleTimingTracker } from "../subtitle-timing-tracker";
|
||||
import type { AnkiIntegration } from "../anki-integration";
|
||||
import type { ImmersionTrackerService } 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 type { RuntimeOptionsManager } from "../runtime-options";
|
||||
import type { MecabTokenizer } from "../mecab-tokenizer";
|
||||
@@ -40,9 +41,11 @@ export interface AppState {
|
||||
yomitanSettingsWindow: BrowserWindow | null;
|
||||
yomitanParserWindow: BrowserWindow | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
jellyfinSetupWindow: BrowserWindow | null;
|
||||
yomitanParserReadyPromise: Promise<void> | null;
|
||||
yomitanParserInitPromise: Promise<boolean> | null;
|
||||
mpvClient: MpvIpcClient | null;
|
||||
jellyfinRemoteSession: JellyfinRemoteSessionService | null;
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
@@ -104,9 +107,11 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
yomitanSettingsWindow: null,
|
||||
yomitanParserWindow: null,
|
||||
anilistSetupWindow: null,
|
||||
jellyfinSetupWindow: null,
|
||||
yomitanParserReadyPromise: null,
|
||||
yomitanParserInitPromise: null,
|
||||
mpvClient: null,
|
||||
jellyfinRemoteSession: null,
|
||||
reconnectTimer: null,
|
||||
currentSubText: "",
|
||||
currentSubAssText: "",
|
||||
|
||||
@@ -270,7 +270,9 @@ const electronAPI: ElectronAPI = {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => {
|
||||
notifyOverlayModalClosed: (
|
||||
modal: "runtime-options" | "subsync" | "jimaku",
|
||||
) => {
|
||||
ipcRenderer.send("overlay:modal-closed", modal);
|
||||
},
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
|
||||
@@ -24,14 +24,18 @@ export function createRuntimeOptionsModal(
|
||||
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;
|
||||
}
|
||||
|
||||
function getSelectedRuntimeOption(): RuntimeOptionState | null {
|
||||
if (ctx.state.runtimeOptions.length === 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 ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
|
||||
@@ -42,7 +46,10 @@ export function createRuntimeOptionsModal(
|
||||
ctx.state.runtimeOptions.forEach((option, index) => {
|
||||
const li = document.createElement("li");
|
||||
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");
|
||||
label.className = "runtime-options-label";
|
||||
@@ -113,14 +120,20 @@ export function createRuntimeOptionsModal(
|
||||
if (!option || option.allowedValues.length === 0) return;
|
||||
|
||||
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 nextIndex =
|
||||
direction === 1
|
||||
? (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();
|
||||
setRuntimeOptionsStatus(
|
||||
`Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`,
|
||||
@@ -140,7 +153,10 @@ export function createRuntimeOptionsModal(
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -160,7 +176,10 @@ export function createRuntimeOptionsModal(
|
||||
|
||||
setRuntimeOptionsStatus("");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ type SessionHelpSection = {
|
||||
title: string;
|
||||
rows: SessionHelpItem[];
|
||||
};
|
||||
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, "multiCopyTimeoutMs">;
|
||||
type RuntimeShortcutConfig = Omit<
|
||||
Required<ShortcutsConfig>,
|
||||
"multiCopyTimeoutMs"
|
||||
>;
|
||||
|
||||
const HEX_COLOR_RE =
|
||||
/^#(?:[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: "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: "triggerSubsync", label: "Open subtitle sync controls" },
|
||||
{ key: "mineSentence", label: "Mine sentence" },
|
||||
@@ -128,10 +134,14 @@ function describeCommand(command: (string | number)[]): string {
|
||||
if (first === "sub-seek" && typeof command[1] === "number") {
|
||||
return `Shift subtitle by ${command[1]} ms`;
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return "Open subtitle sync controls";
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) 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 === SPECIAL_COMMANDS.SUBSYNC_TRIGGER)
|
||||
return "Open subtitle sync controls";
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN)
|
||||
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)) {
|
||||
const [, rawId, rawDirection] = first.split(":");
|
||||
return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`;
|
||||
@@ -154,7 +164,11 @@ function sectionForCommand(command: (string | number)[]): string {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -221,38 +235,80 @@ function buildColorSection(style: {
|
||||
rows: [
|
||||
{
|
||||
shortcut: "Known words",
|
||||
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
action: normalizeColor(
|
||||
style.knownWordColor,
|
||||
FALLBACK_COLORS.knownWordColor,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.knownWordColor,
|
||||
FALLBACK_COLORS.knownWordColor,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "N+1 words",
|
||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
action: normalizeColor(
|
||||
style.nPlusOneColor,
|
||||
FALLBACK_COLORS.nPlusOneColor,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.nPlusOneColor,
|
||||
FALLBACK_COLORS.nPlusOneColor,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "JLPT N1",
|
||||
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
action: normalizeColor(
|
||||
style.jlptColors?.N1,
|
||||
FALLBACK_COLORS.jlptN1Color,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.jlptColors?.N1,
|
||||
FALLBACK_COLORS.jlptN1Color,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "JLPT N2",
|
||||
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
action: normalizeColor(
|
||||
style.jlptColors?.N2,
|
||||
FALLBACK_COLORS.jlptN2Color,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.jlptColors?.N2,
|
||||
FALLBACK_COLORS.jlptN2Color,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "JLPT N3",
|
||||
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
action: normalizeColor(
|
||||
style.jlptColors?.N3,
|
||||
FALLBACK_COLORS.jlptN3Color,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.jlptColors?.N3,
|
||||
FALLBACK_COLORS.jlptN3Color,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "JLPT N4",
|
||||
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
action: normalizeColor(
|
||||
style.jlptColors?.N4,
|
||||
FALLBACK_COLORS.jlptN4Color,
|
||||
),
|
||||
color: normalizeColor(
|
||||
style.jlptColors?.N4,
|
||||
FALLBACK_COLORS.jlptN4Color,
|
||||
),
|
||||
},
|
||||
{
|
||||
shortcut: "JLPT N5",
|
||||
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
action: normalizeColor(
|
||||
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 {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
ctx.dom.sessionHelpModal.contains(target)
|
||||
target instanceof Element && ctx.dom.sessionHelpModal.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,7 +548,9 @@ export function createSessionHelpModal(
|
||||
});
|
||||
|
||||
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
|
||||
? "No matching shortcuts found."
|
||||
: "No active session shortcuts found.";
|
||||
@@ -501,7 +558,9 @@ export function createSessionHelpModal(
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.classList.remove("session-help-content-no-results");
|
||||
ctx.dom.sessionHelpContent.classList.remove(
|
||||
"session-help-content-no-results",
|
||||
);
|
||||
|
||||
if (isFilterInputFocused()) return;
|
||||
|
||||
@@ -519,14 +578,23 @@ export function createSessionHelpModal(
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.addEventListener(
|
||||
"pointerdown",
|
||||
modalPointerFocusGuard,
|
||||
);
|
||||
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
|
||||
}
|
||||
|
||||
function removePointerFocusListener(): void {
|
||||
if (!modalPointerFocusGuard) return;
|
||||
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.removeEventListener(
|
||||
"pointerdown",
|
||||
modalPointerFocusGuard,
|
||||
);
|
||||
ctx.dom.sessionHelpModal.removeEventListener(
|
||||
"click",
|
||||
modalPointerFocusGuard,
|
||||
);
|
||||
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;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
@@ -604,7 +674,8 @@ export function createSessionHelpModal(
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
"Both Y-H and Y-K are bound; Y-K remains the fallback for this session.";
|
||||
} 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 {
|
||||
ctx.dom.sessionHelpWarning.textContent = "";
|
||||
}
|
||||
@@ -655,7 +726,10 @@ export function createSessionHelpModal(
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.sessionHelpModal.classList.add("hidden");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -676,7 +750,10 @@ export function createSessionHelpModal(
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
@@ -716,13 +793,7 @@ export function createSessionHelpModal(
|
||||
const items = getItems();
|
||||
if (items.length === 0) return true;
|
||||
|
||||
if (
|
||||
e.key === "/" &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusFilterInput();
|
||||
return true;
|
||||
@@ -730,21 +801,13 @@ export function createSessionHelpModal(
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (
|
||||
key === "arrowdown" ||
|
||||
key === "j" ||
|
||||
key === "l"
|
||||
) {
|
||||
if (key === "arrowdown" || key === "j" || key === "l") {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
key === "arrowup" ||
|
||||
key === "k" ||
|
||||
key === "h"
|
||||
) {
|
||||
if (key === "arrowup" || key === "k" || key === "h") {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
|
||||
return true;
|
||||
@@ -759,14 +822,19 @@ export function createSessionHelpModal(
|
||||
applyFilterAndRender();
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpFilter.addEventListener("keydown", (event: KeyboardEvent) => {
|
||||
ctx.dom.sessionHelpFilter.addEventListener(
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
focusFallbackTarget();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ctx.dom.sessionHelpContent.addEventListener("click", (event: MouseEvent) => {
|
||||
ctx.dom.sessionHelpContent.addEventListener(
|
||||
"click",
|
||||
(event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
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);
|
||||
if (!Number.isFinite(index)) return;
|
||||
setSelected(index);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ctx.dom.sessionHelpClose.addEventListener("click", () => {
|
||||
closeSessionHelpModal();
|
||||
|
||||
@@ -43,7 +43,11 @@ function getPathValue(source: Record<string, unknown>, path: string): unknown {
|
||||
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(".");
|
||||
let current = target;
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -81,7 +87,10 @@ export class RuntimeOptionsManager {
|
||||
private readonly applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||
private runtimeOverrides: RuntimeOverrides = {};
|
||||
private readonly definitions = new Map<RuntimeOptionId, RuntimeOptionRegistryEntry>();
|
||||
private readonly definitions = new Map<
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionRegistryEntry
|
||||
>();
|
||||
|
||||
constructor(
|
||||
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);
|
||||
if (override !== undefined) return override as RuntimeOptionValue;
|
||||
|
||||
@@ -135,7 +146,10 @@ export class RuntimeOptionsManager {
|
||||
return this.getEffectiveValue(definition);
|
||||
}
|
||||
|
||||
setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult {
|
||||
setOptionValue(
|
||||
id: RuntimeOptionId,
|
||||
value: RuntimeOptionValue,
|
||||
): RuntimeOptionApplyResult {
|
||||
const definition = this.definitions.get(id);
|
||||
if (!definition) {
|
||||
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);
|
||||
if (!definition) {
|
||||
return { ok: false, error: `Unknown runtime option: ${id}` };
|
||||
@@ -191,7 +208,9 @@ export class RuntimeOptionsManager {
|
||||
return this.setOptionValue(id, values[nextIndex]);
|
||||
}
|
||||
|
||||
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
|
||||
getEffectiveAnkiConnectConfig(
|
||||
baseConfig?: AnkiConnectConfig,
|
||||
): AnkiConnectConfig {
|
||||
const source = baseConfig ?? this.getAnkiConfig();
|
||||
const effective: AnkiConnectConfig = deepClone(source);
|
||||
|
||||
@@ -200,7 +219,11 @@ export class RuntimeOptionsManager {
|
||||
if (override === undefined) continue;
|
||||
|
||||
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;
|
||||
|
||||
70
src/types.ts
70
src/types.ts
@@ -338,6 +338,27 @@ export interface AnilistConfig {
|
||||
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 {
|
||||
startupVisibility?: "platform-default" | "visible" | "hidden";
|
||||
}
|
||||
@@ -354,6 +375,18 @@ export interface YoutubeSubgenConfig {
|
||||
export interface ImmersionTrackingConfig {
|
||||
enabled?: boolean;
|
||||
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 {
|
||||
@@ -370,6 +403,7 @@ export interface Config {
|
||||
bind_visible_overlay_to_mpv_sub_visibility?: boolean;
|
||||
jimaku?: JimakuConfig;
|
||||
anilist?: AnilistConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
invisibleOverlay?: InvisibleOverlayConfig;
|
||||
youtubeSubgen?: YoutubeSubgenConfig;
|
||||
immersionTracking?: ImmersionTrackingConfig;
|
||||
@@ -480,6 +514,26 @@ export interface ResolvedConfig {
|
||||
enabled: boolean;
|
||||
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>;
|
||||
youtubeSubgen: YoutubeSubgenConfig & {
|
||||
mode: YoutubeSubgenMode;
|
||||
@@ -490,6 +544,18 @@ export interface ResolvedConfig {
|
||||
immersionTracking: {
|
||||
enabled: boolean;
|
||||
dbPath?: string;
|
||||
batchSize: number;
|
||||
flushIntervalMs: number;
|
||||
queueCap: number;
|
||||
payloadCapBytes: number;
|
||||
maintenanceIntervalMs: number;
|
||||
retention: {
|
||||
eventsDays: number;
|
||||
telemetryDays: number;
|
||||
dailyRollupsDays: number;
|
||||
monthlyRollupsDays: number;
|
||||
vacuumIntervalDays: number;
|
||||
};
|
||||
};
|
||||
logging: {
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
@@ -719,7 +785,9 @@ export interface ElectronAPI {
|
||||
) => void;
|
||||
onOpenRuntimeOptions: (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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user