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.
78 lines
2.3 KiB
TypeScript
78 lines
2.3 KiB
TypeScript
/**
|
|
* Cover-art disk-cache contract tests.
|
|
*
|
|
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
|
|
* playback never waits on the network for art it has already fetched. Pins:
|
|
*
|
|
* 1. A fetch stores the bytes on disk and returns the cache path.
|
|
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
|
|
* the server again (request count stays 1).
|
|
* 3. Concurrent fetches of the same URL share one download (single-flight).
|
|
* 4. A failed fetch (404) resolves null instead of throwing.
|
|
*
|
|
* Served from a local Bun server — no external network dependence. Cache
|
|
* entries created here are removed afterwards.
|
|
*/
|
|
import { test, expect } from "bun:test";
|
|
import { unlinkSync } from "fs";
|
|
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
|
|
|
|
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
|
|
|
|
test("cover art is fetched once, cached on disk, and shared", async () => {
|
|
let requests = 0;
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
requests++;
|
|
if (new URL(req.url).pathname === "/missing.jpg") {
|
|
return new Response("nope", { status: 404 });
|
|
}
|
|
return new Response(FAKE_JPEG, {
|
|
headers: { "content-type": "image/jpeg" },
|
|
});
|
|
},
|
|
});
|
|
|
|
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
|
|
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
|
|
let cachedPath: string | null = null;
|
|
try {
|
|
expect(cachedCoverPath(url)).toBeNull();
|
|
|
|
// First fetch: downloads and caches.
|
|
cachedPath = await fetchCoverArt(url);
|
|
expect(cachedPath).not.toBeNull();
|
|
expect(requests).toBe(1);
|
|
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
|
|
|
|
// Second fetch: disk hit, server untouched.
|
|
expect(await fetchCoverArt(url)).toBe(cachedPath);
|
|
expect(requests).toBe(1);
|
|
|
|
// Single-flight: parallel misses of a fresh URL make ONE request.
|
|
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
|
|
const [a, b, c] = await Promise.all([
|
|
fetchCoverArt(shared),
|
|
fetchCoverArt(shared),
|
|
fetchCoverArt(shared),
|
|
]);
|
|
expect(a).not.toBeNull();
|
|
expect(a).toBe(b);
|
|
expect(b).toBe(c);
|
|
if (a) unlinkSync(a);
|
|
|
|
// 404 resolves null, never throws.
|
|
expect(await fetchCoverArt(missing)).toBeNull();
|
|
} finally {
|
|
server.stop(true);
|
|
if (cachedPath) {
|
|
try {
|
|
unlinkSync(cachedPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
});
|