mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
fix(jellyfin): make terminal play work with a running app and report progress (#288)
This commit is contained in:
1 parent
78eca52b7e
commit
78a9b6346f
33 files changed
+874
-864
No files matched your search
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
|
||||
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
|
||||
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
|
||||
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: jellyfin
|
||||
|
||||
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
|
||||
@@ -515,12 +515,12 @@ In external-profile mode, SubMiner only reads the profile. It does not open its
|
||||
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
|
||||
| ----------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
||||
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
||||
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
||||
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
|
||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup (also needs `startupWarmups.jellyfinRemoteSession`) |
|
||||
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
||||
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
||||
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
||||
|
||||
@@ -14,7 +14,7 @@ SubMiner stores an encrypted session token, not your password, and turns the int
|
||||
|
||||
## Casting from Jellyfin
|
||||
|
||||
After you sign in, SubMiner connects to Jellyfin at startup and shows up in the cast ("Play on") menu under your computer's hostname. To connect for the current session only, tick **Jellyfin Discovery** in the tray menu.
|
||||
After you sign in, tick **Jellyfin Discovery** in the tray menu. SubMiner then shows up in the cast ("Play on") menu under your computer's hostname until it quits. To connect every time SubMiner starts, turn on `startupWarmups.jellyfinRemoteSession` and leave `remoteControlAutoConnect` on.
|
||||
|
||||
1. In the Jellyfin web or mobile app, start playing an episode.
|
||||
2. Open the cast menu and pick your computer.
|
||||
@@ -34,18 +34,18 @@ subminer jellyfin -p # fzf picker; `jf` is an alias for `jellyfin`
|
||||
subminer -R jellyfin -p # rofi picker
|
||||
```
|
||||
|
||||
Sign in first. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
Sign in first. Playback resumes from Jellyfin's saved position, and watch progress syncs back the same way it does for casting, whether or not discovery is on. If SubMiner is already running, the picker uses it and leaves it running. Otherwise the launcher starts SubMiner and quits it when you close mpv, the same as playing a local file. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||
|
||||
## Options
|
||||
|
||||
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
|
||||
|
||||
| Key | What it does |
|
||||
| -------------------------- | -------------------------------------------------------------------- |
|
||||
| -------------------------- | ---------------------------------------------------------------------- |
|
||||
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
||||
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
||||
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
||||
| `remoteControlAutoConnect` | Connects at startup. Turn off to start discovery from the tray. |
|
||||
| `remoteControlAutoConnect` | Connects at startup when `startupWarmups.jellyfinRemoteSession` is on. |
|
||||
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
||||
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
||||
|
||||
|
||||
+75
-68
@@ -1,7 +1,5 @@
|
||||
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 {
|
||||
buildForwardedJellyfinAppArgs,
|
||||
@@ -11,25 +9,16 @@ import {
|
||||
hasStoredJellyfinSession,
|
||||
parseEpisodePathFromDisplay,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinItemsFromAppOutput,
|
||||
parseJellyfinLibrariesFromAppOutput,
|
||||
parseJellyfinAppReply,
|
||||
parseJellyfinItemsReply,
|
||||
parseJellyfinLibrariesReply,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
readUtf8FileAppendedSince,
|
||||
runJellyfinPlayMenuWithDeps,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
} from './jellyfin.js';
|
||||
import { makeLauncherArgs } from './test-support/args.js';
|
||||
import { withEnv } from './test-support/env.js';
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
class HandoffComplete extends Error {}
|
||||
|
||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||
@@ -68,6 +57,16 @@ async function runPlayMenu(options: {
|
||||
handoff.appArgs = appArgs;
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
isAppManagedByLauncher: () => false,
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected owned-app handoff');
|
||||
},
|
||||
waitForUnixSocketClosed: async () => {
|
||||
throw new Error('unexpected wait for mpv to close');
|
||||
},
|
||||
exitProcess: () => {
|
||||
throw new Error('unexpected exit');
|
||||
},
|
||||
log: () => {},
|
||||
...options.overrides,
|
||||
};
|
||||
@@ -105,41 +104,71 @@ test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv'
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
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)
|
||||
`);
|
||||
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
|
||||
const events: string[] = [];
|
||||
const { appArgs } = await runPlayMenu({
|
||||
socketReady: true,
|
||||
overrides: {
|
||||
isAppManagedByLauncher: () => true,
|
||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||
events.push(`handoff ${appArgs.join(' ')}`);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
waitForUnixSocketClosed: async (socketPath) => {
|
||||
events.push(`wait ${socketPath}`);
|
||||
},
|
||||
exitProcess: (code) => {
|
||||
events.push(`exit ${code}`);
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
assert.equal(appArgs, null);
|
||||
assert.deepEqual(events, [
|
||||
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
|
||||
'wait /tmp/subminer.sock',
|
||||
'exit 0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
|
||||
const libraries = parseJellyfinAppReply(
|
||||
JSON.stringify({
|
||||
libraries: [
|
||||
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
|
||||
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
|
||||
{ name: 'No id' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.ok(libraries?.ok);
|
||||
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
||||
]);
|
||||
|
||||
const items = parseJellyfinAppReply(
|
||||
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
|
||||
);
|
||||
assert.ok(items?.ok);
|
||||
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
|
||||
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
|
||||
]);
|
||||
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
|
||||
});
|
||||
|
||||
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('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
|
||||
assert.deepEqual(
|
||||
parseJellyfinAppReply(
|
||||
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
|
||||
),
|
||||
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
|
||||
);
|
||||
assert.equal(parseJellyfinAppReply(''), null);
|
||||
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
||||
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
@@ -184,13 +213,11 @@ test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () =>
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||
const parsed = parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
const parsed = parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
@@ -199,17 +226,13 @@ test('parseJellyfinPreviewAuthResponse parses valid structured response payload'
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
||||
test('parseJellyfinPreviewAuthResponse returns null without a token', () => {
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
parseJellyfinPreviewAuthResponse({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -238,22 +261,6 @@ test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle er
|
||||
);
|
||||
});
|
||||
|
||||
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!'),
|
||||
|
||||
+192
-219
@@ -10,7 +10,8 @@ import type {
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { shouldForwardLogLevel } from './types.js';
|
||||
import { log, fail, getMpvLogPath } from './log.js';
|
||||
import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js';
|
||||
import { log, fail } from './log.js';
|
||||
import { nowMs } from './time.js';
|
||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||
import {
|
||||
@@ -23,11 +24,14 @@ import {
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||
import {
|
||||
adoptLauncherStartedApp,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandWithInheritLogged,
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketClosed,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||
isAppManagedByLauncher: typeof isAppManagedByLauncher;
|
||||
runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput;
|
||||
waitForUnixSocketClosed: typeof waitForUnixSocketClosed;
|
||||
exitProcess: (code: number) => never;
|
||||
log: typeof log;
|
||||
};
|
||||
|
||||
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
runAppCommandWithInheritLogged,
|
||||
isAppManagedByLauncher,
|
||||
runAppCommandCaptureOutput,
|
||||
waitForUnixSocketClosed,
|
||||
exitProcess: (code) => process.exit(code),
|
||||
log,
|
||||
};
|
||||
|
||||
@@ -171,76 +183,77 @@ 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;
|
||||
// Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts.
|
||||
export type JellyfinAppReply =
|
||||
| { ok: true; payload: Record<string, unknown> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
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 };
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
||||
function readString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) return null;
|
||||
const error = readString(parsed, 'error');
|
||||
if (error) return { ok: false, error: normalizeJellyfinAppError(error) };
|
||||
return { ok: true, payload: parsed };
|
||||
}
|
||||
|
||||
export function parseJellyfinLibrariesReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinLibraryEntry[] | null {
|
||||
if (!Array.isArray(payload.libraries)) return null;
|
||||
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);
|
||||
for (const entry of payload.libraries) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
libraries.push({
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
kind: parsed.type,
|
||||
id,
|
||||
name: readString(entry, 'name') || 'Untitled',
|
||||
kind: readString(entry, 'collectionType') || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
||||
export function parseJellyfinItemsReply(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinItemEntry[] | null {
|
||||
if (!Array.isArray(payload.items)) return null;
|
||||
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,
|
||||
});
|
||||
for (const entry of payload.items) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = readString(entry, 'id');
|
||||
if (!id || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
const title = readString(entry, 'title') || 'Untitled';
|
||||
items.push({ id, name: title, type: readString(entry, 'type'), display: title });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizeJellyfinAppError(message: string): string {
|
||||
if (message.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||
}
|
||||
|
||||
if (line.includes('Missing Jellyfin session')) {
|
||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||
return normalizeJellyfinAppError(line);
|
||||
}
|
||||
}
|
||||
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() : '';
|
||||
export function parseJellyfinPreviewAuthResponse(
|
||||
payload: Record<string, unknown>,
|
||||
): JellyfinPreviewAuthPayload | null {
|
||||
const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl'));
|
||||
const accessToken = readString(payload, 'accessToken');
|
||||
const userId = readString(payload, 'userId');
|
||||
if (!serverUrl || !accessToken) return null;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
};
|
||||
return { serverUrl, accessToken, userId };
|
||||
}
|
||||
|
||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
||||
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
|
||||
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 {
|
||||
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
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.`);
|
||||
const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000;
|
||||
const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000;
|
||||
|
||||
function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null {
|
||||
try {
|
||||
return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (attempt.error) {
|
||||
fail(attempt.error);
|
||||
}
|
||||
return attempt.output;
|
||||
}
|
||||
|
||||
async function runAppJellyfinCommand(
|
||||
// Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper
|
||||
// process usually hands the command to an already-running instance and exits at once with no
|
||||
// output, so the reply file is the only result channel that works in both cases.
|
||||
async function requestJellyfinReplyFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||
timeoutMs: number,
|
||||
): Promise<JellyfinAppReply> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-'));
|
||||
const responsePath = path.join(tmpDir, 'response.json');
|
||||
const forwarded = buildForwardedJellyfinAppArgs(args, [
|
||||
...appArgs,
|
||||
`--jellyfin-response-path=${responsePath}`,
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
const runOnce = (): { status: number; output: string; error: string } => {
|
||||
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 };
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
|
||||
};
|
||||
|
||||
let retriedAfterStart = false;
|
||||
try {
|
||||
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 = nowMs() + settleWindowMs;
|
||||
const settleOffset = attempt.logOffset;
|
||||
while (nowMs() < 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',
|
||||
// Start it the way `subminer app` does: the app outlives this picker and the playback, so it
|
||||
// needs its tray to be visible and quittable.
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`${label}: starting app in the background, then retrying command`,
|
||||
);
|
||||
if (attempt.status !== 0 || attempt.error) {
|
||||
return null;
|
||||
launchAppBackgroundDetached(appPath, args.logLevel);
|
||||
adoptLauncherStartedApp(appPath, args);
|
||||
// A cold start (an AppImage mount, first-run setup) can take a while to claim the instance
|
||||
// lock, so keep retrying until the app takes the command or the request deadline passes.
|
||||
const startDeadline = nowMs() + timeoutMs;
|
||||
do {
|
||||
await sleep(1000);
|
||||
attempt = runOnce();
|
||||
} while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline);
|
||||
}
|
||||
// A written reply is authoritative; the exit status and log lines only explain a missing one.
|
||||
const earlyReply = readJellyfinAppReply(responsePath);
|
||||
if (earlyReply) return earlyReply;
|
||||
if (attempt.status !== 0) {
|
||||
return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` };
|
||||
}
|
||||
if (attempt.error) {
|
||||
return { ok: false, error: attempt.error };
|
||||
}
|
||||
|
||||
const deadline = nowMs() + 4000;
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
const reply = readJellyfinAppReply(responsePath);
|
||||
if (reply) return reply;
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJellyfinPreviewAuthFromApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): Promise<JellyfinPreviewAuthPayload | null> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-preview-auth'],
|
||||
'jellyfin-preview-auth',
|
||||
JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`);
|
||||
return null;
|
||||
}
|
||||
return parseJellyfinPreviewAuthResponse(reply.payload);
|
||||
}
|
||||
|
||||
async function requestJellyfinListFromApp<T>(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
parse: (payload: Record<string, unknown>) => T[] | null,
|
||||
): Promise<T[]> {
|
||||
const reply = await requestJellyfinReplyFromApp(
|
||||
appPath,
|
||||
args,
|
||||
appArgs,
|
||||
label,
|
||||
JELLYFIN_APP_REPLY_TIMEOUT_MS,
|
||||
);
|
||||
if (!reply.ok) fail(reply.error);
|
||||
const entries = parse(reply.payload);
|
||||
if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`);
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveJellyfinSelectionViaApp(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
const listLibrariesOutput = await runAppJellyfinListCommand(
|
||||
const libraries = await requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
['--jellyfin-libraries'],
|
||||
'jellyfin-libraries',
|
||||
parseJellyfinLibrariesReply,
|
||||
);
|
||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
||||
if (libraries.length === 0) {
|
||||
fail('No Jellyfin libraries found.');
|
||||
}
|
||||
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
|
||||
if (includeItemTypes) {
|
||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
||||
}
|
||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
||||
return parseJellyfinItemsFromAppOutput(output);
|
||||
return requestJellyfinListFromApp(
|
||||
appPath,
|
||||
args,
|
||||
itemArgs,
|
||||
'jellyfin-items',
|
||||
parseJellyfinItemsReply,
|
||||
);
|
||||
};
|
||||
|
||||
let rootItems =
|
||||
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
|
||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
if (!deps.isAppManagedByLauncher()) {
|
||||
// A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app
|
||||
// quit with mpv on its own.
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
// The picker started the app, so this playback owns it, as with local playback: hand over the
|
||||
// item, wait for mpv to close, then exit, which stops the app.
|
||||
const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded);
|
||||
if (handoff.status !== 0) {
|
||||
fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.');
|
||||
}
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'jellyfin-play: waiting for mpv to close before stopping the app',
|
||||
);
|
||||
await deps.waitForUnixSocketClosed(mpvSocketPath);
|
||||
return deps.exitProcess(0);
|
||||
}
|
||||
@@ -1121,6 +1121,21 @@ function clearOverlayManagedByLauncher(): void {
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
|
||||
// For commands that start the app ahead of playback (the Jellyfin picker): the launcher now owns
|
||||
// it, as with local playback, and stops it when the launcher exits for any reason, including a
|
||||
// cancelled picker or Ctrl+C.
|
||||
export function adoptLauncherStartedApp(appPath: string, args: Args): void {
|
||||
if (state.overlayManagedByLauncher) return;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
process.once('exit', () => stopManagedOverlayApp(args));
|
||||
process.once('SIGINT', () => process.exit(130));
|
||||
process.once('SIGTERM', () => process.exit(143));
|
||||
}
|
||||
|
||||
export function isAppManagedByLauncher(): boolean {
|
||||
return state.overlayManagedByLauncher;
|
||||
}
|
||||
|
||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||
if (result.error) {
|
||||
@@ -1723,6 +1738,13 @@ export async function canConnectUnixSocket(socketPath: string): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves once mpv stops accepting connections on its IPC socket, i.e. the player was closed.
|
||||
export async function waitForUnixSocketClosed(socketPath: string, pollMs = 1000): Promise<void> {
|
||||
while (await canConnectUnixSocket(socketPath)) {
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
|
||||
+19
-3
@@ -119,11 +119,27 @@ export async function promptOptionalJellyfinSearch(
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
if (useRofi && commandExists('rofi')) {
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||
// Input-only prompt: the shipped theme pins a tall window and has no message widget, so
|
||||
// drop the listview (an empty row there reads as "no results") and show the hint instead.
|
||||
const inputOnlyTheme =
|
||||
'window { height: 0; } mainbox { children: [inputbar, message]; } ' +
|
||||
'message { background-color: transparent; padding: 10px 18px 4px 18px; } ' +
|
||||
'textbox { background-color: transparent; }';
|
||||
const rofiArgs = [
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-p',
|
||||
formatRofiPrompt('Jellyfin Search (optional)'),
|
||||
'-mesg',
|
||||
'Type to search, or press Enter to browse all',
|
||||
];
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
rofiArgs.push('-theme', themePath, '-theme-str', inputOnlyTheme);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
rofiArgs.push(
|
||||
'-theme-str',
|
||||
`configuration { font: "Noto Sans CJK JP Regular 8";} ${inputOnlyTheme}`,
|
||||
);
|
||||
}
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: '\n',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start playing.
|
||||
- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
|
||||
- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
|
||||
|
||||
### Fixed
|
||||
- **YouTube**:
|
||||
- Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
|
||||
- On Windows, the subtitle generation modal now works during YouTube playback.
|
||||
- On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
|
||||
- Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
|
||||
- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
|
||||
- **Stats**:
|
||||
- Each mined card is counted once instead of twice.
|
||||
- Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
|
||||
- Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
|
||||
- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
|
||||
- **Security Updates**: Updated Electron and undici to resolve security advisories.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
|
||||
- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||
|
||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||
|
||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||
@@ -925,9 +925,10 @@ test('handleCliCommand runs jellyfin command dispatcher', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
|
||||
handleCliCommand(makeArgs({ jellyfinPreviewAuth: true }), 'second-instance', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
|
||||
assert.equal(runJellyfinCallCount, 2);
|
||||
assert.equal(runJellyfinCallCount, 3);
|
||||
});
|
||||
|
||||
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
|
||||
|
||||
@@ -762,7 +762,8 @@ export function handleCliCommand(
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth
|
||||
) {
|
||||
runAsyncWithOsd(
|
||||
() => deps.runJellyfinCommand(args),
|
||||
|
||||
@@ -131,7 +131,11 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayManager,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildJellyfinTimelinePayload,
|
||||
JellyfinPlaybackReporter,
|
||||
} from './jellyfin-playback-reporter';
|
||||
|
||||
type FetchCall = { input: string; init: RequestInit };
|
||||
|
||||
function createReporter(respond: (input: string) => Response, logWarn?: (message: string) => void) {
|
||||
const calls: FetchCall[] = [];
|
||||
const reporter = new JellyfinPlaybackReporter({
|
||||
serverUrl: 'http://jellyfin.local/',
|
||||
accessToken: 'token-1',
|
||||
deviceId: 'device-1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.2.3',
|
||||
deviceName: 'host',
|
||||
fetchImpl: (async (input, init) => {
|
||||
calls.push({ input: String(input), init: init ?? {} });
|
||||
return respond(String(input));
|
||||
}) as typeof fetch,
|
||||
logWarn,
|
||||
});
|
||||
return { reporter, calls };
|
||||
}
|
||||
|
||||
test('reportProgress posts the timeline payload as this device and treats failure as non-fatal', async () => {
|
||||
let fail = false;
|
||||
const { reporter, calls } = createReporter(
|
||||
() => new Response(fail ? 'boom' : null, { status: fail ? 500 : 200 }),
|
||||
);
|
||||
const state = {
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
};
|
||||
|
||||
assert.equal(await reporter.reportProgress(state), true);
|
||||
fail = true;
|
||||
assert.equal(await reporter.reportProgress({ itemId: 'movie-2', positionTicks: 999 }), false);
|
||||
|
||||
const call = calls[0]!;
|
||||
assert.equal(call.input, 'http://jellyfin.local/Sessions/Playing/Progress');
|
||||
assert.deepEqual(
|
||||
JSON.parse(String(call.init.body)),
|
||||
JSON.parse(JSON.stringify(buildJellyfinTimelinePayload(state))),
|
||||
);
|
||||
assert.equal(
|
||||
(call.init.headers as Record<string, string>).Authorization,
|
||||
'MediaBrowser Client="SubMiner", Device="host", DeviceId="device-1", Version="1.2.3", Token="token-1"',
|
||||
);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const { reporter, calls } = createReporter(() => new Response(null, { status: 200 }));
|
||||
|
||||
assert.equal(
|
||||
await reporter.reportStopped({ itemId: 'movie-stop', positionTicks: 7654321 }),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(calls[0]!.input, 'http://jellyfin.local/Sessions/Playing/Stopped');
|
||||
const posted = JSON.parse(String(calls[0]!.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('warns once per failing endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
const { reporter } = createReporter(
|
||||
() => new Response(null, { status }),
|
||||
(message) => warnings.push(message),
|
||||
);
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await reporter.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await reporter.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinPlaybackReporterOptions {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
deviceId: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
deviceName?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
const JELLYFIN_REPORT_TIMEOUT_MS = 5000;
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
// Authenticated JSON POSTs as this device. Playback timeline reports (start, progress, stop)
|
||||
// are plain HTTP, so they work whether or not the cast-control websocket is connected; that
|
||||
// is how Jellyfin learns the resume position and marks items played for every playback.
|
||||
export class JellyfinPlaybackReporter {
|
||||
readonly authHeader: string;
|
||||
private readonly serverUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
constructor(options: JellyfinPlaybackReporterOptions) {
|
||||
this.serverUrl = options.serverUrl.trim().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.logWarn = options.logWarn;
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = `MediaBrowser Client="${clientName}", Device="${deviceName}", DeviceId="${options.deviceId}", Version="${clientVersion}", Token="${options.accessToken}"`;
|
||||
}
|
||||
|
||||
reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postJson('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
// The stop report waits for in-flight progress reports, so a hung request must not
|
||||
// hold it back indefinitely.
|
||||
signal: AbortSignal.timeout(JELLYFIN_REPORT_TIMEOUT_MS),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin request failed: POST ${path}`, failure);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
import { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
|
||||
class FakeWebSocket {
|
||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
@@ -240,105 +240,6 @@ test('Jellyfin remote stop prevents further reconnect/network activity', () => {
|
||||
assert.equal(service.isConnected(), false);
|
||||
});
|
||||
|
||||
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
let shouldFailTimeline = false;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-4',
|
||||
deviceId: 'device-4',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
|
||||
return new Response('boom', { status: 500 });
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
service.start();
|
||||
sockets[0]!.emit('open');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedPayload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
const expectedPostedPayload = Object.fromEntries(
|
||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const ok = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
isPaused: true,
|
||||
volumeLevel: 33,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
shouldFailTimeline = true;
|
||||
const failed = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 999,
|
||||
});
|
||||
|
||||
const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress'));
|
||||
assert.ok(timelineCall);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(failed, false);
|
||||
assert.ok(typeof timelineCall.init.body === 'string');
|
||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-stop-payload',
|
||||
deviceId: 'device-stop-payload',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
const ok = await service.reportStopped({
|
||||
itemId: 'movie-stop',
|
||||
positionTicks: 7654321,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
||||
assert.equal(ok, true);
|
||||
assert.ok(stoppedCall);
|
||||
assert.ok(typeof stoppedCall.init.body === 'string');
|
||||
const posted = JSON.parse(String(stoppedCall.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const calls: string[] = [];
|
||||
@@ -451,34 +352,6 @@ test('reconnects when the server stops answering keep-alives', () => {
|
||||
assert.equal(sockets.length, 2);
|
||||
});
|
||||
|
||||
test('warns once per failing timeline endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-warn',
|
||||
deviceId: 'device-warn',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await service.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('ignores messages from a superseded socket', () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const playPayloads: unknown[] = [];
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
import WebSocket from 'ws';
|
||||
import { JellyfinPlaybackReporter } from './jellyfin-playback-reporter';
|
||||
|
||||
export interface JellyfinRemoteSessionMessage {
|
||||
MessageType?: string;
|
||||
Data?: unknown;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePlaybackState {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
playbackStartTimeTicks?: number;
|
||||
isPaused?: boolean;
|
||||
isMuted?: boolean;
|
||||
canSeek?: boolean;
|
||||
volumeLevel?: number;
|
||||
playbackRate?: number;
|
||||
playMethod?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
ItemId: string;
|
||||
MediaSourceId?: string;
|
||||
PositionTicks: number;
|
||||
PlaybackStartTimeTicks: number;
|
||||
IsPaused: boolean;
|
||||
IsMuted: boolean;
|
||||
CanSeek: boolean;
|
||||
VolumeLevel: number;
|
||||
PlaybackRate: number;
|
||||
PlayMethod: string;
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
interface JellyfinRemoteSocket {
|
||||
on(event: 'open', listener: () => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
@@ -99,16 +65,6 @@ function normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function clampVolume(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTicks(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function parseMessageData(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
@@ -137,11 +93,6 @@ function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | n
|
||||
}
|
||||
}
|
||||
|
||||
function asNullableInteger(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function createDefaultCapabilities(): {
|
||||
PlayableMediaTypes: string;
|
||||
SupportedCommands: string;
|
||||
@@ -155,40 +106,6 @@ function createDefaultCapabilities(): {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(params: {
|
||||
clientName: string;
|
||||
deviceName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
}): string {
|
||||
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
|
||||
}
|
||||
|
||||
export function buildJellyfinTimelinePayload(
|
||||
state: JellyfinTimelinePlaybackState,
|
||||
): JellyfinTimelinePayload {
|
||||
return {
|
||||
ItemId: state.itemId,
|
||||
MediaSourceId: state.mediaSourceId,
|
||||
PositionTicks: normalizeTicks(state.positionTicks),
|
||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||
IsPaused: state.isPaused === true,
|
||||
IsMuted: state.isMuted === true,
|
||||
CanSeek: state.canSeek !== false,
|
||||
VolumeLevel: clampVolume(state.volumeLevel),
|
||||
PlaybackRate:
|
||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||
? state.playbackRate
|
||||
: 1,
|
||||
PlayMethod: state.playMethod || 'DirectPlay',
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
export class JellyfinRemoteSessionService {
|
||||
private readonly serverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
@@ -210,6 +127,8 @@ export class JellyfinRemoteSessionService {
|
||||
SupportsMediaControl: boolean;
|
||||
};
|
||||
private readonly authHeader: string;
|
||||
// Shares this device's identity for capability posts; playback reports use their own reporter.
|
||||
private readonly http: JellyfinPlaybackReporter;
|
||||
private readonly onConnected?: () => void;
|
||||
private readonly onDisconnected?: () => void;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
@@ -217,7 +136,6 @@ export class JellyfinRemoteSessionService {
|
||||
private keepAliveTimeoutMs: number;
|
||||
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastInboundAtMs = 0;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
private readonly reconnectBaseDelayMs: number;
|
||||
private readonly reconnectMaxDelayMs: number;
|
||||
@@ -243,19 +161,20 @@ export class JellyfinRemoteSessionService {
|
||||
...createDefaultCapabilities(),
|
||||
...(options.capabilities ?? {}),
|
||||
};
|
||||
const clientName = options.clientName || 'SubMiner';
|
||||
const clientVersion = options.clientVersion || '0.1.0';
|
||||
const deviceName = options.deviceName || clientName;
|
||||
this.authHeader = buildAuthorizationHeader({
|
||||
clientName,
|
||||
deviceName,
|
||||
clientVersion,
|
||||
deviceId: this.deviceId,
|
||||
this.logWarn = options.logWarn;
|
||||
this.http = new JellyfinPlaybackReporter({
|
||||
serverUrl: this.serverUrl,
|
||||
accessToken: this.accessToken,
|
||||
deviceId: this.deviceId,
|
||||
clientName: options.clientName,
|
||||
clientVersion: options.clientVersion,
|
||||
deviceName: options.deviceName,
|
||||
fetchImpl: this.fetchImpl,
|
||||
logWarn: this.logWarn,
|
||||
});
|
||||
this.authHeader = this.http.authHeader;
|
||||
this.onConnected = options.onConnected;
|
||||
this.onDisconnected = options.onDisconnected;
|
||||
this.logWarn = options.logWarn;
|
||||
this.now = options.getNow ?? Date.now;
|
||||
this.keepAliveTimeoutMs = Math.max(
|
||||
1000,
|
||||
@@ -298,21 +217,6 @@ export class JellyfinRemoteSessionService {
|
||||
return this.isRegisteredOnServer();
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
private connectSocket(): void {
|
||||
if (!this.running) return;
|
||||
if (this.reconnectTimer) {
|
||||
@@ -441,9 +345,9 @@ export class JellyfinRemoteSessionService {
|
||||
|
||||
private async postCapabilities(): Promise<void> {
|
||||
const payload = this.capabilities;
|
||||
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
|
||||
const fullEndpointOk = await this.http.postJson('/Sessions/Capabilities/Full', payload);
|
||||
if (fullEndpointOk) return;
|
||||
await this.postJson('/Sessions/Capabilities', payload);
|
||||
await this.http.postJson('/Sessions/Capabilities', payload);
|
||||
}
|
||||
|
||||
private async isRegisteredOnServer(): Promise<boolean> {
|
||||
@@ -462,40 +366,6 @@ export class JellyfinRemoteSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
|
||||
return this.postJson(path, payload);
|
||||
}
|
||||
|
||||
private async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||
}
|
||||
|
||||
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||
const message = parseInboundMessage(rawData);
|
||||
if (!message) return;
|
||||
|
||||
+16
-8
@@ -49,6 +49,7 @@ import {
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './main/runtime/jellyfin-remote-playback';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import { writeJellyfinCliResponse } from './shared/jellyfin-cli-response';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
@@ -322,6 +323,7 @@ import {
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
showMpvOsdRuntime,
|
||||
JellyfinPlaybackReporter,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
upsertYomitanDictionarySettings,
|
||||
@@ -399,6 +401,7 @@ import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
resolveJellyfinRemoteDeviceName,
|
||||
} from './main/runtime/jellyfin-device-identity';
|
||||
import {
|
||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||
@@ -3311,9 +3314,17 @@ const {
|
||||
setLastProgressAtMs: (value) => {
|
||||
jellyfinRemoteLastProgressAtMs = value;
|
||||
},
|
||||
reportPlaying: (payload) => {
|
||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
||||
},
|
||||
// Same device identity as the cast session, so Jellyfin attributes reports to one session.
|
||||
createPlaybackReporter: ({ session, clientInfo }) =>
|
||||
new JellyfinPlaybackReporter({
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
deviceId: clientInfo.deviceId,
|
||||
clientName: clientInfo.clientName,
|
||||
clientVersion: clientInfo.clientVersion,
|
||||
deviceName: resolveJellyfinRemoteDeviceName({ hostName: os.hostname() }),
|
||||
logWarn: (message, details) => logger.warn(message, details),
|
||||
}),
|
||||
showMpvOsd: (text) => {
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||
},
|
||||
@@ -3335,7 +3346,6 @@ const {
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
},
|
||||
getSession: () => appState.jellyfinRemoteSession,
|
||||
getNow: () => Date.now(),
|
||||
getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
@@ -3364,10 +3374,7 @@ const {
|
||||
listJellyfinItemsRuntime(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (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');
|
||||
},
|
||||
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
handleJellyfinPlayCommandMainDeps: {
|
||||
@@ -3404,6 +3411,7 @@ const {
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||
},
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps: {
|
||||
getSetupWindow: () => appState.jellyfinSetupWindow,
|
||||
|
||||
@@ -44,7 +44,6 @@ export type JellyfinRemoteComposerOptions = ComposerInputs<{
|
||||
>[0]['jellyfinTicksToSeconds'];
|
||||
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
|
||||
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
|
||||
getSession: JellyfinRemoteProgressMainDeps['getSession'];
|
||||
getNow: JellyfinRemoteProgressMainDeps['getNow'];
|
||||
getLastProgressAtMs: Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
@@ -74,7 +73,6 @@ export function composeJellyfinRemoteHandlers(
|
||||
createBuildReportJellyfinRemoteProgressMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
getLastProgressAtMs: options.getLastProgressAtMs,
|
||||
@@ -87,7 +85,6 @@ export function composeJellyfinRemoteHandlers(
|
||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHandleJellyfinListCommands } from './jellyfin-cli-list';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
const baseSession = {
|
||||
serverUrl: 'http://localhost',
|
||||
@@ -24,7 +25,7 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -42,13 +43,14 @@ test('list handler no-ops when no list command is set', async () => {
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test('list handler logs libraries', async () => {
|
||||
test('list handler logs libraries and writes them to the response path', async () => {
|
||||
const logs: string[] = [];
|
||||
const writes: Array<{ path: string; response: JellyfinCliResponse }> = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -57,6 +59,7 @@ test('list handler logs libraries', async () => {
|
||||
jellyfinLibraries: true,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinResponsePath: '/tmp/reply.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -65,6 +68,12 @@ test('list handler logs libraries', async () => {
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)')));
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/reply.json',
|
||||
response: { libraries: [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('list handler resolves items using default library id', async () => {
|
||||
@@ -72,6 +81,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
let usedRecursive: boolean | undefined;
|
||||
let usedIncludeItemTypes: string | undefined;
|
||||
const logs: string[] = [];
|
||||
const writes: JellyfinCliResponse[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||
@@ -81,7 +91,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||
},
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: (_path, response) => writes.push(response),
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -95,6 +105,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
jellyfinLimit: 10,
|
||||
jellyfinRecursive: false,
|
||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
||||
jellyfinResponsePath: '/tmp/reply.json',
|
||||
} as never,
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
@@ -108,6 +119,7 @@ test('list handler resolves items using default library id', async () => {
|
||||
assert.equal(usedRecursive, false);
|
||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||
assert.deepEqual(writes, [{ items: [{ id: 'item1', title: 'Episode 1', type: 'Episode' }] }]);
|
||||
});
|
||||
|
||||
test('list handler throws when items command has no library id', async () => {
|
||||
@@ -115,7 +127,7 @@ test('list handler throws when items command has no library id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -144,7 +156,7 @@ test('list handler logs subtitle urls only when requested', async () => {
|
||||
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
||||
{ index: 2, language: 'jpn' },
|
||||
],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
@@ -170,7 +182,7 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
@@ -190,16 +202,13 @@ test('list handler throws when subtitle command has no item id', async () => {
|
||||
});
|
||||
|
||||
test('list handler writes preview auth payload to response path', async () => {
|
||||
const writes: Array<{
|
||||
path: string;
|
||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
||||
}> = [];
|
||||
const writes: Array<{ path: string; payload: JellyfinCliResponse }> = [];
|
||||
const logs: string[] = [];
|
||||
const handler = createHandleJellyfinListCommands({
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
writeJellyfinResponse: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logInfo: (message) => logs.push(message),
|
||||
@@ -234,7 +243,7 @@ test('list handler throws when preview auth command has no response path', async
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
writeJellyfinResponse: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
@@ -17,12 +18,6 @@ type JellyfinConfig = {
|
||||
defaultLibraryId: string;
|
||||
};
|
||||
|
||||
type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function createHandleJellyfinListCommands(deps: {
|
||||
listJellyfinLibraries: (
|
||||
session: JellyfinSession,
|
||||
@@ -56,7 +51,7 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
deliveryUrl?: string | null;
|
||||
}>
|
||||
>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
@@ -66,13 +61,13 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
jellyfinConfig: JellyfinConfig;
|
||||
}): Promise<boolean> => {
|
||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
|
||||
if (args.jellyfinPreviewAuth) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (!responsePath) {
|
||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
||||
}
|
||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
@@ -83,6 +78,15 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
|
||||
if (args.jellyfinLibraries) {
|
||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||
if (responsePath) {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
libraries: libraries.map((library) => ({
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
collectionType: library.collectionType || library.type || 'unknown',
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (libraries.length === 0) {
|
||||
deps.logInfo('No Jellyfin libraries found.');
|
||||
return true;
|
||||
@@ -109,6 +113,11 @@ export function createHandleJellyfinListCommands(deps: {
|
||||
recursive: args.jellyfinRecursive,
|
||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
||||
});
|
||||
if (responsePath) {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
items: items.map((item) => ({ id: item.id, title: item.title, type: item.type })),
|
||||
});
|
||||
}
|
||||
if (items.length === 0) {
|
||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||
return true;
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
|
||||
deps.listJellyfinItems(session, clientInfo, params),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
||||
deps.writeJellyfinPreviewAuth(responsePath, payload),
|
||||
writeJellyfinResponse: (responsePath, response) =>
|
||||
deps.writeJellyfinResponse(responsePath, response),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinConfigBase = {
|
||||
serverUrl?: string;
|
||||
@@ -37,6 +38,7 @@ export type RunJellyfinCommandMainDeps<TClientInfo, TConfig extends JellyfinConf
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
};
|
||||
|
||||
export function createBuildRunJellyfinCommandMainDepsHandler<
|
||||
@@ -51,5 +53,7 @@ export function createBuildRunJellyfinCommandMainDepsHandler<
|
||||
handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args),
|
||||
handleListCommands: (params) => deps.handleListCommands(params),
|
||||
handlePlayCommand: (params) => deps.handlePlayCommand(params),
|
||||
writeJellyfinResponse: (responsePath, response) =>
|
||||
deps.writeJellyfinResponse(responsePath, response),
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
||||
calls.push('list');
|
||||
return false;
|
||||
},
|
||||
writeJellyfinResponse: () => {},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
@@ -76,7 +77,8 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
||||
assert.deepEqual(calls, ['auth']);
|
||||
});
|
||||
|
||||
test('run jellyfin command throws when session missing after auth', async () => {
|
||||
test('run jellyfin command throws when session missing and writes the error reply', async () => {
|
||||
const writes: Array<{ path: string; response: unknown }> = [];
|
||||
const run = createRunJellyfinCommandHandler({
|
||||
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
|
||||
defaultServerUrl: '',
|
||||
@@ -84,10 +86,20 @@ test('run jellyfin command throws when session missing after auth', async () =>
|
||||
handleAuthCommands: async () => false,
|
||||
handleRemoteAnnounceCommand: async () => false,
|
||||
handleListCommands: async () => false,
|
||||
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||
handlePlayCommand: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
|
||||
await assert.rejects(
|
||||
() => run(createArgs({ jellyfinResponsePath: '/tmp/reply.json' })),
|
||||
/Missing Jellyfin session/,
|
||||
);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/reply.json',
|
||||
response: { error: 'Missing Jellyfin session. Run --jellyfin-login first.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
|
||||
@@ -116,6 +128,7 @@ test('run jellyfin command dispatches remote/list/play in order until handled',
|
||||
seenServerUrls.push(session.serverUrl);
|
||||
return true;
|
||||
},
|
||||
writeJellyfinResponse: () => {},
|
||||
handlePlayCommand: async () => {
|
||||
calls.push('play');
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||
|
||||
type JellyfinConfigBase = {
|
||||
serverUrl?: string;
|
||||
@@ -40,8 +41,9 @@ export function createRunJellyfinCommandHandler<
|
||||
clientInfo: TClientInfo;
|
||||
jellyfinConfig: TConfig;
|
||||
}) => Promise<boolean>;
|
||||
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||
}) {
|
||||
return async (args: CliArgs): Promise<void> => {
|
||||
const run = async (args: CliArgs): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
const serverUrl =
|
||||
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
|
||||
@@ -97,4 +99,23 @@ export function createRunJellyfinCommandHandler<
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// A launcher waiting on --jellyfin-response-path gets the failure reason instead of a timeout.
|
||||
return async (args: CliArgs): Promise<void> => {
|
||||
try {
|
||||
await run(args);
|
||||
} catch (error) {
|
||||
const responsePath = args.jellyfinResponsePath?.trim();
|
||||
if (responsePath) {
|
||||
try {
|
||||
deps.writeJellyfinResponse(responsePath, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch {
|
||||
// Keep the original failure; the launcher falls back to its timeout.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params),
|
||||
setActivePlayback: (state) => deps.setActivePlayback(state),
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
createPlaybackReporter: (params) => deps.createPlaybackReporter(params),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
|
||||
|
||||
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
|
||||
@@ -46,7 +47,13 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const events: string[] = [];
|
||||
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
|
||||
const reports: Array<Parameters<Deps['reportPlaying']>[0]> = [];
|
||||
const reports: JellyfinTimelinePlaybackState[] = [];
|
||||
const reporterParams: Array<Parameters<Deps['createPlaybackReporter']>[0]> = [];
|
||||
const reporter = {
|
||||
reportPlaying: async (state: JellyfinTimelinePlaybackState) => void reports.push(state),
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
};
|
||||
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
@@ -67,7 +74,10 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
events.push(`active:${String(state.loadedMediaPath)}`);
|
||||
},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reports.push(payload),
|
||||
createPlaybackReporter: (params) => {
|
||||
reporterParams.push(params);
|
||||
return reporter;
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
...options.deps,
|
||||
});
|
||||
@@ -77,6 +87,8 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
||||
events,
|
||||
activeStates,
|
||||
reports,
|
||||
reporter,
|
||||
reporterParams,
|
||||
play: (params: Partial<PlayParams> = {}) =>
|
||||
handler({
|
||||
session: baseSession,
|
||||
@@ -141,6 +153,8 @@ test('playback handler disables mpv subtitle selection, then loads media and rep
|
||||
assert.equal(harness.activeStates.length, 1);
|
||||
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
||||
assert.equal(harness.activeStates[0]?.reporter, harness.reporter);
|
||||
assert.deepEqual(harness.reporterParams, [{ session: baseSession, clientInfo: baseClientInfo }]);
|
||||
assert.equal(harness.reports.length, 1);
|
||||
assert.equal(harness.reports[0]?.eventName, 'start');
|
||||
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||
import type { JellyfinConfig } from '../../types';
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
import type { JellyfinPlaybackReporterLike } from './jellyfin-remote-commands';
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
clientName: string;
|
||||
@@ -10,6 +11,7 @@ type JellyfinClientInfo = {
|
||||
|
||||
type ActivePlaybackState = {
|
||||
itemId: string;
|
||||
reporter: JellyfinPlaybackReporterLike;
|
||||
mediaSourceId: undefined;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
@@ -134,16 +136,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
}) => void | Promise<void>;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
positionTicks?: number;
|
||||
isPaused?: boolean;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
// Reports go out as the device and account that started this playback, for CLI play and
|
||||
// casting alike, so Jellyfin keeps the resume point and played state either way.
|
||||
createPlaybackReporter: (params: {
|
||||
session: JellyfinAuthSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
}) => JellyfinPlaybackReporterLike;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (
|
||||
metadata: JellyfinPlaybackStatsMetadata,
|
||||
@@ -210,8 +208,13 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
const reporter = deps.createPlaybackReporter({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
});
|
||||
deps.setActivePlayback({
|
||||
itemId: params.itemId,
|
||||
reporter,
|
||||
mediaSourceId: undefined,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
@@ -237,7 +240,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
);
|
||||
deps.showVisibleOverlay();
|
||||
|
||||
deps.reportPlaying({
|
||||
void reporter.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
type JellyfinRemotePlayHandlerDeps,
|
||||
} from './jellyfin-remote-commands';
|
||||
|
||||
const noopReporter = {
|
||||
reportPlaying: async () => {},
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
};
|
||||
|
||||
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
||||
assert.equal(
|
||||
getConfiguredJellyfinSession({
|
||||
@@ -122,7 +128,11 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
|
||||
|
||||
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
||||
const { handlePlay, played } = makePlayHandler({
|
||||
getActivePlayback: () => ({ itemId: 'item-1', playMethod: 'DirectPlay' }),
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
reporter: noopReporter,
|
||||
}),
|
||||
});
|
||||
|
||||
await handlePlay({ ItemIds: ['item-1'] });
|
||||
@@ -207,6 +217,7 @@ test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices',
|
||||
const playback: ActiveJellyfinRemotePlaybackState = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
reporter: noopReporter,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||
|
||||
type JellyfinTimelineReport = (state: JellyfinTimelinePlaybackState) => Promise<unknown>;
|
||||
|
||||
export type JellyfinPlaybackReporterLike = {
|
||||
reportPlaying: JellyfinTimelineReport;
|
||||
reportProgress: JellyfinTimelineReport;
|
||||
reportStopped: JellyfinTimelineReport;
|
||||
};
|
||||
|
||||
export type ActiveJellyfinRemotePlaybackState = {
|
||||
itemId: string;
|
||||
// Bound at launch to the session that started playback, so reports flow for CLI play and
|
||||
// casting alike, independent of the cast-control websocket.
|
||||
reporter: JellyfinPlaybackReporterLike;
|
||||
mediaSourceId?: string;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
|
||||
@@ -53,7 +53,6 @@ export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
|
||||
return (): JellyfinRemoteProgressReporterDeps => ({
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: () => deps.getNow(),
|
||||
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
|
||||
@@ -70,7 +69,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
return (): JellyfinRemoteStoppedReporterDeps => ({
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
|
||||
@@ -8,26 +8,31 @@ import {
|
||||
secondsToJellyfinTicks,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './jellyfin-remote-playback';
|
||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
||||
import type {
|
||||
ActiveJellyfinRemotePlaybackState,
|
||||
JellyfinPlaybackReporterLike,
|
||||
} from './jellyfin-remote-commands';
|
||||
|
||||
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
|
||||
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
|
||||
type Session = NonNullable<ReturnType<ProgressDeps['getSession']>>;
|
||||
|
||||
const TICKS_PER_SECOND = 10_000_000;
|
||||
|
||||
const makeReporter = (
|
||||
overrides: Partial<JellyfinPlaybackReporterLike> = {},
|
||||
): JellyfinPlaybackReporterLike => ({
|
||||
reportPlaying: async () => {},
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePlayback = (
|
||||
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
|
||||
): ActiveJellyfinRemotePlaybackState => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSession = (overrides: Partial<Session> = {}): Session => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
reporter: makeReporter(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -35,7 +40,6 @@ function makeProgressDeps(overrides: Partial<ProgressDeps> = {}): ProgressDeps {
|
||||
return {
|
||||
getActivePlayback: () => makePlayback(),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => makeSession(),
|
||||
getMpvClient: () => ({ requestProperty: async () => 1 }),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
@@ -51,7 +55,6 @@ function makeStoppedDeps(overrides: Partial<StoppedDeps> = {}): StoppedDeps {
|
||||
return {
|
||||
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => makeSession(),
|
||||
getMpvClient: () => ({ currentTimePos: 0 }),
|
||||
ticksPerSecond: TICKS_PER_SECOND,
|
||||
logDebug: () => {},
|
||||
@@ -69,29 +72,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
||||
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
||||
makePlayback({ loadedMediaPath: null }),
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||
'/tmp/local.mkv',
|
||||
),
|
||||
true,
|
||||
@@ -100,12 +95,13 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
||||
let lastProgressAtMs = 0;
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks?: number; isPaused?: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
itemId: payload.itemId,
|
||||
@@ -114,6 +110,7 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
|
||||
}),
|
||||
@@ -135,44 +132,19 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
assert.equal(lastProgressAtMs, 5000);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
isConnected: () => false,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 42,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||
const reportPayloads: Array<{ isPaused?: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({ isPaused: payload.isPaused });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
||||
}),
|
||||
@@ -188,12 +160,14 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
||||
let called = false;
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async () => {
|
||||
called = true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getNow: () => 4000,
|
||||
getLastProgressAtMs: () => 3500,
|
||||
}),
|
||||
@@ -207,12 +181,13 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
|
||||
let now = 5000;
|
||||
let lastProgressAtMs = 0;
|
||||
let position = 10;
|
||||
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
||||
const reportPayloads: Array<{ positionTicks?: number; eventName?: string }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||
makeProgressDeps({
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
@@ -220,6 +195,7 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||
@@ -253,12 +229,11 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode' }),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'Transcode',
|
||||
reporter: makeReporter({
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
@@ -267,6 +242,10 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
requestProperty: async () => {
|
||||
@@ -289,16 +268,18 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
||||
let cleared = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
getActivePlayback: () => makePlayback({ loadedMediaPath: null }),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
getActivePlayback: () =>
|
||||
makePlayback({
|
||||
loadedMediaPath: null,
|
||||
reporter: makeReporter({
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
}),
|
||||
);
|
||||
@@ -308,43 +289,6 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
makeStoppedDeps({
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
isConnected: () => false,
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({ currentTimePos: 12.5 }),
|
||||
}),
|
||||
);
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-1',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
||||
let cleared = false;
|
||||
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
||||
@@ -354,12 +298,7 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
|
||||
makePlayback({
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
lastKnownPositionSeconds: 72.25,
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reporter: makeReporter({
|
||||
reportProgress: async (payload) => {
|
||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||
},
|
||||
@@ -368,6 +307,10 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await reportStopped();
|
||||
@@ -423,16 +366,15 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
makePlayback({
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
stopReportsAfterMs: 20_000,
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () =>
|
||||
makeSession({
|
||||
reporter: makeReporter({
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getNow: () => 12_000,
|
||||
}),
|
||||
);
|
||||
@@ -445,34 +387,28 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
||||
const tracker = createJellyfinRemoteReportTracker();
|
||||
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let releaseProgress: () => void = () => undefined;
|
||||
const progressGate = new Promise<void>((resolve) => {
|
||||
releaseProgress = resolve;
|
||||
});
|
||||
const session = {
|
||||
isConnected: () => true,
|
||||
reportProgress: async ({ eventName }: { eventName: string }) => {
|
||||
let playback: ActiveJellyfinRemotePlaybackState | null = makePlayback({
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
reporter: makeReporter({
|
||||
reportProgress: async ({ eventName }) => {
|
||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||
if (calls.length === 1) await progressGate;
|
||||
return true;
|
||||
},
|
||||
reportStopped: async () => {
|
||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
const shared = {
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {
|
||||
playback = null;
|
||||
},
|
||||
getSession: () => session,
|
||||
getMpvClient: () => ({ currentTimePos: 42 }),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => undefined,
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
||||
|
||||
type JellyfinRemoteSessionLike = {
|
||||
isConnected: () => boolean;
|
||||
reportProgress: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks: number;
|
||||
isPaused: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'TimeUpdate';
|
||||
}) => Promise<unknown>;
|
||||
reportStopped: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'stop';
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
@@ -160,7 +136,6 @@ export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow: () => number;
|
||||
getLastProgressAtMs: () => number;
|
||||
@@ -179,9 +154,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
const report = async (force: boolean): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
const now = deps.getNow();
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
@@ -201,7 +173,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
return;
|
||||
}
|
||||
const paused = await mpvClient?.requestProperty?.('pause');
|
||||
await session.reportProgress({
|
||||
await playback.reporter.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
@@ -228,7 +200,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
@@ -252,12 +223,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const session = deps.getSession();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
// Clear before any network call so progress ticks fired during the stop find nothing to
|
||||
// report, then let reports already in flight finish so none can arrive after the stop.
|
||||
deps.clearActivePlayback();
|
||||
@@ -267,7 +232,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
||||
try {
|
||||
await session.reportProgress({
|
||||
await playback.reporter.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
@@ -280,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
const reported = await session.reportStopped({
|
||||
const reported = await playback.reporter.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Structured replies the app writes to `--jellyfin-response-path`. The launcher passes a temp
|
||||
// path and waits for the file instead of scraping logs, because the command usually runs in an
|
||||
// already-running app instance whose output never reaches the launcher's helper process.
|
||||
|
||||
export type JellyfinCliLibrary = {
|
||||
id: string;
|
||||
name: string;
|
||||
collectionType: string;
|
||||
};
|
||||
|
||||
export type JellyfinCliItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type JellyfinPreviewAuthPayload = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type JellyfinCliResponse =
|
||||
| { libraries: JellyfinCliLibrary[] }
|
||||
| { items: JellyfinCliItem[] }
|
||||
| { error: string }
|
||||
| JellyfinPreviewAuthPayload;
|
||||
|
||||
// Writes via rename so a polling reader never parses a half-written file.
|
||||
export function writeJellyfinCliResponse(
|
||||
responsePath: string,
|
||||
response: JellyfinCliResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
const tempPath = `${responsePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(response), 'utf-8');
|
||||
fs.renameSync(tempPath, responsePath);
|
||||
}
|
||||
Reference in new issue
Block a user