Two fragility points, rebuilt at the root: Playback: one resident mpv daemon (--idle --keep-open) with a persistent IPC connection and observe_property state instead of spawn-per-episode and connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at ~20Hz; external pauses arrive as events. Boot session restore preloads the episode paused (loadfile + paused time-pos seek, since mpv defers --start stream work until playback) so first Play is a ~400ms unpause instead of a cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload cannot clobber an in-flight play. Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB) so a paused preload no longer races to its 150MiB default (measured 45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start isn't starved by the visualizer ripping the whole episode. Visualization: replaced the paced-ring reader (AudioStreamReader) with a position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed by absolute playback time; reads at the player position are always exact. Pause freezes the render loop, resume re-arms it — no coverage guessing, no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks and speed changes need no pipeline restarts; uncovered reads return empty and the last frame holds. Cover art: persistent per-URL disk cache under XDG cache dir; play() no longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses apply late via mpv video-add. Test suite: 161 pass. New tests pin the position-index contract (sample- exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments), the daemon contract (play/pause/resume/seek/stop, preload fast path, EOF->replay), and cover cache/single-flight/404.
265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
/**
|
|
* external-pause-reconcile.test.ts — "audio paused outside PodTUI must not
|
|
* freeze the player tab" regression test.
|
|
*
|
|
* The OS can pause the player without PodTUI knowing: system sleep/lock,
|
|
* AirPod removal / device swap, OS media keys, the Now Playing center. mpv
|
|
* flips its own `pause` property and keeps it there. Before the fix,
|
|
* useAudio's signals stayed on "playing" — [Pause] button shown while
|
|
* silent, a poll that only re-read the same frozen `time-pos` (stuck
|
|
* waveform), and no way to catch an external RESUME either (the poll was
|
|
* stopped whenever the UI thought it was paused).
|
|
*
|
|
* Integration style (like restore-session.test.ts): real stores and real
|
|
* persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv
|
|
* backend driven over its actual IPC socket. The test flips mpv's pause
|
|
* property the same way the OS does and asserts useAudio reconciles in
|
|
* both directions. Skipped when mpv isn't installed.
|
|
*
|
|
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
|
* interval against a real mpv process, with no injectable clock — the
|
|
* deliberate-exception case from the no-real-timers rule (same as
|
|
* visualizer-store.test.ts). `waitFor` polls with Bun.sleep.
|
|
*
|
|
* Shared-worker note (same as restore-session.test.ts): the suite reuses
|
|
* bun test workers, so other files' `mock.module("../src/hooks/useAudio")`
|
|
* leaks into this file's module registry. The REAL useAudio is therefore
|
|
* imported via a `?external-pause-test` query suffix — a distinct module
|
|
* identity bun loads from disk, bypassing the leaked mock.
|
|
*/
|
|
import { test, expect, afterAll } from "bun:test";
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
writeFileSync,
|
|
rmSync,
|
|
readdirSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const hasMpv = !!Bun.which("mpv");
|
|
|
|
// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ────
|
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-"));
|
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
|
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-"));
|
|
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC
|
|
const APP_CONFIG = join(CONFIG, "podtui");
|
|
mkdirSync(APP_CONFIG, { recursive: true });
|
|
|
|
// Seed one feed so the app store boots cleanly. No coverUrl — the play()
|
|
// path skips cover-art fetching. The RSS URL is unreachable so the
|
|
// background refresh fails fast and leaves the seeded data untouched.
|
|
const ISO = "2026-08-10T00:00:00.000Z";
|
|
const feed = {
|
|
id: "feed1",
|
|
podcast: {
|
|
id: "pod1",
|
|
title: "Pod One",
|
|
description: "",
|
|
feedUrl: "http://127.0.0.1:1/show.xml",
|
|
lastUpdated: ISO,
|
|
isSubscribed: true,
|
|
},
|
|
episodes: [],
|
|
visibility: "public",
|
|
sourceId: "test",
|
|
lastUpdated: ISO,
|
|
isPinned: false,
|
|
};
|
|
|
|
await Bun.write(
|
|
join(APP_CONFIG, "config.json"),
|
|
JSON.stringify({ feeds: [feed] }, null, 2),
|
|
);
|
|
|
|
// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ─────
|
|
const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
|
|
{
|
|
const SAMPLE_RATE = 44100;
|
|
const DURATION = 60;
|
|
const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit
|
|
const buf = Buffer.alloc(44 + dataLen * 2);
|
|
buf.write("RIFF", 0);
|
|
buf.writeUInt32LE(36 + dataLen * 2, 4);
|
|
buf.write("WAVE", 8);
|
|
buf.write("fmt ", 12);
|
|
buf.writeUInt32LE(16, 16); // fmt chunk size
|
|
buf.writeUInt16LE(1, 20); // PCM
|
|
buf.writeUInt16LE(1, 22); // mono
|
|
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
|
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
|
|
buf.writeUInt16LE(2, 32); // block align
|
|
buf.writeUInt16LE(16, 34); // bits per sample
|
|
buf.write("data", 36);
|
|
buf.writeUInt32LE(dataLen * 2, 40);
|
|
for (let i = 0; i < dataLen; i++) {
|
|
const sample = Math.round(
|
|
Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000,
|
|
);
|
|
buf.writeInt16LE(sample, 44 + i * 2);
|
|
}
|
|
writeFileSync(wavPath, buf);
|
|
}
|
|
|
|
// ── Real modules (loaded after env + sandbox are set up) ──────────────────
|
|
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
|
// loads the real file instead of a leaked mock.module from another test file.
|
|
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
|
|
|
|
/**
|
|
* The socket path of the LIVE backend daemon in this process. The backend
|
|
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
|
|
* tmpdir for this pid's sockets and take the newest (the one mpv actually
|
|
* bound — earlier instances may have been orphaned by a re-spawn).
|
|
*/
|
|
function mpvSocket(): string | null {
|
|
let newest: string | null = null;
|
|
let newestMtime = 0;
|
|
for (const name of readdirSync(tmpdir())) {
|
|
if (
|
|
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
|
|
!name.endsWith(".sock")
|
|
) {
|
|
continue;
|
|
}
|
|
const candidate = join(tmpdir(), name);
|
|
const mtime = statSync(candidate).mtimeMs;
|
|
if (mtime > newestMtime) {
|
|
newest = candidate;
|
|
newestMtime = mtime;
|
|
}
|
|
}
|
|
return newest;
|
|
}
|
|
|
|
/**
|
|
* Send a raw mpv IPC command over the unix socket — exactly how the OS
|
|
* media session pauses/resumes mpv without PodTUI's involvement.
|
|
*/
|
|
async function mpvCommand(command: unknown[]): Promise<void> {
|
|
const socket = mpvSocket();
|
|
if (!socket) throw new Error("backend mpv socket not found");
|
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
let settled = false;
|
|
const settle = (err: Error | null): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (err) reject(err);
|
|
else resolve();
|
|
};
|
|
Bun.connect({
|
|
unix: socket,
|
|
socket: {
|
|
open(s) {
|
|
s.write(JSON.stringify({ command }) + "\n");
|
|
},
|
|
data() {},
|
|
error() {
|
|
settle(new Error("mpv IPC connect failed"));
|
|
},
|
|
close() {
|
|
settle(null);
|
|
},
|
|
},
|
|
}).then((s) =>
|
|
setTimeout(() => {
|
|
try {
|
|
s.end();
|
|
} catch {}
|
|
}, 150),
|
|
);
|
|
// Never hang the test on a vanished socket.
|
|
setTimeout(() => settle(null), 1000);
|
|
await promise;
|
|
}
|
|
|
|
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
|
|
async function waitFor(
|
|
check: () => boolean,
|
|
timeoutMs = 10000,
|
|
): Promise<void> {
|
|
const start = Date.now();
|
|
while (!check()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error("condition not met in time");
|
|
}
|
|
await Bun.sleep(25);
|
|
}
|
|
}
|
|
|
|
const episode = {
|
|
id: "ep1",
|
|
podcastId: "pod1",
|
|
title: "Episode One",
|
|
description: "desc",
|
|
audioUrl: wavPath,
|
|
duration: 60,
|
|
pubDate: new Date(),
|
|
};
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────
|
|
|
|
test.skipIf(!hasMpv)(
|
|
"external pause flips the UI to paused; external resume recovers",
|
|
async () => {
|
|
const audio = useAudio();
|
|
await audio.play(episode);
|
|
expect(audio.isPlaying()).toBe(true);
|
|
|
|
// Simulate the OS pausing the session (lock/sleep, AirPod removal,
|
|
// device swap, media-center pause): flip mpv's own pause property
|
|
// over IPC. PodTUI is never told.
|
|
await mpvCommand(["set_property", "pause", true]);
|
|
await waitFor(() => !audio.isPlaying());
|
|
expect(audio.isPlaying()).toBe(false);
|
|
// The episode stays loaded — nothing was torn down.
|
|
expect(audio.currentEpisode()?.id).toBe("ep1");
|
|
|
|
// Simulate an external resume (AirPod play tap, media-center play).
|
|
await mpvCommand(["set_property", "pause", false]);
|
|
await waitFor(() => audio.isPlaying());
|
|
expect(audio.isPlaying()).toBe(true);
|
|
|
|
// The TUI transport still works from the reconciled state.
|
|
await audio.togglePlayback();
|
|
expect(audio.isPlaying()).toBe(false);
|
|
await audio.togglePlayback();
|
|
expect(audio.isPlaying()).toBe(true);
|
|
|
|
await audio.stop();
|
|
expect(audio.isPlaying()).toBe(false);
|
|
},
|
|
{ timeout: 30000 },
|
|
);
|
|
|
|
// ── Teardown ──────────────────────────────────────────────────────────────
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
useAudio().stop();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
// The resident daemon survives stop() by design — quit it so test
|
|
// workers don't leak idle mpv processes.
|
|
try {
|
|
await mpvCommand(["quit"]);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
try {
|
|
const socket = mpvSocket();
|
|
if (socket) rmSync(socket, { force: true });
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
try {
|
|
rmSync(wavPath, { force: true });
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
});
|