mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
b1bdeabca8
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * fix(jellyfin): subtitle timing, resume progress, and overlay sync - Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX * test: update lifecycle cleanup assertion * fix: clear aborted playback state, fix overlay passthrough, and guard du - Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
|
|
|
class FakeWebSocket {
|
|
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
|
|
on(event: string, listener: (...args: unknown[]) => void): this {
|
|
if (!this.listeners[event]) {
|
|
this.listeners[event] = [];
|
|
}
|
|
this.listeners[event].push(listener);
|
|
return this;
|
|
}
|
|
|
|
close(): void {
|
|
this.emit('close');
|
|
}
|
|
|
|
emit(event: string, ...args: unknown[]): void {
|
|
for (const listener of this.listeners[event] ?? []) {
|
|
listener(...args);
|
|
}
|
|
}
|
|
}
|
|
|
|
test('Jellyfin remote service has no traffic until started', async () => {
|
|
let socketCreateCount = 0;
|
|
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local:8096',
|
|
accessToken: 'token-0',
|
|
deviceId: 'device-0',
|
|
webSocketFactory: () => {
|
|
socketCreateCount += 1;
|
|
return new FakeWebSocket() as unknown as any;
|
|
},
|
|
fetchImpl: (async (input, init) => {
|
|
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.equal(socketCreateCount, 0);
|
|
assert.equal(fetchCalls.length, 0);
|
|
assert.equal(service.isConnected(), false);
|
|
});
|
|
|
|
test('start posts capabilities on socket connect', async () => {
|
|
const sockets: FakeWebSocket[] = [];
|
|
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local:8096',
|
|
accessToken: 'token-1',
|
|
deviceId: 'device-1',
|
|
webSocketFactory: (url) => {
|
|
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async (input, init) => {
|
|
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
service.start();
|
|
sockets[0]!.emit('open');
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(fetchCalls[0]!.input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full');
|
|
assert.equal(service.isConnected(), true);
|
|
});
|
|
|
|
test('socket headers include jellyfin authorization metadata', () => {
|
|
const seenHeaders: Record<string, string>[] = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local:8096',
|
|
accessToken: 'token-auth',
|
|
deviceId: 'device-auth',
|
|
clientName: 'SubMiner',
|
|
clientVersion: '0.1.0',
|
|
deviceName: 'SubMiner',
|
|
socketHeadersFactory: (_url, headers) => {
|
|
seenHeaders.push(headers);
|
|
return new FakeWebSocket() as unknown as any;
|
|
},
|
|
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
|
});
|
|
|
|
service.start();
|
|
assert.equal(seenHeaders.length, 1);
|
|
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
|
|
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
|
|
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
|
|
});
|
|
|
|
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
|
const sockets: FakeWebSocket[] = [];
|
|
const playPayloads: unknown[] = [];
|
|
const playstatePayloads: unknown[] = [];
|
|
const commandPayloads: unknown[] = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local',
|
|
accessToken: 'token-2',
|
|
deviceId: 'device-2',
|
|
webSocketFactory: () => {
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
|
onPlay: (payload) => playPayloads.push(payload),
|
|
onPlaystate: (payload) => playstatePayloads.push(payload),
|
|
onGeneralCommand: (payload) => commandPayloads.push(payload),
|
|
});
|
|
|
|
service.start();
|
|
const socket = sockets[0]!;
|
|
socket.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemId: 'movie-1' } }));
|
|
socket.emit(
|
|
'message',
|
|
JSON.stringify({ MessageType: 'Playstate', Data: JSON.stringify({ Command: 'Pause' }) }),
|
|
);
|
|
socket.emit(
|
|
'message',
|
|
Buffer.from(
|
|
JSON.stringify({
|
|
MessageType: 'GeneralCommand',
|
|
Data: { Name: 'DisplayMessage' },
|
|
}),
|
|
'utf8',
|
|
),
|
|
);
|
|
|
|
assert.deepEqual(playPayloads, [{ ItemId: 'movie-1' }]);
|
|
assert.deepEqual(playstatePayloads, [{ Command: 'Pause' }]);
|
|
assert.deepEqual(commandPayloads, [{ Name: 'DisplayMessage' }]);
|
|
});
|
|
|
|
test('schedules reconnect with bounded exponential backoff', () => {
|
|
const sockets: FakeWebSocket[] = [];
|
|
const delays: number[] = [];
|
|
const pendingTimers: Array<() => void> = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local',
|
|
accessToken: 'token-3',
|
|
deviceId: 'device-3',
|
|
webSocketFactory: () => {
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
|
reconnectBaseDelayMs: 100,
|
|
reconnectMaxDelayMs: 400,
|
|
setTimer: ((handler: () => void, delay?: number) => {
|
|
pendingTimers.push(handler);
|
|
delays.push(Number(delay));
|
|
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
|
|
}) as typeof setTimeout,
|
|
clearTimer: (() => {
|
|
return;
|
|
}) as typeof clearTimeout,
|
|
});
|
|
|
|
service.start();
|
|
sockets[0]!.emit('close');
|
|
pendingTimers.shift()?.();
|
|
sockets[1]!.emit('close');
|
|
pendingTimers.shift()?.();
|
|
sockets[2]!.emit('close');
|
|
pendingTimers.shift()?.();
|
|
sockets[3]!.emit('close');
|
|
|
|
assert.deepEqual(delays, [100, 200, 400, 400]);
|
|
assert.equal(sockets.length, 4);
|
|
});
|
|
|
|
test('Jellyfin remote stop prevents further reconnect/network activity', () => {
|
|
const sockets: FakeWebSocket[] = [];
|
|
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
const pendingTimers: Array<() => void> = [];
|
|
const clearedTimers: unknown[] = [];
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local',
|
|
accessToken: 'token-stop',
|
|
deviceId: 'device-stop',
|
|
webSocketFactory: () => {
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async (input, init) => {
|
|
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch,
|
|
setTimer: ((handler: () => void) => {
|
|
pendingTimers.push(handler);
|
|
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>;
|
|
}) as typeof setTimeout,
|
|
clearTimer: ((timer) => {
|
|
clearedTimers.push(timer);
|
|
}) as typeof clearTimeout,
|
|
});
|
|
|
|
service.start();
|
|
assert.equal(sockets.length, 1);
|
|
sockets[0]!.emit('close');
|
|
assert.equal(pendingTimers.length, 1);
|
|
|
|
service.stop();
|
|
for (const reconnect of pendingTimers) reconnect();
|
|
|
|
assert.ok(clearedTimers.length >= 1);
|
|
assert.equal(sockets.length, 1);
|
|
assert.equal(fetchCalls.length, 0);
|
|
assert.equal(service.isConnected(), false);
|
|
});
|
|
|
|
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
|
|
const sockets: FakeWebSocket[] = [];
|
|
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
let shouldFailTimeline = false;
|
|
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local',
|
|
accessToken: 'token-4',
|
|
deviceId: 'device-4',
|
|
webSocketFactory: () => {
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async (input, init) => {
|
|
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
|
|
return new Response('boom', { status: 500 });
|
|
}
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
service.start();
|
|
sockets[0]!.emit('open');
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const expectedPayload = buildJellyfinTimelinePayload({
|
|
itemId: 'movie-2',
|
|
positionTicks: 123456,
|
|
isPaused: true,
|
|
volumeLevel: 33,
|
|
audioStreamIndex: 1,
|
|
subtitleStreamIndex: 2,
|
|
});
|
|
const expectedPostedPayload = 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[] = [];
|
|
const service = new JellyfinRemoteSessionService({
|
|
serverUrl: 'http://jellyfin.local',
|
|
accessToken: 'token-5',
|
|
deviceId: 'device-5',
|
|
webSocketFactory: () => {
|
|
const socket = new FakeWebSocket();
|
|
sockets.push(socket);
|
|
return socket as unknown as any;
|
|
},
|
|
fetchImpl: (async (input) => {
|
|
const url = String(input);
|
|
calls.push(url);
|
|
if (url.endsWith('/Sessions')) {
|
|
return new Response(JSON.stringify([{ DeviceId: 'device-5' }]), { status: 200 });
|
|
}
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
service.start();
|
|
sockets[0]!.emit('open');
|
|
const ok = await service.advertiseNow();
|
|
assert.equal(ok, true);
|
|
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
|
|
});
|