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.
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
/**
|
|
* Cover-art staging for the system Now Playing session.
|
|
*
|
|
* macOS shows the media session's albumart in the audio center (Control
|
|
* Center / lock screen). mpv reads artwork from `--cover-art-files` (loads
|
|
* the file as an albumart video track), so the podcast cover must exist on
|
|
* disk before (cover-art-files) or right after (video-add) playback starts.
|
|
*
|
|
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
|
|
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
|
|
* download happens ONCE per feed — subsequent plays (including the
|
|
* boot-restored episode) hit the disk cache and never wait on the network.
|
|
* The play path must never block on art: `cachedCoverPath` is the sync
|
|
* fast path; `fetchCoverArt` is awaited only by flows where latency does
|
|
* not matter (CLI play) or fired in the background with the result
|
|
* applied to a live mpv via `video-add`.
|
|
*
|
|
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
|
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
|
* which would silently drop every cover in shipped builds. curl is present
|
|
* on macOS and Linux. Bounded: a slow cover server must never stall audio.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
|
import { createHash } from "crypto";
|
|
import { join } from "path";
|
|
|
|
/** Resolved once per process; null when no home directory is detectable. */
|
|
let cacheDir: string | null | undefined;
|
|
|
|
function coversDir(): string | null {
|
|
if (cacheDir !== undefined) return cacheDir;
|
|
let dir: string | null = null;
|
|
try {
|
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
if (home) {
|
|
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
} catch {
|
|
dir = null;
|
|
}
|
|
cacheDir = dir;
|
|
return dir;
|
|
}
|
|
|
|
function cachePathFor(url: string): string | null {
|
|
const dir = coversDir();
|
|
if (!dir) return null;
|
|
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
|
|
}
|
|
|
|
/**
|
|
* Sync fast path: the cached cover file for `url`, or null when it has not
|
|
* been downloaded yet. This is what keeps cover art off the play() critical
|
|
* path — a cache hit costs one stat() and a miss simply plays without art
|
|
* (or applies it late via video-add).
|
|
*/
|
|
export function cachedCoverPath(url: string): string | null {
|
|
const path = cachePathFor(url);
|
|
if (!path) return null;
|
|
try {
|
|
return existsSync(path) && statSync(path).size > 0 ? path : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** In-flight downloads keyed by URL — a burst of plays of the same show
|
|
* shares one curl instead of racing ephemeral files. */
|
|
const inflight = new Map<string, Promise<string | null>>();
|
|
|
|
/**
|
|
* Fetch the cover for `url`, returns its cache path. Cache hits return
|
|
* immediately. Downloads are single-flight per URL and time-bounded (8s);
|
|
* failure resolves null and retries on the next call. The file is written
|
|
* to a temp name and renamed into place so a killed process can never
|
|
* poison the cache with a truncated file.
|
|
*/
|
|
export function fetchCoverArt(url: string): Promise<string | null> {
|
|
const cached = cachedCoverPath(url);
|
|
if (cached) return Promise.resolve(cached);
|
|
|
|
const dest = cachePathFor(url);
|
|
if (!dest) return Promise.resolve(null);
|
|
|
|
const pending = inflight.get(url);
|
|
if (pending) return pending;
|
|
|
|
const task = (async (): Promise<string | null> => {
|
|
const staging = `${dest}.${process.pid}.tmp`;
|
|
try {
|
|
const { promise, resolve } = Promise.withResolvers<string | null>();
|
|
const proc = Bun.spawn(
|
|
[
|
|
"curl",
|
|
"-sS",
|
|
"--fail",
|
|
"-m",
|
|
"8",
|
|
"--max-filesize",
|
|
"2097152",
|
|
"-o",
|
|
staging,
|
|
url,
|
|
],
|
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
|
);
|
|
proc.exited
|
|
.then((code) => {
|
|
if (code !== 0) return resolve(null);
|
|
try {
|
|
if (statSync(staging).size <= 0) return resolve(null);
|
|
renameSync(staging, dest);
|
|
resolve(dest);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
})
|
|
.catch(() => resolve(null));
|
|
setTimeout(() => resolve(null), 8000);
|
|
return await promise;
|
|
} finally {
|
|
inflight.delete(url);
|
|
// Best-effort staging cleanup (no-op after a successful rename).
|
|
try {
|
|
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
})();
|
|
|
|
inflight.set(url, task);
|
|
return task;
|
|
}
|
|
|
|
/** Fire-and-forget warm-up used by the boot/restore path. */
|
|
export function prefetchCoverArt(url: string): void {
|
|
fetchCoverArt(url).catch(() => {});
|
|
}
|