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, } from './jellyfin.js'; type RunResult = { status: number | null; stdout: string; stderr: string; }; function withTempDir(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)', }, ]); });