feat(audio): podcast cover art in system Now Playing, CLI --play included

The cover was previously only wired through the UI hook; --play skipped it.
Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover
download silently failed in shipped builds, timing out against any host.
Cover staging now shells out to curl (present on macOS/Linux, 8s bound),
shared via src/utils/cover-art.ts so the UI and CLI paths stage the same
temp file and pass it to mpv as --cover-art-files (albumart track).
This commit is contained in:
2026-08-10 18:18:58 -04:00
parent cda29bcb95
commit de6d0ccbf6
3 changed files with 87 additions and 1 deletions

57
src/utils/cover-art.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 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 it from `--cover-art-files` (loads the
* file as an albumart video track), so the podcast cover is staged to a temp
* file BEFORE playback starts and passed to mpv.
*
* 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,
* so an 8s cap drops the art.
*/
import { tmpdir } from "os";
import { join } from "path";
import { unlinkSync, statSync } from "fs";
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
export async function fetchCoverArt(url: string): Promise<string | null> {
const path = coverTempPath();
try {
unlinkSync(path);
} catch {
/* no stale cover */
}
try {
return await Promise.race([
(async () => {
const proc = Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
path,
url,
]);
const code = await proc.exited;
if (code !== 0) return null;
try {
return statSync(path).size > 0 ? path : null;
} catch {
return null;
}
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
]);
} catch {
return null;
}
}