feat(jellyfin): add remote playback and config plumbing

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

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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/);
});

View File

@@ -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
--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
--trigger-subsync Run subtitle sync
--mark-audio-card Mark last card as audio card
--open-runtime-options Open runtime options palette
--anilist-status Show AniList 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)
--help Show this help
${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
--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
--toggle-secondary-sub Cycle secondary subtitle mode
--refresh-known-words Refresh known words cache
--open-runtime-options Open runtime options palette
${B}AniList${R}
--anilist-setup Open AniList authentication flow
--anilist-status Show token and retry queue status
--anilist-logout Clear stored AniList token
--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
`);
}

View File

@@ -18,8 +18,22 @@ test("loads defaults when config is missing", () => {
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.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",
),
);
});

View File

@@ -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",
},

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"),
),
);
});

View File

@@ -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);

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,9 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
return {
state,
deps: {
getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }),
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ["ja"] },
}),
getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false,
emitSubtitleChange: (payload) => state.events.push(payload),
@@ -94,16 +96,16 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
state.commands.push(payload);
return true;
},
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
...overrides,
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
};
}
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
...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) => {
errors.push({ line, error: String(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}");

View File

@@ -35,10 +35,7 @@ export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
export 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;
}

View File

@@ -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);
});

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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"]);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,12 @@
import { handleCliCommand, createCliCommandDepsRuntime } from "../core/services";
import {
handleCliCommand,
createCliCommandDepsRuntime,
} from "../core/services";
import type { CliArgs, CliCommandSource } from "../cli/args";
import { 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),
);
}

View File

@@ -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,

View File

@@ -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: "",

View File

@@ -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) => {

View File

@@ -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");
}
}

View File

@@ -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,22 +822,28 @@ export function createSessionHelpModal(
applyFilterAndRender();
});
ctx.dom.sessionHelpFilter.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
});
ctx.dom.sessionHelpFilter.addEventListener(
"keydown",
(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
},
);
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;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
});
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;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
},
);
ctx.dom.sessionHelpClose.addEventListener("click", () => {
closeSessionHelpModal();

View File

@@ -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;

View File

@@ -245,13 +245,13 @@ export interface AnkiConnectConfig {
minSentenceWords?: number;
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: "append" | "prepend";
highlightWord?: boolean;
notificationType?: "osd" | "system" | "both" | "none";
autoUpdateNewCards?: boolean;
};
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: "append" | "prepend";
highlightWord?: boolean;
notificationType?: "osd" | "system" | "both" | "none";
autoUpdateNewCards?: boolean;
};
metadata?: {
pattern?: string;
};
@@ -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;
}