mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user