feat: expand subtitle and media tracking workflows

- Add reference-guided subtitle timing, frame picking, live-action Jimaku search, and YouTube library kinds
- Harden Jellyfin media identity handling and live settings feedback
- Refresh user-facing documentation and changelog fragments
This commit is contained in:
2026-09-20 20:10:03 -07:00
139 changed files with 4511 additions and 389 deletions
+75 -1
View File
@@ -20,6 +20,8 @@ function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
onWait?: (elapsedMs: number, harness: Harness) => void;
isCurrent?: () => boolean;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
@@ -39,10 +41,12 @@ function createHarness(overrides?: {
},
wait: async (ms) => {
clock += ms;
overrides?.onWait?.(clock, harness);
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
isCurrent: overrides?.isCurrent,
});
const harness: Harness = {
@@ -94,6 +98,76 @@ test('times out with a failure when nothing ever starts', async () => {
watch.dispose();
});
test('waits for a slow stream that is still loading after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('vo-configured', true);
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), { ok: true });
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('reports a real stream error after the confirmation deadline', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.emitEndFile({ reason: 'error', fileError: 'HTTP 503' });
},
});
harness.setProperty('idle-active', false);
assert.deepEqual(await watch.wait(), {
ok: false,
error: 'mpv could not play this stream: HTTP 503',
});
watch.dispose();
});
test('stops waiting if a slow stream leaves mpv idle', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs, state) => {
if (elapsedMs >= 600) state.setProperty('idle-active', true);
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
});
test('stops watching a slow stream when the request is superseded', async () => {
let current = true;
const { watch, harness } = createHarness({
timeoutMs: 300,
isCurrent: () => current,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) current = false;
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});
test('disposing the watcher stops polling a slow stream', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
onWait: (elapsedMs) => {
if (elapsedMs >= 600) watch.dispose();
},
});
harness.setProperty('idle-active', false);
assert.equal((await watch.wait()).ok, false);
assert.equal(harness.elapsed(), 600);
assert.equal(harness.listenerCount(), 0);
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
@@ -102,7 +176,7 @@ test('slow property reads eat the budget instead of extending it', async () => {
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
// Property reads and the final idle check all count toward elapsed time.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
+24 -8
View File
@@ -26,10 +26,12 @@ export interface WatchPlaybackOutcomeDeps {
/** One-shot mpv property read; may reject while the file is still loading. */
readProperty: (name: string) => Promise<unknown>;
wait: (ms: number) => Promise<void>;
/** Injectable clock; the timeout is wall-clock, not a probe count. */
/** Injectable clock for the initial confirmation deadline. */
now?: () => number;
timeoutMs?: number;
probeIntervalMs?: number;
/** Stop watching when a newer episode replaces this request or the app closes. */
isCurrent?: () => boolean;
}
export interface PlaybackOutcomeWatch {
@@ -49,6 +51,7 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
let failure: PlaybackOutcome | null = null;
let disposed = false;
const unsubscribe = deps.onEndFile((event) => {
if (event.reason !== 'error') return;
failure = {
@@ -60,11 +63,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
});
async function wait(): Promise<PlaybackOutcome> {
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
// budget rather than stretch it, and a zero probe interval must still end.
// Use elapsed time rather than probe count before checking whether mpv is
// still active. Slow property reads count toward this initial deadline.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
while (!disposed && (deps.isCurrent?.() ?? true)) {
if (failure) return failure;
try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
@@ -72,10 +75,17 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
// The property is unreadable while mpv is between files; keep polling.
}
if (failure) return failure;
// Sleeping past the deadline would only delay the timeout report.
// A deadline without video is not a failure while mpv is still opening
// the stream. Keep waiting for video or a real end-file error in that case.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
if (remaining <= 0) {
try {
if ((await deps.readProperty('idle-active')) !== false) break;
} catch {
break;
}
}
await deps.wait(remaining > 0 ? Math.min(probeIntervalMs, remaining) : probeIntervalMs);
}
return (
failure ?? {
@@ -85,5 +95,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
);
}
return { wait, dispose: unsubscribe };
return {
wait,
dispose: () => {
disposed = true;
unsubscribe();
},
};
}