mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
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 = {
|
|
status: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-test-'));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
|
|
{
|
|
env,
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
return {
|
|
status: result.status,
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
};
|
|
}
|
|
|
|
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
XDG_CONFIG_HOME: xdgConfigHome,
|
|
};
|
|
}
|
|
|
|
test('config path uses XDG_CONFIG_HOME override', () => {
|
|
withTempDir((root) => {
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
const homeDir = path.join(root, 'home');
|
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
|
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"source":"xdg"}');
|
|
|
|
const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.json'));
|
|
});
|
|
});
|
|
|
|
test('config discovery ignores lowercase subminer candidate', () => {
|
|
const homeDir = '/home/tester';
|
|
const xdgConfigHome = '/tmp/xdg-config';
|
|
const expected = path.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
|
|
const foundPaths = new Set([path.join(xdgConfigHome, 'subminer', 'config.json')]);
|
|
|
|
const resolved = resolveConfigFilePath({
|
|
xdgConfigHome,
|
|
homeDir,
|
|
existsSync: (candidate) => foundPaths.has(path.normalize(candidate)),
|
|
});
|
|
|
|
assert.equal(resolved, expected);
|
|
});
|
|
|
|
test('config path prefers jsonc over json for same directory', () => {
|
|
withTempDir((root) => {
|
|
const homeDir = path.join(root, 'home');
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
|
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"format":"json"}');
|
|
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"format":"jsonc"}');
|
|
|
|
const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
|
|
});
|
|
});
|
|
|
|
test('config show prints config body and appends trailing newline', () => {
|
|
withTempDir((root) => {
|
|
const homeDir = path.join(root, 'home');
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
|
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"logLevel":"debug"}');
|
|
|
|
const result = runLauncher(['config', 'show'], makeTestEnv(homeDir, xdgConfigHome));
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(result.stdout, '{"logLevel":"debug"}\n');
|
|
});
|
|
});
|
|
|
|
test('mpv socket command returns socket path from plugin runtime config', () => {
|
|
withTempDir((root) => {
|
|
const homeDir = path.join(root, 'home');
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
|
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
`socket_path=${expectedSocket}\n`,
|
|
);
|
|
|
|
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(result.stdout.trim(), expectedSocket);
|
|
});
|
|
});
|
|
|
|
test('mpv status exits non-zero when socket is not ready', () => {
|
|
withTempDir((root) => {
|
|
const homeDir = path.join(root, 'home');
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stdout, /socket not ready/i);
|
|
});
|
|
});
|
|
|
|
test('doctor reports checks and exits non-zero without hard dependencies', () => {
|
|
withTempDir((root) => {
|
|
const homeDir = path.join(root, 'home');
|
|
const xdgConfigHome = path.join(root, 'xdg');
|
|
const env = {
|
|
...makeTestEnv(homeDir, xdgConfigHome),
|
|
PATH: '',
|
|
};
|
|
const result = runLauncher(['doctor'], env);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stdout, /\[doctor\] app binary:/);
|
|
assert.match(result.stdout, /\[doctor\] mpv:/);
|
|
assert.match(result.stdout, /\[doctor\] config:/);
|
|
});
|
|
});
|
|
|
|
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
|
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(['jellyfin', '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',
|
|
);
|
|
});
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('jellyfin login routes credentials to app command', () => {
|
|
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(
|
|
[
|
|
'jellyfin',
|
|
'login',
|
|
'--server',
|
|
'https://jf.example.test',
|
|
'--username',
|
|
'alice',
|
|
'--password',
|
|
'secret',
|
|
],
|
|
env,
|
|
);
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(
|
|
fs.readFileSync(capturePath, 'utf8'),
|
|
'--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('jellyfin setup forwards password-store to app command', () => {
|
|
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(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
|
|
|
|
assert.equal(result.status, 0);
|
|
assert.equal(
|
|
fs.readFileSync(capturePath, 'utf8'),
|
|
'--jellyfin\n--password-store\ngnome-libsecret\n',
|
|
);
|
|
});
|
|
});
|
|
|
|
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',
|
|
});
|
|
});
|