mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
Compare commits
69 Commits
v0.2.1
...
refactor-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
b17e3ea32a
|
|||
|
4b0a2ec486
|
|||
|
2c001e8017
|
|||
|
dd0ed3f849
|
|||
|
c8b65a01f6
|
|||
|
73e70b4395
|
|||
|
d0f29cfeae
|
|||
|
5e74209b61
|
|||
|
05805a3169
|
|||
|
cf9a444e08
|
|||
|
30e3e858f6
|
|||
|
3fe6b8c926
|
|||
|
0e64b630d0
|
|||
|
fb948c6feb
|
|||
|
a46f90d085
|
|||
|
33007b3f40
|
|||
|
e78e45b4e7
|
|||
|
a80d6dbea9
|
|||
|
cbff3f9ad9
|
|||
|
e4038127cb
|
|||
|
4309e0dec3
|
|||
|
55c577e911
|
|||
|
fd77f8f6a2
|
|||
|
dcc82c8052
|
|||
|
93336afa07
|
|||
|
a7d220e182
|
|||
|
498fd2d09a
|
|||
|
d2af09d941
|
|||
|
9c2618c4c7
|
|||
|
bf333c7c08
|
|||
|
dac9a3429a
|
|||
|
536db5ff85
|
|||
|
39288a62b6
|
|||
|
93e392910c
|
|||
|
185528aee6
|
|||
|
870acb45d5
|
|||
|
40787e8b71
|
|||
|
98fd2a731e
|
|||
|
de8c15fd56
|
|||
|
370274e78a
|
|||
|
9e0c5e478e
|
|||
|
3f1702b0f6
|
|||
|
66c24767fb
|
|||
|
f8e961d105
|
|||
|
34a0feae71
|
|||
|
db5e3f9e50
|
|||
|
30a76d7767
|
|||
|
1e645f961b
|
|||
|
3a1d746a2e
|
|||
|
17fa10ba36
|
|||
|
d6c4a85a3b
|
|||
|
19c7448f26
|
|||
|
b212986682
|
|||
|
d07b0aa957
|
|||
|
603af36a48
|
|||
|
5ef3396205
|
|||
|
721036342d
|
|||
|
c7c91077fd
|
|||
|
771ea5777f
|
|||
|
151752b17a
|
|||
|
62f53071ec
|
|||
|
337e3268f1
|
|||
|
fa0cb00f70
|
|||
|
a33a87bf8f
|
|||
|
3c2c8453be
|
|||
|
3c5ba3a3d3
|
|||
|
1ae46cd4ba
|
|||
|
1e2b43a7dc
|
|||
|
0de278f3ab
|
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -242,7 +242,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
tar -czf "release/subminer-assets.tar.gz" \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer.lua \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi
|
assets/themes/subminer.rasi
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ jobs:
|
|||||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||||
1. Download `subminer-assets.tar.gz`
|
1. Download `subminer-assets.tar.gz`
|
||||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
3. Copy `plugin/subminer.lua` to `~/.config/mpv/scripts/`
|
||||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||||
5. Copy `assets/themes/subminer.rasi` to:
|
5. Copy `assets/themes/subminer.rasi` to:
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-79
|
|
||||||
title: 'Jimaku modal: auto-close after successful subtitle load'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-01 13:52'
|
|
||||||
updated_date: '2026-03-01 14:06'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 10000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds.
|
|
||||||
|
|
||||||
Current behavior:
|
|
||||||
- Subtitle file downloads and loads into mpv.
|
|
||||||
- Jimaku modal remains open until manual close.
|
|
||||||
|
|
||||||
Expected behavior:
|
|
||||||
- On successful `jimakuDownloadFile` result, close modal immediately.
|
|
||||||
- Keep error behavior unchanged (stay open + show error).
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Successful subtitle file selection/download in Jimaku closes modal automatically.
|
|
||||||
- [x] #2 Existing error path keeps modal open and shows error.
|
|
||||||
- [x] #3 Regression test covers success auto-close behavior.
|
|
||||||
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Fixed renderer Jimaku success flow to close modal immediately after successful `jimakuDownloadFile` result. Added regression test (`src/renderer/modals/jimaku.test.ts`) that reproduces keyboard file-selection success path and asserts modal close state + `notifyOverlayModalClosed('jimaku')` emission. Kept failure path unchanged.
|
|
||||||
|
|
||||||
Also wired new test into `test:core:src` and `test:core:dist` package scripts.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-80
|
|
||||||
title: 'Jimaku download: rename subtitle to current video basename'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-01 14:17'
|
|
||||||
updated_date: '2026-03-01 14:19'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 11000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Current media: `anime.mkv`
|
|
||||||
- Downloaded subtitle extension: `.srt`
|
|
||||||
- Saved subtitle path: `anime.ja.srt`
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Apply in Jimaku download IPC path before writing file.
|
|
||||||
- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists).
|
|
||||||
- Keep mpv load flow unchanged except using renamed path.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Jimaku subtitle destination name uses current media basename plus `.ja` and subtitle extension.
|
|
||||||
- [x] #2 Existing duplicate filename conflict handling still works.
|
|
||||||
- [x] #3 Regression tests cover renamed destination path behavior.
|
|
||||||
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Jimaku download path generation now derives subtitle filename from currently playing media basename and keeps subtitle extension from Jimaku file (`anime.mkv` + `.srt` => `anime.ja.srt`). Added pure helper `buildJimakuSubtitleFilenameFromMediaPath` and routed IPC download flow through it before existing duplicate-path conflict handling. Added regression tests for local path, missing extension fallback, and remote URL media paths.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -884,7 +884,7 @@ Launcher subcommands:
|
|||||||
- `subminer jellyfin -l --server ... --username ... --password ...` logs in.
|
- `subminer jellyfin -l --server ... --username ... --password ...` logs in.
|
||||||
- `subminer jellyfin --logout` clears stored credentials.
|
- `subminer jellyfin --logout` clears stored credentials.
|
||||||
- `subminer jellyfin -p` opens play picker.
|
- `subminer jellyfin -p` opens play picker.
|
||||||
- `subminer jellyfin -d` starts cast discovery mode in background/tray mode.
|
- `subminer jellyfin -d` starts cast discovery mode.
|
||||||
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
|
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
|
||||||
|
|
||||||
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
||||||
|
|||||||
@@ -60,18 +60,12 @@ Launcher wrapper equivalent for interactive playback flow:
|
|||||||
subminer jellyfin -p
|
subminer jellyfin -p
|
||||||
```
|
```
|
||||||
|
|
||||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
Launcher wrapper for Jellyfin cast discovery mode (foreground app process):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer jellyfin -d
|
subminer jellyfin -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop discovery session/app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer app --stop
|
|
||||||
```
|
|
||||||
|
|
||||||
`subminer jf ...` is an alias for `subminer jellyfin ...`.
|
`subminer jf ...` is an alias for `subminer jellyfin ...`.
|
||||||
|
|
||||||
To clear saved session credentials:
|
To clear saved session credentials:
|
||||||
@@ -86,17 +80,6 @@ subminer jellyfin --logout
|
|||||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
|
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional listing controls:
|
|
||||||
|
|
||||||
- `--jellyfin-recursive=true|false` (default: true)
|
|
||||||
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
|
|
||||||
|
|
||||||
These are used by the launcher picker flow to:
|
|
||||||
|
|
||||||
- keep root search focused on shows/folders/movies (exclude episode rows)
|
|
||||||
- browse selected anime/show directories as folder-or-file lists
|
|
||||||
- recurse for playable files only after selecting a folder
|
|
||||||
|
|
||||||
5. Start playback:
|
5. Start playback:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
|||||||
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
||||||
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
||||||
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
||||||
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
subminer jellyfin -d # Jellyfin cast-discovery mode (foreground app)
|
||||||
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
|
||||||
subminer doctor # Dependency + config + socket diagnostics
|
subminer doctor # Dependency + config + socket diagnostics
|
||||||
subminer config path # Print active config path
|
subminer config path # Print active config path
|
||||||
subminer config show # Print active config contents
|
subminer config show # Print active config contents
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--background', '--jellyfin-remote-announce'];
|
const forwarded = ['--start'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import type {
|
import type {
|
||||||
Args,
|
Args,
|
||||||
@@ -9,8 +8,8 @@ import type {
|
|||||||
JellyfinItemEntry,
|
JellyfinItemEntry,
|
||||||
JellyfinGroupEntry,
|
JellyfinGroupEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail } from './log.js';
|
||||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
import { commandExists, resolvePathMaybe } from './util.js';
|
||||||
import {
|
import {
|
||||||
pickLibrary,
|
pickLibrary,
|
||||||
pickItem,
|
pickItem,
|
||||||
@@ -19,17 +18,12 @@ import {
|
|||||||
findRofiTheme,
|
findRofiTheme,
|
||||||
} from './picker.js';
|
} from './picker.js';
|
||||||
import { loadLauncherJellyfinConfig } from './config.js';
|
import { loadLauncherJellyfinConfig } from './config.js';
|
||||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
|
||||||
import {
|
import {
|
||||||
runAppCommandWithInheritLogged,
|
runAppCommandWithInheritLogged,
|
||||||
runAppCommandCaptureOutput,
|
|
||||||
launchAppStartDetached,
|
|
||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from './mpv.js';
|
} from './mpv.js';
|
||||||
|
|
||||||
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
||||||
|
|
||||||
export function sanitizeServerUrl(value: string): string {
|
export function sanitizeServerUrl(value: string): string {
|
||||||
return value.trim().replace(/\/+$/, '');
|
return value.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -120,605 +114,6 @@ export function formatJellyfinItemDisplay(item: Record<string, unknown>): string
|
|||||||
return `${name} (${type})`;
|
return `${name} (${type})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripAnsi(value: string): string {
|
|
||||||
return value.replace(ANSI_ESCAPE_PATTERN, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNamedJellyfinRecord(payload: string): {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
} | null {
|
|
||||||
const typeClose = payload.lastIndexOf(')');
|
|
||||||
if (typeClose !== payload.length - 1) return null;
|
|
||||||
|
|
||||||
const typeOpen = payload.lastIndexOf(' (');
|
|
||||||
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
|
|
||||||
|
|
||||||
const idClose = payload.lastIndexOf(']', typeOpen);
|
|
||||||
if (idClose <= 0) return null;
|
|
||||||
|
|
||||||
const idOpen = payload.lastIndexOf(' [', idClose);
|
|
||||||
if (idOpen <= 0 || idOpen >= idClose) return null;
|
|
||||||
|
|
||||||
const name = payload.slice(0, idOpen).trim();
|
|
||||||
const id = payload.slice(idOpen + 2, idClose).trim();
|
|
||||||
const type = payload.slice(typeOpen + 2, typeClose).trim();
|
|
||||||
if (!name || !id || !type) return null;
|
|
||||||
|
|
||||||
return { name, id, type };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
|
||||||
const libraries: JellyfinLibraryEntry[] = [];
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const rawLine of output.split(/\r?\n/)) {
|
|
||||||
const line = stripAnsi(rawLine);
|
|
||||||
const markerIndex = line.indexOf('Jellyfin library:');
|
|
||||||
if (markerIndex < 0) continue;
|
|
||||||
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
|
|
||||||
const parsed = parseNamedJellyfinRecord(payload);
|
|
||||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
|
||||||
seenIds.add(parsed.id);
|
|
||||||
libraries.push({
|
|
||||||
id: parsed.id,
|
|
||||||
name: parsed.name,
|
|
||||||
kind: parsed.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return libraries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
|
||||||
const items: JellyfinItemEntry[] = [];
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const rawLine of output.split(/\r?\n/)) {
|
|
||||||
const line = stripAnsi(rawLine);
|
|
||||||
const markerIndex = line.indexOf('Jellyfin item:');
|
|
||||||
if (markerIndex < 0) continue;
|
|
||||||
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
|
|
||||||
const parsed = parseNamedJellyfinRecord(payload);
|
|
||||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
|
||||||
seenIds.add(parsed.id);
|
|
||||||
items.push({
|
|
||||||
id: parsed.id,
|
|
||||||
name: parsed.name,
|
|
||||||
type: parsed.type,
|
|
||||||
display: parsed.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJellyfinErrorFromAppOutput(output: string): string {
|
|
||||||
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
|
|
||||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
const bracketedErrorIndex = line.indexOf('[ERROR]');
|
|
||||||
if (bracketedErrorIndex >= 0) {
|
|
||||||
const message = line.slice(bracketedErrorIndex + '[ERROR]'.length).trim();
|
|
||||||
if (message.length > 0) return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainErrorIndex = line.indexOf(' - ERROR - ');
|
|
||||||
if (mainErrorIndex >= 0) {
|
|
||||||
const message = line.slice(mainErrorIndex + ' - ERROR - '.length).trim();
|
|
||||||
if (message.length > 0) return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.includes('Missing Jellyfin session')) {
|
|
||||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
type JellyfinPreviewAuthResponse = {
|
|
||||||
serverUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
|
|
||||||
if (!raw || raw.trim().length === 0) return null;
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== 'object') return null;
|
|
||||||
|
|
||||||
const candidate = parsed as Record<string, unknown>;
|
|
||||||
const serverUrl = sanitizeServerUrl(
|
|
||||||
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
|
|
||||||
);
|
|
||||||
const accessToken =
|
|
||||||
typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
|
|
||||||
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
|
|
||||||
if (!serverUrl || !accessToken) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
serverUrl,
|
|
||||||
accessToken,
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
|
||||||
return errorMessage.includes('No running instance. Use --start to launch the app.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveJellyfinTokenStorePath(configPath: string): string {
|
|
||||||
return path.join(path.dirname(configPath), 'jellyfin-token-store.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasStoredJellyfinSession(
|
|
||||||
configPath: string,
|
|
||||||
exists: (candidate: string) => boolean = fs.existsSync,
|
|
||||||
): boolean {
|
|
||||||
return exists(deriveJellyfinTokenStorePath(configPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
|
|
||||||
try {
|
|
||||||
const buffer = fs.readFileSync(logPath);
|
|
||||||
if (buffer.length === 0) return '';
|
|
||||||
const normalizedOffset =
|
|
||||||
Number.isFinite(offsetBytes) && offsetBytes >= 0
|
|
||||||
? Math.floor(offsetBytes)
|
|
||||||
: 0;
|
|
||||||
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
|
|
||||||
return buffer.subarray(startOffset).toString('utf8');
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEpisodePathFromDisplay(
|
|
||||||
display: string,
|
|
||||||
): { seriesName: string; seasonNumber: number } | null {
|
|
||||||
const normalized = display.trim().replace(/\s+/g, ' ');
|
|
||||||
const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i);
|
|
||||||
if (!match) return null;
|
|
||||||
const seriesName = match[1].trim();
|
|
||||||
const seasonNumber = Number.parseInt(match[2], 10);
|
|
||||||
if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null;
|
|
||||||
return { seriesName, seasonNumber };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeJellyfinType(type: string): string {
|
|
||||||
return type.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isJellyfinPlayableType(type: string): boolean {
|
|
||||||
const normalizedType = normalizeJellyfinType(type);
|
|
||||||
return (
|
|
||||||
normalizedType === 'movie' ||
|
|
||||||
normalizedType === 'episode' ||
|
|
||||||
normalizedType === 'audio' ||
|
|
||||||
normalizedType === 'video' ||
|
|
||||||
normalizedType === 'musicvideo'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isJellyfinContainerType(type: string): boolean {
|
|
||||||
const normalizedType = normalizeJellyfinType(type);
|
|
||||||
return (
|
|
||||||
normalizedType === 'series' ||
|
|
||||||
normalizedType === 'season' ||
|
|
||||||
normalizedType === 'folder' ||
|
|
||||||
normalizedType === 'collectionfolder'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJellyfinRootSearchType(type: string): boolean {
|
|
||||||
const normalizedType = normalizeJellyfinType(type);
|
|
||||||
return (
|
|
||||||
isJellyfinContainerType(normalizedType) ||
|
|
||||||
normalizedType === 'movie' ||
|
|
||||||
normalizedType === 'video' ||
|
|
||||||
normalizedType === 'musicvideo'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRootSearchGroups(items: JellyfinItemEntry[]): JellyfinGroupEntry[] {
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
const groups: JellyfinGroupEntry[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.id || seenIds.has(item.id) || !isJellyfinRootSearchType(item.type)) continue;
|
|
||||||
seenIds.add(item.id);
|
|
||||||
groups.push({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
type: item.type,
|
|
||||||
display: `${item.name} (${item.type})`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JellyfinChildSelection =
|
|
||||||
| { kind: 'playable'; id: string }
|
|
||||||
| { kind: 'container'; id: string };
|
|
||||||
|
|
||||||
export function classifyJellyfinChildSelection(
|
|
||||||
selectedChild: Pick<JellyfinGroupEntry, 'id' | 'type'>,
|
|
||||||
): JellyfinChildSelection {
|
|
||||||
if (isJellyfinPlayableType(selectedChild.type)) {
|
|
||||||
return { kind: 'playable', id: selectedChild.id };
|
|
||||||
}
|
|
||||||
if (isJellyfinContainerType(selectedChild.type)) {
|
|
||||||
return { kind: 'container', id: selectedChild.id };
|
|
||||||
}
|
|
||||||
fail('Selected Jellyfin item is not playable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAppJellyfinListCommand(
|
|
||||||
appPath: string,
|
|
||||||
args: Args,
|
|
||||||
appArgs: string[],
|
|
||||||
label: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
|
|
||||||
if (attempt.status !== 0) {
|
|
||||||
const message = attempt.output.trim();
|
|
||||||
fail(message || `${label} failed.`);
|
|
||||||
}
|
|
||||||
if (attempt.error) {
|
|
||||||
fail(attempt.error);
|
|
||||||
}
|
|
||||||
return attempt.output;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAppJellyfinCommand(
|
|
||||||
appPath: string,
|
|
||||||
args: Args,
|
|
||||||
appArgs: string[],
|
|
||||||
label: string,
|
|
||||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
|
||||||
const forwardedBase = [...appArgs];
|
|
||||||
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
|
|
||||||
if (serverOverride) {
|
|
||||||
forwardedBase.push('--jellyfin-server', serverOverride);
|
|
||||||
}
|
|
||||||
if (args.passwordStore) {
|
|
||||||
forwardedBase.push('--password-store', args.passwordStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readLogAppendedSince = (offset: number): string => {
|
|
||||||
const logPath = getMpvLogPath();
|
|
||||||
return readUtf8FileAppendedSince(logPath, offset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasCommandSignal = (output: string): boolean => {
|
|
||||||
if (label === 'jellyfin-libraries') {
|
|
||||||
return output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.');
|
|
||||||
}
|
|
||||||
if (label === 'jellyfin-items') {
|
|
||||||
return (
|
|
||||||
output.includes('Jellyfin item:') ||
|
|
||||||
output.includes('No Jellyfin items found for the selected library/search.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (label === 'jellyfin-preview-auth') {
|
|
||||||
return output.includes('Jellyfin preview auth written.');
|
|
||||||
}
|
|
||||||
return output.trim().length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
|
|
||||||
const forwarded = [...forwardedBase];
|
|
||||||
const logPath = getMpvLogPath();
|
|
||||||
let logOffset = 0;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(logPath)) {
|
|
||||||
logOffset = fs.statSync(logPath).size;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logOffset = 0;
|
|
||||||
}
|
|
||||||
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
|
|
||||||
const result = runAppCommandCaptureOutput(appPath, forwarded);
|
|
||||||
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
|
|
||||||
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
|
|
||||||
let error = parseJellyfinErrorFromAppOutput(output);
|
|
||||||
|
|
||||||
return { status: result.status, output, error, logOffset };
|
|
||||||
};
|
|
||||||
|
|
||||||
let retriedAfterStart = false;
|
|
||||||
let attempt = runOnce();
|
|
||||||
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
|
|
||||||
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
|
|
||||||
launchAppStartDetached(appPath, args.logLevel);
|
|
||||||
await sleep(1000);
|
|
||||||
retriedAfterStart = true;
|
|
||||||
attempt = runOnce();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
|
|
||||||
// When app is already running, command handling happens in the primary process and log
|
|
||||||
// lines can land slightly after the helper process exits.
|
|
||||||
const settleWindowMs = (() => {
|
|
||||||
if (label === 'jellyfin-items') {
|
|
||||||
return retriedAfterStart ? 45000 : 30000;
|
|
||||||
}
|
|
||||||
return retriedAfterStart ? 12000 : 4000;
|
|
||||||
})();
|
|
||||||
const settleDeadline = Date.now() + settleWindowMs;
|
|
||||||
const settleOffset = attempt.logOffset;
|
|
||||||
while (Date.now() < settleDeadline) {
|
|
||||||
await sleep(100);
|
|
||||||
const settledOutput = readLogAppendedSince(settleOffset);
|
|
||||||
if (!settledOutput.trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
attempt.output = `${attempt.output}\n${settledOutput}`;
|
|
||||||
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
|
|
||||||
if (attempt.error || hasCommandSignal(attempt.output)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return attempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJellyfinPreviewAuthFromApp(
|
|
||||||
appPath: string,
|
|
||||||
args: Args,
|
|
||||||
): Promise<JellyfinPreviewAuthResponse | null> {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
|
|
||||||
const responsePath = path.join(tmpDir, 'response.json');
|
|
||||||
try {
|
|
||||||
const attempt = await runAppJellyfinCommand(
|
|
||||||
appPath,
|
|
||||||
args,
|
|
||||||
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
|
|
||||||
'jellyfin-preview-auth',
|
|
||||||
);
|
|
||||||
if (attempt.status !== 0 || attempt.error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadline = Date.now() + 4000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(responsePath)) {
|
|
||||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
|
||||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
|
||||||
if (parsed) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// retry until timeout
|
|
||||||
}
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveJellyfinSelectionViaApp(
|
|
||||||
appPath: string,
|
|
||||||
args: Args,
|
|
||||||
session: JellyfinSessionConfig,
|
|
||||||
themePath: string | null = null,
|
|
||||||
): Promise<string> {
|
|
||||||
const listLibrariesOutput = await runAppJellyfinListCommand(
|
|
||||||
appPath,
|
|
||||||
args,
|
|
||||||
['--jellyfin-libraries'],
|
|
||||||
'jellyfin-libraries',
|
|
||||||
);
|
|
||||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
|
||||||
if (libraries.length === 0) {
|
|
||||||
fail('No Jellyfin libraries found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconlessSession: JellyfinSessionConfig = {
|
|
||||||
...session,
|
|
||||||
userId: session.userId || 'launcher',
|
|
||||||
};
|
|
||||||
const noIcon = (): string | null => null;
|
|
||||||
const hasPreviewSession = Boolean(iconlessSession.serverUrl && iconlessSession.accessToken);
|
|
||||||
const pickerSession: JellyfinSessionConfig = {
|
|
||||||
...iconlessSession,
|
|
||||||
pullPictures: hasPreviewSession && iconlessSession.pullPictures === true,
|
|
||||||
};
|
|
||||||
const ensureIconForPicker = hasPreviewSession ? ensureJellyfinIcon : noIcon;
|
|
||||||
if (!hasPreviewSession) {
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
args.logLevel,
|
|
||||||
'Jellyfin picker image previews disabled (no launcher-accessible Jellyfin token).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredDefaultLibraryId = session.defaultLibraryId;
|
|
||||||
const hasConfiguredDefault = libraries.some((library) => library.id === configuredDefaultLibraryId);
|
|
||||||
let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : '';
|
|
||||||
if (!libraryId) {
|
|
||||||
libraryId = pickLibrary(
|
|
||||||
pickerSession,
|
|
||||||
libraries,
|
|
||||||
args.useRofi,
|
|
||||||
ensureIconForPicker,
|
|
||||||
'',
|
|
||||||
themePath,
|
|
||||||
);
|
|
||||||
if (!libraryId) fail('No Jellyfin library selected.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
|
|
||||||
const normalizedSearch = searchTerm.trim();
|
|
||||||
const searchLimit = 400;
|
|
||||||
const browseLimit = 2500;
|
|
||||||
const rootIncludeItemTypes = 'Series,Season,Folder,CollectionFolder,Movie,Video,MusicVideo';
|
|
||||||
const directoryIncludeItemTypes =
|
|
||||||
'Series,Season,Folder,CollectionFolder,Movie,Episode,Audio,Video,MusicVideo';
|
|
||||||
const recursivePlayableIncludeItemTypes = 'Movie,Episode,Audio,Video,MusicVideo';
|
|
||||||
const listItemsViaApp = async (
|
|
||||||
parentId: string,
|
|
||||||
options: {
|
|
||||||
search?: string;
|
|
||||||
limit: number;
|
|
||||||
recursive?: boolean;
|
|
||||||
includeItemTypes?: string;
|
|
||||||
},
|
|
||||||
): Promise<JellyfinItemEntry[]> => {
|
|
||||||
const itemArgs = [
|
|
||||||
'--jellyfin-items',
|
|
||||||
`--jellyfin-library-id=${parentId}`,
|
|
||||||
`--jellyfin-limit=${Math.max(1, options.limit)}`,
|
|
||||||
];
|
|
||||||
const normalized = (options.search || '').trim();
|
|
||||||
if (normalized.length > 0) {
|
|
||||||
itemArgs.push(`--jellyfin-search=${normalized}`);
|
|
||||||
}
|
|
||||||
if (typeof options.recursive === 'boolean') {
|
|
||||||
itemArgs.push(`--jellyfin-recursive=${options.recursive ? 'true' : 'false'}`);
|
|
||||||
}
|
|
||||||
const includeItemTypes = options.includeItemTypes?.trim();
|
|
||||||
if (includeItemTypes) {
|
|
||||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
|
||||||
}
|
|
||||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
|
||||||
return parseJellyfinItemsFromAppOutput(output);
|
|
||||||
};
|
|
||||||
|
|
||||||
let rootItems =
|
|
||||||
normalizedSearch.length > 0
|
|
||||||
? await listItemsViaApp(libraryId, {
|
|
||||||
search: normalizedSearch,
|
|
||||||
limit: searchLimit,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: rootIncludeItemTypes,
|
|
||||||
})
|
|
||||||
: await listItemsViaApp(libraryId, {
|
|
||||||
limit: browseLimit,
|
|
||||||
recursive: false,
|
|
||||||
includeItemTypes: rootIncludeItemTypes,
|
|
||||||
});
|
|
||||||
if (normalizedSearch.length > 0 && rootItems.length === 0) {
|
|
||||||
// Compatibility fallback for older app binaries that may ignore custom search include types.
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
args.logLevel,
|
|
||||||
`jellyfin-items: no direct search hits for "${normalizedSearch}", falling back to unfiltered library query`,
|
|
||||||
);
|
|
||||||
rootItems = await listItemsViaApp(libraryId, {
|
|
||||||
limit: browseLimit,
|
|
||||||
recursive: false,
|
|
||||||
includeItemTypes: rootIncludeItemTypes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const rootGroups = buildRootSearchGroups(rootItems);
|
|
||||||
if (rootGroups.length === 0) {
|
|
||||||
fail('No Jellyfin shows or movies found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootById = new Map(rootGroups.map((group) => [group.id, group]));
|
|
||||||
const selectedRootId = pickGroup(
|
|
||||||
pickerSession,
|
|
||||||
rootGroups,
|
|
||||||
args.useRofi,
|
|
||||||
ensureIconForPicker,
|
|
||||||
normalizedSearch,
|
|
||||||
themePath,
|
|
||||||
);
|
|
||||||
if (!selectedRootId) fail('No Jellyfin show/movie selected.');
|
|
||||||
const selectedRoot = rootById.get(selectedRootId);
|
|
||||||
if (!selectedRoot) fail('Invalid Jellyfin root selection.');
|
|
||||||
|
|
||||||
if (isJellyfinPlayableType(selectedRoot.type)) {
|
|
||||||
return selectedRoot.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickPlayableDescendants = async (parentId: string): Promise<string> => {
|
|
||||||
const descendantItems = await listItemsViaApp(parentId, {
|
|
||||||
limit: browseLimit,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: recursivePlayableIncludeItemTypes,
|
|
||||||
});
|
|
||||||
const playableItems = descendantItems.filter((item) => isJellyfinPlayableType(item.type));
|
|
||||||
if (playableItems.length === 0) {
|
|
||||||
fail('No playable Jellyfin items found.');
|
|
||||||
}
|
|
||||||
const selectedItemId = pickItem(
|
|
||||||
pickerSession,
|
|
||||||
playableItems,
|
|
||||||
args.useRofi,
|
|
||||||
ensureIconForPicker,
|
|
||||||
'',
|
|
||||||
themePath,
|
|
||||||
);
|
|
||||||
if (!selectedItemId) {
|
|
||||||
fail('No Jellyfin item selected.');
|
|
||||||
}
|
|
||||||
return selectedItemId;
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentContainerId = selectedRoot.id;
|
|
||||||
while (true) {
|
|
||||||
const directoryEntries = await listItemsViaApp(currentContainerId, {
|
|
||||||
limit: browseLimit,
|
|
||||||
recursive: false,
|
|
||||||
includeItemTypes: directoryIncludeItemTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
const childGroups: JellyfinGroupEntry[] = [];
|
|
||||||
for (const item of directoryEntries) {
|
|
||||||
if (!item.id || seenIds.has(item.id)) continue;
|
|
||||||
if (!isJellyfinContainerType(item.type) && !isJellyfinPlayableType(item.type)) continue;
|
|
||||||
seenIds.add(item.id);
|
|
||||||
childGroups.push({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
type: item.type,
|
|
||||||
display: `${item.name} (${item.type})`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (childGroups.length === 0) {
|
|
||||||
return await pickPlayableDescendants(currentContainerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const childById = new Map(childGroups.map((group) => [group.id, group]));
|
|
||||||
const selectedChildId = pickGroup(
|
|
||||||
pickerSession,
|
|
||||||
childGroups,
|
|
||||||
args.useRofi,
|
|
||||||
ensureIconForPicker,
|
|
||||||
'',
|
|
||||||
themePath,
|
|
||||||
);
|
|
||||||
if (!selectedChildId) fail('No Jellyfin folder/file selected.');
|
|
||||||
const selectedChild = childById.get(selectedChildId);
|
|
||||||
if (!selectedChild) fail('Invalid Jellyfin item selection.');
|
|
||||||
const selection = classifyJellyfinChildSelection(selectedChild);
|
|
||||||
if (selection.kind === 'playable') {
|
|
||||||
return selection.id;
|
|
||||||
}
|
|
||||||
currentContainerId = selection.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveJellyfinSelection(
|
export async function resolveJellyfinSelection(
|
||||||
args: Args,
|
args: Args,
|
||||||
session: JellyfinSessionConfig,
|
session: JellyfinSessionConfig,
|
||||||
@@ -972,37 +367,18 @@ export async function runJellyfinPlayMenu(
|
|||||||
iconCacheDir: config.iconCacheDir || '',
|
iconCacheDir: config.iconCacheDir || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
||||||
|
fail(
|
||||||
|
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||||
if (args.useRofi && !rofiTheme) {
|
if (args.useRofi && !rofiTheme) {
|
||||||
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||||
let itemId = '';
|
|
||||||
if (hasDirectSession) {
|
|
||||||
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
|
||||||
} else {
|
|
||||||
const configPath = resolveLauncherMainConfigPath();
|
|
||||||
if (!hasStoredJellyfinSession(configPath)) {
|
|
||||||
fail(
|
|
||||||
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
|
|
||||||
if (previewAuth) {
|
|
||||||
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
|
||||||
session.accessToken = previewAuth.accessToken;
|
|
||||||
session.userId = previewAuth.userId || session.userId;
|
|
||||||
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
args.logLevel,
|
|
||||||
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
|
||||||
}
|
|
||||||
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||||
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||||
let mpvReady = false;
|
let mpvReady = false;
|
||||||
@@ -1017,7 +393,7 @@ export async function runJellyfinPlayMenu(
|
|||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||||
}
|
}
|
||||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
||||||
import {
|
|
||||||
parseJellyfinLibrariesFromAppOutput,
|
|
||||||
parseJellyfinItemsFromAppOutput,
|
|
||||||
parseJellyfinErrorFromAppOutput,
|
|
||||||
parseJellyfinPreviewAuthResponse,
|
|
||||||
deriveJellyfinTokenStorePath,
|
|
||||||
hasStoredJellyfinSession,
|
|
||||||
shouldRetryWithStartForNoRunningInstance,
|
|
||||||
readUtf8FileAppendedSince,
|
|
||||||
parseEpisodePathFromDisplay,
|
|
||||||
buildRootSearchGroups,
|
|
||||||
classifyJellyfinChildSelection,
|
|
||||||
} from './jellyfin.js';
|
|
||||||
|
|
||||||
type RunResult = {
|
type RunResult = {
|
||||||
status: number | null;
|
status: number | null;
|
||||||
@@ -162,7 +149,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
test('jellyfin discovery routes to app --start with log-level forwarding', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -182,37 +169,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
|||||||
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
|
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
|
||||||
fs.readFileSync(capturePath, 'utf8'),
|
|
||||||
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const homeDir = path.join(root, 'home');
|
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
|
||||||
const appPath = path.join(root, 'fake-subminer.sh');
|
|
||||||
const capturePath = path.join(root, 'captured-args.txt');
|
|
||||||
fs.writeFileSync(
|
|
||||||
appPath,
|
|
||||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
|
||||||
);
|
|
||||||
fs.chmodSync(appPath, 0o755);
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
|
||||||
};
|
|
||||||
const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
|
||||||
assert.equal(
|
|
||||||
fs.readFileSync(capturePath, 'utf8'),
|
|
||||||
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,174 +238,3 @@ test('jellyfin setup forwards password-store to app command', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
|
|
||||||
const parsed = parseJellyfinLibrariesFromAppOutput(`
|
|
||||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
|
|
||||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
|
|
||||||
`);
|
|
||||||
|
|
||||||
assert.deepEqual(parsed, [
|
|
||||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
|
||||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
|
||||||
const parsed = parseJellyfinItemsFromAppOutput(`
|
|
||||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
|
|
||||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
|
|
||||||
`);
|
|
||||||
|
|
||||||
assert.deepEqual(parsed, [
|
|
||||||
{
|
|
||||||
id: 'item-10',
|
|
||||||
name: 'Solo Leveling S01E10',
|
|
||||||
type: 'Episode',
|
|
||||||
display: 'Solo Leveling S01E10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'movie-1',
|
|
||||||
name: 'Movie [Alt]',
|
|
||||||
type: 'Movie',
|
|
||||||
display: 'Movie [Alt]',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
|
||||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
|
||||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
|
||||||
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
|
|
||||||
`);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
parsed,
|
|
||||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
|
|
||||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
|
||||||
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
|
|
||||||
`);
|
|
||||||
|
|
||||||
assert.equal(parsed, '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
|
||||||
const parsed = parseJellyfinPreviewAuthResponse(
|
|
||||||
JSON.stringify({
|
|
||||||
serverUrl: 'http://pve-main:8096/',
|
|
||||||
accessToken: 'token-123',
|
|
||||||
userId: 'user-1',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(parsed, {
|
|
||||||
serverUrl: 'http://pve-main:8096',
|
|
||||||
accessToken: 'token-123',
|
|
||||||
userId: 'user-1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
|
||||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
|
||||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
|
||||||
assert.equal(
|
|
||||||
parseJellyfinPreviewAuthResponse(
|
|
||||||
JSON.stringify({
|
|
||||||
serverUrl: 'http://pve-main:8096',
|
|
||||||
accessToken: '',
|
|
||||||
userId: 'user-1',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
|
|
||||||
const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc');
|
|
||||||
assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hasStoredJellyfinSession checks token-store existence', () => {
|
|
||||||
const exists = (candidate: string): boolean =>
|
|
||||||
candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json';
|
|
||||||
assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true);
|
|
||||||
assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
|
|
||||||
assert.equal(
|
|
||||||
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
shouldRetryWithStartForNoRunningInstance('Missing Jellyfin session. Run --jellyfin-login first.'),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const logPath = path.join(root, 'SubMiner.log');
|
|
||||||
const prefix = '[subminer] こんにちは\n';
|
|
||||||
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
|
|
||||||
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
|
|
||||||
|
|
||||||
const byteOffset = Buffer.byteLength(prefix, 'utf8');
|
|
||||||
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
|
|
||||||
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
|
||||||
|
|
||||||
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
|
|
||||||
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
|
||||||
assert.deepEqual(parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), {
|
|
||||||
seriesName: 'KONOSUBA',
|
|
||||||
seasonNumber: 1,
|
|
||||||
});
|
|
||||||
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
|
||||||
seriesName: 'Frieren',
|
|
||||||
seasonNumber: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
|
|
||||||
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
|
|
||||||
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
|
|
||||||
const groups = buildRootSearchGroups([
|
|
||||||
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
|
|
||||||
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
|
|
||||||
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.deepEqual(groups, [
|
|
||||||
{
|
|
||||||
id: 'series-1',
|
|
||||||
name: 'The Eminence in Shadow',
|
|
||||||
type: 'Series',
|
|
||||||
display: 'The Eminence in Shadow (Series)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'movie-1',
|
|
||||||
name: 'Spirited Away',
|
|
||||||
type: 'Movie',
|
|
||||||
display: 'Spirited Away (Movie)',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
|
|
||||||
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
|
|
||||||
assert.deepEqual(next, {
|
|
||||||
kind: 'container',
|
|
||||||
id: 'season-2',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
import { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
|
import { startOverlay, state, waitForUnixSocketReady } from './mpv';
|
||||||
import * as mpvModule from './mpv';
|
import * as mpvModule from './mpv';
|
||||||
|
|
||||||
function createTempSocketPath(): { dir: string; socketPath: string } {
|
function createTempSocketPath(): { dir: string; socketPath: string } {
|
||||||
@@ -19,18 +19,6 @@ test('mpv module exposes only canonical socket readiness helper', () => {
|
|||||||
assert.equal('waitForSocket' in mpvModule, false);
|
assert.equal('waitForSocket' in mpvModule, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppCommandCaptureOutput captures status and stdio', () => {
|
|
||||||
const result = runAppCommandCaptureOutput(process.execPath, [
|
|
||||||
'-e',
|
|
||||||
'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
|
||||||
assert.equal(result.stdout, 'stdout-line');
|
|
||||||
assert.equal(result.stderr, 'stderr-line');
|
|
||||||
assert.equal(result.error, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -658,28 +658,6 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): ne
|
|||||||
process.exit(result.status ?? 0);
|
process.exit(result.status ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAppCommandCaptureOutput(
|
|
||||||
appPath: string,
|
|
||||||
appArgs: string[],
|
|
||||||
): {
|
|
||||||
status: number;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
error?: Error;
|
|
||||||
} {
|
|
||||||
const result = spawnSync(appPath, appArgs, {
|
|
||||||
env: buildAppEnv(),
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: result.status ?? 1,
|
|
||||||
stdout: result.stdout ?? '',
|
|
||||||
stderr: result.stderr ?? '',
|
|
||||||
error: result.error ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runAppCommandWithInheritLogged(
|
export function runAppCommandWithInheritLogged(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
appArgs: string[],
|
appArgs: string[],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
|
|||||||
@@ -42,30 +42,6 @@ test('parseArgs ignores missing value after --log-level', () => {
|
|||||||
assert.equal(args.start, true);
|
assert.equal(args.start, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs handles jellyfin item listing controls', () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
'--jellyfin-items',
|
|
||||||
'--jellyfin-recursive=false',
|
|
||||||
'--jellyfin-include-item-types',
|
|
||||||
'Series,Movie,Folder',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.jellyfinItems, true);
|
|
||||||
assert.equal(args.jellyfinRecursive, false);
|
|
||||||
assert.equal(args.jellyfinIncludeItemTypes, 'Series,Movie,Folder');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs handles space-separated jellyfin recursive control', () => {
|
|
||||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', 'false']);
|
|
||||||
assert.equal(args.jellyfinRecursive, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs ignores unrecognized space-separated jellyfin recursive values', () => {
|
|
||||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', '--start']);
|
|
||||||
assert.equal(args.jellyfinRecursive, undefined);
|
|
||||||
assert.equal(args.start, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||||
const stopOnly = parseArgs(['--stop']);
|
const stopOnly = parseArgs(['--stop']);
|
||||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||||
@@ -142,19 +118,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
||||||
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
||||||
|
|
||||||
const jellyfinPreviewAuth = parseArgs([
|
|
||||||
'--jellyfin-preview-auth',
|
|
||||||
'--jellyfin-response-path',
|
|
||||||
'/tmp/subminer-jf-response.json',
|
|
||||||
]);
|
|
||||||
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
|
|
||||||
assert.equal(
|
|
||||||
jellyfinPreviewAuth.jellyfinResponsePath,
|
|
||||||
'/tmp/subminer-jf-response.json',
|
|
||||||
);
|
|
||||||
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
|
|
||||||
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
|
|
||||||
|
|
||||||
const background = parseArgs(['--background']);
|
const background = parseArgs(['--background']);
|
||||||
assert.equal(background.background, true);
|
assert.equal(background.background, true);
|
||||||
assert.equal(hasExplicitCommand(background), true);
|
assert.equal(hasExplicitCommand(background), true);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export interface CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: boolean;
|
jellyfinSubtitleUrlsOnly: boolean;
|
||||||
jellyfinPlay: boolean;
|
jellyfinPlay: boolean;
|
||||||
jellyfinRemoteAnnounce: boolean;
|
jellyfinRemoteAnnounce: boolean;
|
||||||
jellyfinPreviewAuth: boolean;
|
|
||||||
texthooker: boolean;
|
texthooker: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
autoStartOverlay: boolean;
|
autoStartOverlay: boolean;
|
||||||
@@ -50,11 +49,8 @@ export interface CliArgs {
|
|||||||
jellyfinItemId?: string;
|
jellyfinItemId?: string;
|
||||||
jellyfinSearch?: string;
|
jellyfinSearch?: string;
|
||||||
jellyfinLimit?: number;
|
jellyfinLimit?: number;
|
||||||
jellyfinRecursive?: boolean;
|
|
||||||
jellyfinIncludeItemTypes?: string;
|
|
||||||
jellyfinAudioStreamIndex?: number;
|
jellyfinAudioStreamIndex?: number;
|
||||||
jellyfinSubtitleStreamIndex?: number;
|
jellyfinSubtitleStreamIndex?: number;
|
||||||
jellyfinResponsePath?: string;
|
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
}
|
}
|
||||||
@@ -97,7 +93,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: false,
|
jellyfinSubtitleUrlsOnly: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinRemoteAnnounce: false,
|
jellyfinRemoteAnnounce: false,
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
@@ -152,7 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
args.jellyfinSubtitleUrlsOnly = true;
|
args.jellyfinSubtitleUrlsOnly = true;
|
||||||
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
||||||
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
||||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
|
||||||
else if (arg === '--texthooker') args.texthooker = true;
|
else if (arg === '--texthooker') args.texthooker = true;
|
||||||
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||||
else if (arg === '--generate-config') args.generateConfig = true;
|
else if (arg === '--generate-config') args.generateConfig = true;
|
||||||
@@ -235,25 +229,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--jellyfin-limit') {
|
} else if (arg === '--jellyfin-limit') {
|
||||||
const value = Number(readValue(argv[i + 1]));
|
const value = Number(readValue(argv[i + 1]));
|
||||||
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
||||||
} else if (arg.startsWith('--jellyfin-recursive=')) {
|
|
||||||
const value = arg.split('=', 2)[1]?.trim().toLowerCase();
|
|
||||||
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
|
|
||||||
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
|
|
||||||
} else if (arg === '--jellyfin-recursive') {
|
|
||||||
const value = readValue(argv[i + 1])?.trim().toLowerCase();
|
|
||||||
if (value === 'false' || value === '0' || value === 'no') {
|
|
||||||
args.jellyfinRecursive = false;
|
|
||||||
} else if (value === 'true' || value === '1' || value === 'yes') {
|
|
||||||
args.jellyfinRecursive = true;
|
|
||||||
}
|
|
||||||
} else if (arg === '--jellyfin-non-recursive') {
|
|
||||||
args.jellyfinRecursive = false;
|
|
||||||
} else if (arg.startsWith('--jellyfin-include-item-types=')) {
|
|
||||||
const value = arg.split('=', 2)[1];
|
|
||||||
if (value) args.jellyfinIncludeItemTypes = value;
|
|
||||||
} else if (arg === '--jellyfin-include-item-types') {
|
|
||||||
const value = readValue(argv[i + 1]);
|
|
||||||
if (value) args.jellyfinIncludeItemTypes = value;
|
|
||||||
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
||||||
@@ -266,12 +241,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
||||||
const value = Number(readValue(argv[i + 1]));
|
const value = Number(readValue(argv[i + 1]));
|
||||||
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
||||||
} else if (arg.startsWith('--jellyfin-response-path=')) {
|
|
||||||
const value = arg.split('=', 2)[1];
|
|
||||||
if (value) args.jellyfinResponsePath = value;
|
|
||||||
} else if (arg === '--jellyfin-response-path') {
|
|
||||||
const value = readValue(argv[i + 1]);
|
|
||||||
if (value) args.jellyfinResponsePath = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +282,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.jellyfinSubtitles ||
|
args.jellyfinSubtitles ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.jellyfinRemoteAnnounce ||
|
args.jellyfinRemoteAnnounce ||
|
||||||
args.jellyfinPreviewAuth ||
|
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.generateConfig ||
|
args.generateConfig ||
|
||||||
args.help
|
args.help
|
||||||
@@ -382,7 +350,6 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.jellyfinSubtitles &&
|
!args.jellyfinSubtitles &&
|
||||||
!args.jellyfinPlay &&
|
!args.jellyfinPlay &&
|
||||||
!args.jellyfinRemoteAnnounce &&
|
!args.jellyfinRemoteAnnounce &&
|
||||||
!args.jellyfinPreviewAuth &&
|
|
||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
parseKikuFieldGroupingChoice,
|
parseKikuFieldGroupingChoice,
|
||||||
parseKikuMergePreviewRequest,
|
parseKikuMergePreviewRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
|
||||||
|
|
||||||
const logger = createLogger('main:anki-jimaku-ipc');
|
const logger = createLogger('main:anki-jimaku-ipc');
|
||||||
|
|
||||||
@@ -149,11 +148,10 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
if (!safeName) {
|
if (!safeName) {
|
||||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||||
}
|
}
|
||||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
|
|
||||||
|
|
||||||
const ext = path.extname(subtitleFilename);
|
const ext = path.extname(safeName);
|
||||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
let targetPath = path.join(mediaDir, safeName);
|
||||||
if (fs.existsSync(targetPath)) {
|
if (fs.existsSync(targetPath)) {
|
||||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
||||||
let counter = 2;
|
let counter = 2;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { CliArgs } from '../../cli/args';
|
|
||||||
import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
|
|
||||||
|
|
||||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|
||||||
return {
|
|
||||||
background: false,
|
|
||||||
start: false,
|
|
||||||
stop: false,
|
|
||||||
toggle: false,
|
|
||||||
toggleVisibleOverlay: false,
|
|
||||||
settings: false,
|
|
||||||
show: false,
|
|
||||||
hide: false,
|
|
||||||
showVisibleOverlay: false,
|
|
||||||
hideVisibleOverlay: false,
|
|
||||||
copySubtitle: false,
|
|
||||||
copySubtitleMultiple: false,
|
|
||||||
mineSentence: false,
|
|
||||||
mineSentenceMultiple: false,
|
|
||||||
updateLastCardFromClipboard: false,
|
|
||||||
refreshKnownWords: false,
|
|
||||||
toggleSecondarySub: false,
|
|
||||||
triggerFieldGrouping: false,
|
|
||||||
triggerSubsync: false,
|
|
||||||
markAudioCard: false,
|
|
||||||
openRuntimeOptions: false,
|
|
||||||
anilistStatus: false,
|
|
||||||
anilistLogout: false,
|
|
||||||
anilistSetup: false,
|
|
||||||
anilistRetryQueue: false,
|
|
||||||
jellyfin: false,
|
|
||||||
jellyfinLogin: false,
|
|
||||||
jellyfinLogout: false,
|
|
||||||
jellyfinLibraries: false,
|
|
||||||
jellyfinItems: false,
|
|
||||||
jellyfinSubtitles: false,
|
|
||||||
jellyfinSubtitleUrlsOnly: false,
|
|
||||||
jellyfinPlay: false,
|
|
||||||
jellyfinRemoteAnnounce: false,
|
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
|
||||||
help: false,
|
|
||||||
autoStartOverlay: false,
|
|
||||||
generateConfig: false,
|
|
||||||
backupOverwrite: false,
|
|
||||||
debug: false,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let lockCalls = 0;
|
|
||||||
|
|
||||||
const deps: AppLifecycleServiceDeps = {
|
|
||||||
shouldStartApp: () => false,
|
|
||||||
parseArgs: () => makeArgs(),
|
|
||||||
requestSingleInstanceLock: () => {
|
|
||||||
lockCalls += 1;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
quitApp: () => {
|
|
||||||
calls.push('quitApp');
|
|
||||||
},
|
|
||||||
onSecondInstance: () => {},
|
|
||||||
handleCliCommand: () => {},
|
|
||||||
printHelp: () => {
|
|
||||||
calls.push('printHelp');
|
|
||||||
},
|
|
||||||
logNoRunningInstance: () => {
|
|
||||||
calls.push('logNoRunningInstance');
|
|
||||||
},
|
|
||||||
whenReady: () => {},
|
|
||||||
onWindowAllClosed: () => {},
|
|
||||||
onWillQuit: () => {},
|
|
||||||
onActivate: () => {},
|
|
||||||
isDarwinPlatform: () => false,
|
|
||||||
onReady: async () => {},
|
|
||||||
onWillQuitCleanup: () => {},
|
|
||||||
shouldRestoreWindowsOnActivate: () => false,
|
|
||||||
restoreWindowsOnActivate: () => {},
|
|
||||||
shouldQuitOnWindowAllClosed: () => true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deps, calls, getLockCalls: () => lockCalls };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('startAppLifecycle handles --help without acquiring single-instance lock', () => {
|
|
||||||
const { deps, calls, getLockCalls } = createDeps({
|
|
||||||
shouldStartApp: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
startAppLifecycle(makeArgs({ help: true }), deps);
|
|
||||||
|
|
||||||
assert.equal(getLockCalls(), 0);
|
|
||||||
assert.deepEqual(calls, ['printHelp', 'quitApp']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startAppLifecycle still acquires lock for startup commands', () => {
|
|
||||||
const { deps, getLockCalls } = createDeps({
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
whenReady: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
startAppLifecycle(makeArgs({ start: true }), deps);
|
|
||||||
|
|
||||||
assert.equal(getLockCalls(), 1);
|
|
||||||
});
|
|
||||||
@@ -87,12 +87,6 @@ export function createAppLifecycleDepsRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
|
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
|
||||||
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
|
||||||
deps.printHelp();
|
|
||||||
deps.quitApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotTheLock = deps.requestSingleInstanceLock();
|
const gotTheLock = deps.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
@@ -107,6 +101,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
||||||
|
deps.printHelp();
|
||||||
|
deps.quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!deps.shouldStartApp(initialArgs)) {
|
if (!deps.shouldStartApp(initialArgs)) {
|
||||||
if (initialArgs.stop && !initialArgs.start) {
|
if (initialArgs.stop && !initialArgs.start) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
assert.equal(calls.includes('logConfigWarning'), false);
|
assert.equal(calls.includes('logConfigWarning'), false);
|
||||||
assert.equal(calls.includes('handleInitialArgs'), true);
|
assert.equal(calls.includes('handleInitialArgs'), true);
|
||||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
assert.equal(calls.includes('loadYomitanExtension'), true);
|
||||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
assert.equal(calls[0], 'loadYomitanExtension');
|
||||||
|
assert.equal(calls[calls.length - 1], 'handleInitialArgs');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: false,
|
jellyfinSubtitleUrlsOnly: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinRemoteAnnounce: false,
|
jellyfinRemoteAnnounce: false,
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ test('listItems supports search and formats title', async () => {
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = (async (input) => {
|
globalThis.fetch = (async (input) => {
|
||||||
assert.match(String(input), /SearchTerm=planet/);
|
assert.match(String(input), /SearchTerm=planet/);
|
||||||
assert.match(
|
|
||||||
String(input),
|
|
||||||
/IncludeItemTypes=Movie%2CEpisode%2CAudio%2CSeries%2CSeason%2CFolder%2CCollectionFolder/,
|
|
||||||
);
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
Items: [
|
Items: [
|
||||||
@@ -129,64 +125,6 @@ test('listItems supports search and formats title', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listItems keeps playable-only include types when search is empty', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async (input) => {
|
|
||||||
assert.match(String(input), /IncludeItemTypes=Movie%2CEpisode%2CAudio/);
|
|
||||||
assert.doesNotMatch(String(input), /CollectionFolder|Series|Season|Folder/);
|
|
||||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await listItems(
|
|
||||||
{
|
|
||||||
serverUrl: 'http://jellyfin.local',
|
|
||||||
accessToken: 'token',
|
|
||||||
userId: 'u1',
|
|
||||||
username: 'kyle',
|
|
||||||
},
|
|
||||||
clientInfo,
|
|
||||||
{
|
|
||||||
libraryId: 'lib-1',
|
|
||||||
limit: 25,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.deepEqual(items, []);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('listItems accepts explicit include types and recursive mode', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async (input) => {
|
|
||||||
assert.match(String(input), /Recursive=false/);
|
|
||||||
assert.match(String(input), /IncludeItemTypes=Series%2CMovie%2CFolder/);
|
|
||||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await listItems(
|
|
||||||
{
|
|
||||||
serverUrl: 'http://jellyfin.local',
|
|
||||||
accessToken: 'token',
|
|
||||||
userId: 'u1',
|
|
||||||
username: 'kyle',
|
|
||||||
},
|
|
||||||
clientInfo,
|
|
||||||
{
|
|
||||||
libraryId: 'lib-1',
|
|
||||||
includeItemTypes: 'Series,Movie,Folder',
|
|
||||||
recursive: false,
|
|
||||||
limit: 25,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.deepEqual(items, []);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
|
|||||||
@@ -370,29 +370,21 @@ export async function listItems(
|
|||||||
libraryId: string;
|
libraryId: string;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
recursive?: boolean;
|
|
||||||
includeItemTypes?: string;
|
|
||||||
},
|
},
|
||||||
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
|
): Promise<Array<{ id: string; name: string; type: string; title: string }>> {
|
||||||
if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
|
if (!options.libraryId) throw new Error('Missing Jellyfin library id.');
|
||||||
const normalizedSearchTerm = options.searchTerm?.trim() || '';
|
|
||||||
const includeItemTypes =
|
|
||||||
options.includeItemTypes?.trim() ||
|
|
||||||
(normalizedSearchTerm
|
|
||||||
? 'Movie,Episode,Audio,Series,Season,Folder,CollectionFolder'
|
|
||||||
: 'Movie,Episode,Audio');
|
|
||||||
|
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
ParentId: options.libraryId,
|
ParentId: options.libraryId,
|
||||||
Recursive: options.recursive === false ? 'false' : 'true',
|
Recursive: 'true',
|
||||||
IncludeItemTypes: includeItemTypes,
|
IncludeItemTypes: 'Movie,Episode,Audio',
|
||||||
Fields: 'MediaSources,UserData',
|
Fields: 'MediaSources,UserData',
|
||||||
SortBy: 'SortName',
|
SortBy: 'SortName',
|
||||||
SortOrder: 'Ascending',
|
SortOrder: 'Ascending',
|
||||||
Limit: String(options.limit ?? 100),
|
Limit: String(options.limit ?? 100),
|
||||||
});
|
});
|
||||||
if (normalizedSearchTerm) {
|
if (options.searchTerm?.trim()) {
|
||||||
query.set('SearchTerm', normalizedSearchTerm);
|
query.set('SearchTerm', options.searchTerm.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path.js';
|
|
||||||
|
|
||||||
test('buildJimakuSubtitleFilenameFromMediaPath uses media basename + ja + subtitle extension', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs.Release.1080p.srt'),
|
|
||||||
'anime.ja.srt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildJimakuSubtitleFilenameFromMediaPath falls back to .srt when subtitle name has no extension', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs Release'),
|
|
||||||
'anime.ja.srt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildJimakuSubtitleFilenameFromMediaPath supports remote media URLs', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildJimakuSubtitleFilenameFromMediaPath(
|
|
||||||
'https://cdn.example.org/library/Anime%20Episode%2001.mkv?token=abc',
|
|
||||||
'anything.ass',
|
|
||||||
),
|
|
||||||
'Anime Episode 01.ja.ass',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
const DEFAULT_JIMAKU_LANGUAGE_SUFFIX = 'ja';
|
|
||||||
const DEFAULT_SUBTITLE_EXTENSION = '.srt';
|
|
||||||
|
|
||||||
function stripFileExtension(name: string): string {
|
|
||||||
const ext = path.extname(name);
|
|
||||||
return ext ? name.slice(0, -ext.length) : name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFilenameSegment(value: string, fallback: string): string {
|
|
||||||
const sanitized = value
|
|
||||||
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return sanitized || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMediaFilename(mediaPath: string): string {
|
|
||||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath)) {
|
|
||||||
return path.basename(path.resolve(mediaPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(mediaPath);
|
|
||||||
const decodedPath = decodeURIComponent(parsedUrl.pathname);
|
|
||||||
const fromPath = path.basename(decodedPath);
|
|
||||||
if (fromPath) {
|
|
||||||
return fromPath;
|
|
||||||
}
|
|
||||||
return parsedUrl.hostname.replace(/^www\./, '') || 'subtitle';
|
|
||||||
} catch {
|
|
||||||
return path.basename(mediaPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildJimakuSubtitleFilenameFromMediaPath(
|
|
||||||
mediaPath: string,
|
|
||||||
downloadedSubtitleName: string,
|
|
||||||
languageSuffix = DEFAULT_JIMAKU_LANGUAGE_SUFFIX,
|
|
||||||
): string {
|
|
||||||
const mediaFilename = resolveMediaFilename(mediaPath);
|
|
||||||
const mediaBasename = sanitizeFilenameSegment(stripFileExtension(mediaFilename), 'subtitle');
|
|
||||||
const subtitleName = path.basename(downloadedSubtitleName);
|
|
||||||
const subtitleExt = path.extname(subtitleName) || DEFAULT_SUBTITLE_EXTENSION;
|
|
||||||
const normalizedLanguageSuffix = sanitizeFilenameSegment(languageSuffix, 'ja').replace(
|
|
||||||
/\s+/g,
|
|
||||||
'-',
|
|
||||||
);
|
|
||||||
return `${mediaBasename}.${normalizedLanguageSuffix}${subtitleExt}`;
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: false,
|
jellyfinSubtitleUrlsOnly: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinRemoteAnnounce: false,
|
jellyfinRemoteAnnounce: false,
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
sanitizeHelpEnv,
|
|
||||||
sanitizeBackgroundEnv,
|
|
||||||
shouldDetachBackgroundLaunch,
|
|
||||||
shouldHandleHelpOnlyAtEntry,
|
|
||||||
} from './main-entry-runtime';
|
|
||||||
|
|
||||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--start'], {}), false);
|
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
|
||||||
const env = sanitizeHelpEnv({
|
|
||||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
|
||||||
});
|
|
||||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
|
||||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
|
||||||
const env = sanitizeBackgroundEnv({
|
|
||||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
|
||||||
});
|
|
||||||
assert.equal(env.SUBMINER_BACKGROUND_CHILD, '1');
|
|
||||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
|
||||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldDetachBackgroundLaunch only for first background invocation', () => {
|
|
||||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true);
|
|
||||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }), false);
|
|
||||||
assert.equal(shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
|
||||||
assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false);
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
|
||||||
|
|
||||||
const BACKGROUND_ARG = '--background';
|
|
||||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
|
||||||
|
|
||||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
|
||||||
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
|
|
||||||
delete env.VK_INSTANCE_LAYERS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): CliArgs {
|
|
||||||
return parseArgs(argv);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
|
||||||
if (!argv.includes(BACKGROUND_ARG)) return false;
|
|
||||||
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
|
||||||
const args = parseCliArgs(argv);
|
|
||||||
return args.help && !shouldStartApp(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
||||||
const env = { ...baseEnv };
|
|
||||||
if (!env.NODE_NO_WARNINGS) {
|
|
||||||
env.NODE_NO_WARNINGS = '1';
|
|
||||||
}
|
|
||||||
removeLsfgLayer(env);
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
||||||
const env = sanitizeHelpEnv(baseEnv);
|
|
||||||
env[BACKGROUND_CHILD_ENV] = '1';
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { printHelp } from './cli/help';
|
|
||||||
import {
|
|
||||||
sanitizeBackgroundEnv,
|
|
||||||
sanitizeHelpEnv,
|
|
||||||
shouldDetachBackgroundLaunch,
|
|
||||||
shouldHandleHelpOnlyAtEntry,
|
|
||||||
} from './main-entry-runtime';
|
|
||||||
|
|
||||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
const BACKGROUND_ARG = '--background';
|
||||||
|
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||||
|
|
||||||
|
function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||||
|
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||||
|
if (!argv.includes(BACKGROUND_ARG)) return false;
|
||||||
|
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...baseEnv };
|
||||||
|
env[BACKGROUND_CHILD_ENV] = '1';
|
||||||
|
if (!env.NODE_NO_WARNINGS) {
|
||||||
|
env.NODE_NO_WARNINGS = '1';
|
||||||
|
}
|
||||||
|
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
|
||||||
|
delete env.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||||
@@ -19,14 +32,4 @@ if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
|
||||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
|
||||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
|
||||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
|
||||||
delete process.env.VK_INSTANCE_LAYERS;
|
|
||||||
}
|
|
||||||
printHelp(DEFAULT_TEXTHOOKER_PORT);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
require('./main.js');
|
require('./main.js');
|
||||||
|
|||||||
@@ -1498,10 +1498,6 @@ const {
|
|||||||
listJellyfinItemsRuntime(session, clientInfo, params),
|
listJellyfinItemsRuntime(session, clientInfo, params),
|
||||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||||
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
|
||||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
|
||||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
},
|
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
},
|
},
|
||||||
handleJellyfinPlayCommandMainDeps: {
|
handleJellyfinPlayCommandMainDeps: {
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
},
|
},
|
||||||
handleJellyfinPlayCommandMainDeps: {
|
handleJellyfinPlayCommandMainDeps: {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ test('list handler no-ops when no list command is set', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ test('list handler logs libraries', async () => {
|
|||||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,19 +67,14 @@ test('list handler logs libraries', async () => {
|
|||||||
|
|
||||||
test('list handler resolves items using default library id', async () => {
|
test('list handler resolves items using default library id', async () => {
|
||||||
let usedLibraryId = '';
|
let usedLibraryId = '';
|
||||||
let usedRecursive: boolean | undefined;
|
|
||||||
let usedIncludeItemTypes: string | undefined;
|
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const handler = createHandleJellyfinListCommands({
|
const handler = createHandleJellyfinListCommands({
|
||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||||
usedLibraryId = params.libraryId;
|
usedLibraryId = params.libraryId;
|
||||||
usedRecursive = params.recursive;
|
|
||||||
usedIncludeItemTypes = params.includeItemTypes;
|
|
||||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||||
},
|
},
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,8 +86,6 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
jellyfinLibraryId: '',
|
jellyfinLibraryId: '',
|
||||||
jellyfinSearch: 'episode',
|
jellyfinSearch: 'episode',
|
||||||
jellyfinLimit: 10,
|
jellyfinLimit: 10,
|
||||||
jellyfinRecursive: false,
|
|
||||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
|
||||||
} as never,
|
} as never,
|
||||||
session: baseSession,
|
session: baseSession,
|
||||||
clientInfo: baseClientInfo,
|
clientInfo: baseClientInfo,
|
||||||
@@ -105,8 +96,6 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.equal(usedLibraryId, 'default-lib');
|
assert.equal(usedLibraryId, 'default-lib');
|
||||||
assert.equal(usedRecursive, false);
|
|
||||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
|
||||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +104,6 @@ test('list handler throws when items command has no library id', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +132,6 @@ test('list handler logs subtitle urls only when requested', async () => {
|
|||||||
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
||||||
{ index: 2, language: 'jpn' },
|
{ index: 2, language: 'jpn' },
|
||||||
],
|
],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +157,6 @@ test('list handler throws when subtitle command has no item id', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,65 +174,3 @@ test('list handler throws when subtitle command has no item id', async () => {
|
|||||||
/Missing --jellyfin-item-id/,
|
/Missing --jellyfin-item-id/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list handler writes preview auth payload to response path', async () => {
|
|
||||||
const writes: Array<{
|
|
||||||
path: string;
|
|
||||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
|
||||||
}> = [];
|
|
||||||
const logs: string[] = [];
|
|
||||||
const handler = createHandleJellyfinListCommands({
|
|
||||||
listJellyfinLibraries: async () => [],
|
|
||||||
listJellyfinItems: async () => [],
|
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
|
||||||
writes.push({ path: responsePath, payload });
|
|
||||||
},
|
|
||||||
logInfo: (message) => logs.push(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handled = await handler({
|
|
||||||
args: {
|
|
||||||
jellyfinPreviewAuth: true,
|
|
||||||
jellyfinResponsePath: '/tmp/subminer-preview-auth.json',
|
|
||||||
} as never,
|
|
||||||
session: baseSession,
|
|
||||||
clientInfo: baseClientInfo,
|
|
||||||
jellyfinConfig: baseConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(writes, [
|
|
||||||
{
|
|
||||||
path: '/tmp/subminer-preview-auth.json',
|
|
||||||
payload: {
|
|
||||||
serverUrl: baseSession.serverUrl,
|
|
||||||
accessToken: baseSession.accessToken,
|
|
||||||
userId: baseSession.userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert.deepEqual(logs, ['Jellyfin preview auth written.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list handler throws when preview auth command has no response path', async () => {
|
|
||||||
const handler = createHandleJellyfinListCommands({
|
|
||||||
listJellyfinLibraries: async () => [],
|
|
||||||
listJellyfinItems: async () => [],
|
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
|
||||||
writeJellyfinPreviewAuth: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
handler({
|
|
||||||
args: {
|
|
||||||
jellyfinPreviewAuth: true,
|
|
||||||
} as never,
|
|
||||||
session: baseSession,
|
|
||||||
clientInfo: baseClientInfo,
|
|
||||||
jellyfinConfig: baseConfig,
|
|
||||||
}),
|
|
||||||
/Missing --jellyfin-response-path/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ type JellyfinConfig = {
|
|||||||
defaultLibraryId: string;
|
defaultLibraryId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinPreviewAuthPayload = {
|
|
||||||
serverUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createHandleJellyfinListCommands(deps: {
|
export function createHandleJellyfinListCommands(deps: {
|
||||||
listJellyfinLibraries: (
|
listJellyfinLibraries: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
@@ -31,13 +25,7 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
listJellyfinItems: (
|
listJellyfinItems: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
clientInfo: JellyfinClientInfo,
|
clientInfo: JellyfinClientInfo,
|
||||||
params: {
|
params: { libraryId: string; searchTerm?: string; limit: number },
|
||||||
libraryId: string;
|
|
||||||
searchTerm?: string;
|
|
||||||
limit: number;
|
|
||||||
recursive?: boolean;
|
|
||||||
includeItemTypes?: string;
|
|
||||||
},
|
|
||||||
) => Promise<Array<{ id: string; title: string; type: string }>>;
|
) => Promise<Array<{ id: string; title: string; type: string }>>;
|
||||||
listJellyfinSubtitleTracks: (
|
listJellyfinSubtitleTracks: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
@@ -56,7 +44,6 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
deliveryUrl?: string | null;
|
deliveryUrl?: string | null;
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (params: {
|
return async (params: {
|
||||||
@@ -67,20 +54,6 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||||
|
|
||||||
if (args.jellyfinPreviewAuth) {
|
|
||||||
const responsePath = args.jellyfinResponsePath?.trim();
|
|
||||||
if (!responsePath) {
|
|
||||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
|
||||||
}
|
|
||||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
|
||||||
serverUrl: session.serverUrl,
|
|
||||||
accessToken: session.accessToken,
|
|
||||||
userId: session.userId,
|
|
||||||
});
|
|
||||||
deps.logInfo('Jellyfin preview auth written.');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.jellyfinLibraries) {
|
if (args.jellyfinLibraries) {
|
||||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
@@ -106,8 +79,6 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
libraryId,
|
libraryId,
|
||||||
searchTerm: args.jellyfinSearch,
|
searchTerm: args.jellyfinSearch,
|
||||||
limit: args.jellyfinLimit ?? 100,
|
limit: args.jellyfinLimit ?? 100,
|
||||||
recursive: args.jellyfinRecursive,
|
|
||||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
|
||||||
});
|
});
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
|
|||||||
|
|
||||||
test('jellyfin list commands main deps builder maps callbacks', async () => {
|
test('jellyfin list commands main deps builder maps callbacks', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const writes: Array<{
|
|
||||||
responsePath: string;
|
|
||||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
|
||||||
}> = [];
|
|
||||||
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
|
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
|
||||||
listJellyfinLibraries: async () => {
|
listJellyfinLibraries: async () => {
|
||||||
calls.push('libraries');
|
calls.push('libraries');
|
||||||
@@ -48,32 +44,14 @@ test('jellyfin list commands main deps builder maps callbacks', async () => {
|
|||||||
calls.push('subtitles');
|
calls.push('subtitles');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
|
||||||
writes.push({ responsePath, payload });
|
|
||||||
},
|
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
await deps.listJellyfinLibraries({} as never, {} as never);
|
await deps.listJellyfinLibraries({} as never, {} as never);
|
||||||
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
|
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
|
||||||
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
|
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
|
||||||
deps.writeJellyfinPreviewAuth('/tmp/jellyfin-preview.json', {
|
|
||||||
serverUrl: 'https://example.test',
|
|
||||||
accessToken: 'token',
|
|
||||||
userId: 'user-id',
|
|
||||||
});
|
|
||||||
deps.logInfo('done');
|
deps.logInfo('done');
|
||||||
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
|
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
|
||||||
assert.deepEqual(writes, [
|
|
||||||
{
|
|
||||||
responsePath: '/tmp/jellyfin-preview.json',
|
|
||||||
payload: {
|
|
||||||
serverUrl: 'https://example.test',
|
|
||||||
accessToken: 'token',
|
|
||||||
userId: 'user-id',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jellyfin play command main deps builder maps callbacks', async () => {
|
test('jellyfin play command main deps builder maps callbacks', async () => {
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
|
|||||||
deps.listJellyfinItems(session, clientInfo, params),
|
deps.listJellyfinItems(session, clientInfo, params),
|
||||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||||
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
|
||||||
deps.writeJellyfinPreviewAuth(responsePath, payload),
|
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { ElectronAPI } from '../../types';
|
|
||||||
import { createRendererState } from '../state.js';
|
|
||||||
import { createJimakuModal } from './jimaku.js';
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
|
||||||
const tokens = new Set(initialTokens);
|
|
||||||
return {
|
|
||||||
add: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
tokens.add(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remove: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
tokens.delete(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contains: (entry: string) => tokens.has(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createElementStub() {
|
|
||||||
const classList = createClassList();
|
|
||||||
return {
|
|
||||||
textContent: '',
|
|
||||||
className: '',
|
|
||||||
style: {},
|
|
||||||
classList,
|
|
||||||
children: [] as unknown[],
|
|
||||||
appendChild(child: unknown) {
|
|
||||||
this.children.push(child);
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createListStub() {
|
|
||||||
return {
|
|
||||||
innerHTML: '',
|
|
||||||
children: [] as unknown[],
|
|
||||||
appendChild(child: unknown) {
|
|
||||||
this.children.push(child);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushAsyncWork(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('successful Jimaku subtitle selection closes modal', async () => {
|
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
|
||||||
const previousWindow = globals.window;
|
|
||||||
const previousDocument = globals.document;
|
|
||||||
|
|
||||||
const modalCloseNotifications: Array<'runtime-options' | 'subsync' | 'jimaku' | 'kiku'> = [];
|
|
||||||
|
|
||||||
const electronAPI = {
|
|
||||||
jimakuDownloadFile: async () => ({ ok: true, path: '/tmp/subtitles/episode01.ass' }),
|
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
|
||||||
modalCloseNotifications.push(modal);
|
|
||||||
},
|
|
||||||
} as unknown as ElectronAPI;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: { electronAPI },
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
activeElement: null,
|
|
||||||
createElement: () => createElementStub(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const overlayClassList = createClassList(['interactive']);
|
|
||||||
const jimakuModalClassList = createClassList();
|
|
||||||
const jimakuEntriesSectionClassList = createClassList(['hidden']);
|
|
||||||
const jimakuFilesSectionClassList = createClassList();
|
|
||||||
const jimakuBroadenButtonClassList = createClassList(['hidden']);
|
|
||||||
const state = createRendererState();
|
|
||||||
state.jimakuModalOpen = true;
|
|
||||||
state.currentEntryId = 42;
|
|
||||||
state.selectedFileIndex = 0;
|
|
||||||
state.jimakuFiles = [
|
|
||||||
{
|
|
||||||
name: 'episode01.ass',
|
|
||||||
url: 'https://jimaku.cc/files/episode01.ass',
|
|
||||||
size: 1000,
|
|
||||||
last_modified: '2026-03-01',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
dom: {
|
|
||||||
overlay: { classList: overlayClassList },
|
|
||||||
jimakuModal: {
|
|
||||||
classList: jimakuModalClassList,
|
|
||||||
setAttribute: () => {},
|
|
||||||
},
|
|
||||||
jimakuTitleInput: { value: '' },
|
|
||||||
jimakuSeasonInput: { value: '' },
|
|
||||||
jimakuEpisodeInput: { value: '' },
|
|
||||||
jimakuSearchButton: { addEventListener: () => {} },
|
|
||||||
jimakuCloseButton: { addEventListener: () => {} },
|
|
||||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
|
||||||
jimakuEntriesSection: { classList: jimakuEntriesSectionClassList },
|
|
||||||
jimakuEntriesList: createListStub(),
|
|
||||||
jimakuFilesSection: { classList: jimakuFilesSectionClassList },
|
|
||||||
jimakuFilesList: createListStub(),
|
|
||||||
jimakuBroadenButton: {
|
|
||||||
classList: jimakuBroadenButtonClassList,
|
|
||||||
addEventListener: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
|
|
||||||
const jimakuModal = createJimakuModal(ctx as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
let prevented = false;
|
|
||||||
jimakuModal.handleJimakuKeydown({
|
|
||||||
key: 'Enter',
|
|
||||||
preventDefault: () => {
|
|
||||||
prevented = true;
|
|
||||||
},
|
|
||||||
} as KeyboardEvent);
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
assert.equal(prevented, true);
|
|
||||||
assert.equal(state.jimakuModalOpen, false);
|
|
||||||
assert.equal(jimakuModalClassList.contains('hidden'), true);
|
|
||||||
assert.equal(overlayClassList.contains('interactive'), false);
|
|
||||||
assert.deepEqual(modalCloseNotifications, ['jimaku']);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -234,7 +234,6 @@ export function createJimakuModal(
|
|||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||||
closeJimakuModal();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { createSubsyncModal } from './subsync.js';
|
|
||||||
|
|
||||||
type Listener = () => void;
|
|
||||||
|
|
||||||
function createClassList() {
|
|
||||||
const classes = new Set<string>();
|
|
||||||
return {
|
|
||||||
add: (...tokens: string[]) => {
|
|
||||||
for (const token of tokens) classes.add(token);
|
|
||||||
},
|
|
||||||
remove: (...tokens: string[]) => {
|
|
||||||
for (const token of tokens) classes.delete(token);
|
|
||||||
},
|
|
||||||
toggle: (token: string, force?: boolean) => {
|
|
||||||
if (force === undefined) {
|
|
||||||
if (classes.has(token)) classes.delete(token);
|
|
||||||
else classes.add(token);
|
|
||||||
return classes.has(token);
|
|
||||||
}
|
|
||||||
if (force) classes.add(token);
|
|
||||||
else classes.delete(token);
|
|
||||||
return force;
|
|
||||||
},
|
|
||||||
contains: (token: string) => classes.has(token),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEventTarget() {
|
|
||||||
const listeners = new Map<string, Listener[]>();
|
|
||||||
return {
|
|
||||||
addEventListener: (event: string, listener: Listener) => {
|
|
||||||
const existing = listeners.get(event) ?? [];
|
|
||||||
existing.push(listener);
|
|
||||||
listeners.set(event, existing);
|
|
||||||
},
|
|
||||||
dispatch: (event: string) => {
|
|
||||||
for (const listener of listeners.get(event) ?? []) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeferred<T>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
|
|
||||||
const overlayClassList = createClassList();
|
|
||||||
const modalClassList = createClassList();
|
|
||||||
const statusClassList = createClassList();
|
|
||||||
const sourceLabelClassList = createClassList();
|
|
||||||
const runButtonEvents = createEventTarget();
|
|
||||||
const closeButtonEvents = createEventTarget();
|
|
||||||
const engineAlassEvents = createEventTarget();
|
|
||||||
const engineFfsubsyncEvents = createEventTarget();
|
|
||||||
|
|
||||||
const sourceOptions: Array<{ value: string; textContent: string }> = [];
|
|
||||||
|
|
||||||
const runButton = {
|
|
||||||
disabled: false,
|
|
||||||
addEventListener: runButtonEvents.addEventListener,
|
|
||||||
dispatch: runButtonEvents.dispatch,
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeButton = {
|
|
||||||
addEventListener: closeButtonEvents.addEventListener,
|
|
||||||
dispatch: closeButtonEvents.dispatch,
|
|
||||||
};
|
|
||||||
|
|
||||||
const subsyncEngineAlass = {
|
|
||||||
checked: false,
|
|
||||||
addEventListener: engineAlassEvents.addEventListener,
|
|
||||||
dispatch: engineAlassEvents.dispatch,
|
|
||||||
};
|
|
||||||
|
|
||||||
const subsyncEngineFfsubsync = {
|
|
||||||
checked: false,
|
|
||||||
addEventListener: engineFfsubsyncEvents.addEventListener,
|
|
||||||
dispatch: engineFfsubsyncEvents.dispatch,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sourceSelect = {
|
|
||||||
innerHTML: '',
|
|
||||||
value: '',
|
|
||||||
disabled: false,
|
|
||||||
appendChild: (option: { value: string; textContent: string }) => {
|
|
||||||
sourceOptions.push(option);
|
|
||||||
if (!sourceSelect.value) {
|
|
||||||
sourceSelect.value = option.value;
|
|
||||||
}
|
|
||||||
return option;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let notifyClosedCalls = 0;
|
|
||||||
let notifyOpenedCalls = 0;
|
|
||||||
|
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
|
||||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
electronAPI: {
|
|
||||||
runSubsyncManual,
|
|
||||||
notifyOverlayModalOpened: () => {
|
|
||||||
notifyOpenedCalls += 1;
|
|
||||||
},
|
|
||||||
notifyOverlayModalClosed: () => {
|
|
||||||
notifyClosedCalls += 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
createElement: () => ({ value: '', textContent: '' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
dom: {
|
|
||||||
overlay: { classList: overlayClassList },
|
|
||||||
subsyncModal: {
|
|
||||||
classList: modalClassList,
|
|
||||||
setAttribute: () => {},
|
|
||||||
},
|
|
||||||
subsyncCloseButton: closeButton,
|
|
||||||
subsyncEngineAlass,
|
|
||||||
subsyncEngineFfsubsync,
|
|
||||||
subsyncSourceLabel: { classList: sourceLabelClassList },
|
|
||||||
subsyncSourceSelect: sourceSelect,
|
|
||||||
subsyncRunButton: runButton,
|
|
||||||
subsyncStatus: {
|
|
||||||
textContent: '',
|
|
||||||
classList: statusClassList,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
subsyncModalOpen: false,
|
|
||||||
subsyncSourceTracks: [],
|
|
||||||
subsyncSubmitting: false,
|
|
||||||
isOverSubtitle: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = createSubsyncModal(ctx as never, {
|
|
||||||
modalStateReader: {
|
|
||||||
isAnyModalOpen: () => false,
|
|
||||||
},
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
modal,
|
|
||||||
runButton,
|
|
||||||
statusClassList,
|
|
||||||
getNotifyClosedCalls: () => notifyClosedCalls,
|
|
||||||
getNotifyOpenedCalls: () => notifyOpenedCalls,
|
|
||||||
restoreGlobals: () => {
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: previousWindow,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: previousDocument,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushMicrotasks(): Promise<void> {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
test('manual subsync failure closes during run, then reopens modal with error', async () => {
|
|
||||||
const deferred = createDeferred<{ ok: boolean; message: string }>();
|
|
||||||
const harness = createTestHarness(async () => deferred.promise);
|
|
||||||
|
|
||||||
try {
|
|
||||||
harness.modal.wireDomEvents();
|
|
||||||
harness.modal.openSubsyncModal({
|
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.runButton.dispatch('click');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.equal(harness.ctx.state.subsyncModalOpen, false);
|
|
||||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
|
||||||
assert.equal(harness.getNotifyOpenedCalls(), 0);
|
|
||||||
|
|
||||||
deferred.resolve({
|
|
||||||
ok: false,
|
|
||||||
message: 'alass synchronization failed: code=1 stderr: invalid subtitle format',
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
assert.equal(harness.ctx.state.subsyncModalOpen, true);
|
|
||||||
assert.equal(
|
|
||||||
harness.ctx.dom.subsyncStatus.textContent,
|
|
||||||
'alass synchronization failed: code=1 stderr: invalid subtitle format',
|
|
||||||
);
|
|
||||||
assert.equal(harness.statusClassList.contains('error'), true);
|
|
||||||
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
|
|
||||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
|
||||||
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
|
|
||||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
|
||||||
assert.equal(harness.getNotifyOpenedCalls(), 1);
|
|
||||||
} finally {
|
|
||||||
harness.restoreGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -71,30 +71,6 @@ export function createSubsyncModal(
|
|||||||
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function reopenSubsyncModalWithError(
|
|
||||||
sourceTracks: SubsyncManualPayload['sourceTracks'],
|
|
||||||
engine: 'alass' | 'ffsubsync',
|
|
||||||
sourceTrackId: number | null,
|
|
||||||
message: string,
|
|
||||||
): void {
|
|
||||||
openSubsyncModal({ sourceTracks });
|
|
||||||
|
|
||||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
|
||||||
ctx.dom.subsyncEngineAlass.checked = true;
|
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = false;
|
|
||||||
if (Number.isFinite(sourceTrackId)) {
|
|
||||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.dom.subsyncEngineAlass.checked = false;
|
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSubsyncSourceVisibility();
|
|
||||||
setSubsyncStatus(message, true);
|
|
||||||
window.electronAPI.notifyOverlayModalOpened('subsync');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSubsyncManualFromModal(): Promise<void> {
|
async function runSubsyncManualFromModal(): Promise<void> {
|
||||||
if (ctx.state.subsyncSubmitting) return;
|
if (ctx.state.subsyncSubmitting) return;
|
||||||
|
|
||||||
@@ -109,25 +85,15 @@ export function createSubsyncModal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
|
|
||||||
ctx.state.subsyncSubmitting = true;
|
ctx.state.subsyncSubmitting = true;
|
||||||
ctx.dom.subsyncRunButton.disabled = true;
|
ctx.dom.subsyncRunButton.disabled = true;
|
||||||
closeSubsyncModal();
|
|
||||||
|
|
||||||
|
closeSubsyncModal();
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.runSubsyncManual({
|
await window.electronAPI.runSubsyncManual({
|
||||||
engine,
|
engine,
|
||||||
sourceTrackId,
|
sourceTrackId,
|
||||||
});
|
});
|
||||||
if (result.ok) return;
|
|
||||||
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
|
|
||||||
} catch (error) {
|
|
||||||
reopenSubsyncModalWithError(
|
|
||||||
sourceTracksSnapshot,
|
|
||||||
engine,
|
|
||||||
sourceTrackId,
|
|
||||||
`Subsync failed: ${(error as Error).message}`,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
ctx.state.subsyncSubmitting = false;
|
ctx.state.subsyncSubmitting = false;
|
||||||
ctx.dom.subsyncRunButton.disabled = false;
|
ctx.dom.subsyncRunButton.disabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user