Files
PodTui/src/stores/download.ts
Michael Freno d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
2026-08-12 21:02:19 -04:00

539 lines
15 KiB
TypeScript

/**
* Download store for PodTUI
*
* Manages per-episode download state with SolidJS signals, persists download
* metadata to downloads.json in XDG_CONFIG_HOME, and provides a sequential
* download queue (max 2 concurrent).
*/
import { createSignal } from "solid-js";
import { DownloadStatus } from "../types/episode";
import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
import type { Podcast } from "../types/podcast";
import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { useFeedStore } from "./feed";
const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2;
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
* downloads). The id doubles as the file subdirectory name, so it must be
* filesystem-safe. */
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
* its search downloads together (and names their file subdirectory) without
* colliding with real feed ids (UUIDs). */
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
const base = podcast.feedUrl || podcast.title;
const slug = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
}
/** Serializable download record for persistence */
interface DownloadRecord {
episodeId: string;
feedId: string;
status: DownloadStatus;
filePath: string | null;
downloadedAt: string | null;
fileSize: number;
error: string | null;
audioUrl: string;
episodeTitle: string;
/** ISO publication date, for unsubscribed-show downloads. */
pubDate?: string;
/** Show title, for downloads whose show isn't subscribed. */
podcastTitle?: string;
/** The show's RSS feed URL (re-classifies the download once subscribed). */
podcastFeedUrl?: string;
}
/** Queue item for pending downloads */
interface QueueItem {
episodeId: string;
feedId: string;
audioUrl: string;
episodeTitle: string;
}
// ── post-download decoration ─────────────────────────────────────────────────
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
* picks it up for Now Playing art when the local file plays (same basename,
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
* hangs in compiled binaries, so the shipped app never wrote this file. */
function writeCoverArt(filePath: string, coverUrl: string): void {
const dot = filePath.lastIndexOf(".");
if (dot <= 0) return;
const coverPath = filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
}
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
* metadata for local playback is title=episode, artist=podcast — the source
* streams carry no usable tags and macOS composes "title - artist" from
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
* into place. */
function tagLocalFile(
filePath: string,
episode: Episode,
podcastTitle: string,
): void {
const tmp = `${filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, filePath);
})
.catch(() => {});
}
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
>(new Map());
const [queue, setQueue] = createSignal<QueueItem[]>([]);
const [activeCount, setActiveCount] = createSignal(0);
/** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>();
(async () => {
const loaded = await loadDownloads();
if (loaded.size > 0) setDownloads(loaded);
// Resume any queued downloads from previous session
resumeIncomplete();
})();
/** Load downloads from JSON file */
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
try {
const filePath = getConfigFilePath(DOWNLOADS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return new Map();
const raw: DownloadRecord[] = await file.json();
if (!Array.isArray(raw)) return new Map();
const map = new Map<string, DownloadedEpisode>();
for (const rec of raw) {
map.set(rec.episodeId, {
episodeId: rec.episodeId,
feedId: rec.feedId,
status:
rec.status === DownloadStatus.DOWNLOADING
? DownloadStatus.QUEUED
: rec.status,
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
filePath: rec.filePath,
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
speed: 0,
fileSize: rec.fileSize,
error: rec.error,
episodeTitle: rec.episodeTitle || undefined,
audioUrl: rec.audioUrl || undefined,
pubDate: rec.pubDate || undefined,
podcastTitle: rec.podcastTitle || undefined,
podcastFeedUrl: rec.podcastFeedUrl || undefined,
});
}
return map;
} catch {
return new Map();
}
}
/** Persist downloads to JSON file */
async function saveDownloads(): Promise<void> {
try {
await ensureConfigDir();
const map = downloads();
const records: DownloadRecord[] = [];
for (const [, dl] of map) {
// Find the audioUrl from queue or use empty string
const qItem = queue().find((q) => q.episodeId === dl.episodeId);
records.push({
episodeId: dl.episodeId,
feedId: dl.feedId,
status: dl.status,
filePath: dl.filePath,
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
fileSize: dl.fileSize,
error: dl.error,
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
pubDate: dl.pubDate,
podcastTitle: dl.podcastTitle,
podcastFeedUrl: dl.podcastFeedUrl,
});
}
const filePath = getConfigFilePath(DOWNLOADS_FILE);
await Bun.write(filePath, JSON.stringify(records, null, 2));
} catch {
// Silently ignore write errors
}
}
/** Resume incomplete downloads from a previous session */
function resumeIncomplete(): void {
const map = downloads();
for (const [, dl] of map) {
if (dl.status === DownloadStatus.QUEUED) {
// Re-queue — but we lack audioUrl from persistence alone.
// These will sit as QUEUED until the user re-triggers them.
}
}
}
/** Update a single download entry and trigger reactivity */
function updateDownload(
episodeId: string,
updates: Partial<DownloadedEpisode>,
): void {
setDownloads((prev) => {
const next = new Map(prev);
const existing = next.get(episodeId);
if (existing) {
next.set(episodeId, { ...existing, ...updates });
}
return next;
});
}
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
function processQueue(): void {
const current = activeCount();
const q = queue();
if (current >= MAX_CONCURRENT || q.length === 0) return;
const slotsAvailable = MAX_CONCURRENT - current;
const toStart = q.slice(0, slotsAvailable);
if (toStart.length > 0) {
setQueue((prev) => prev.slice(toStart.length));
}
for (const item of toStart) {
executeDownload(item);
}
}
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
setActiveCount((c) => c + 1);
updateDownload(item.episodeId, {
status: DownloadStatus.DOWNLOADING,
progress: 0,
speed: 0,
error: null,
});
const result = await downloadEpisode(
item.audioUrl,
item.episodeTitle,
item.feedId,
(progress) => {
updateDownload(item.episodeId, {
progress: progress.percent >= 0 ? progress.percent : 0,
speed: progress.speed,
fileSize: progress.totalBytes,
});
},
controller.signal,
);
abortControllers.delete(item.episodeId);
setActiveCount((c) => Math.max(0, c - 1));
if (result.success) {
updateDownload(item.episodeId, {
status: DownloadStatus.COMPLETED,
progress: 100,
filePath: result.filePath,
fileSize: result.fileSize,
downloadedAt: new Date(),
speed: 0,
error: null,
});
// Decorate the local file: cover art + ID3 tags (see the
// module-level helpers above) — the source streams carry neither.
// Cover falls back to the episode's own image when the feed has
// no channel cover (URL-added feeds).
const feedStore = useFeedStore();
const episode = feedStore.findEpisode(item.episodeId);
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
if (result.filePath && coverUrl) {
writeCoverArt(result.filePath, coverUrl);
}
if (result.filePath && episode) {
const podcastTitle =
feed?.podcast.title ??
downloads().get(item.episodeId)?.podcastTitle;
if (podcastTitle) {
tagLocalFile(result.filePath, episode, podcastTitle);
}
}
} else {
updateDownload(item.episodeId, {
status: DownloadStatus.FAILED,
speed: 0,
error: result.error ?? "Unknown error",
});
}
saveDownloads().catch(() => {});
// Process next items in queue
processQueue();
}
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
return dl.filePath;
}
return null;
};
/** Optional metadata for a download whose show isn't subscribed (search
* downloads) — without it the record cannot render a title or be
* re-classified once the show is subscribed. */
interface UnsubscribedMeta {
podcastTitle: string;
podcastFeedUrl?: string;
}
const startDownload = (
episode: Episode,
feedId: string,
meta?: UnsubscribedMeta,
): void => {
const existing = downloads().get(episode.id);
if (
existing?.status === DownloadStatus.DOWNLOADING ||
existing?.status === DownloadStatus.QUEUED
) {
return; // Already downloading or queued
}
const entry: DownloadedEpisode = {
episodeId: episode.id,
feedId,
status: DownloadStatus.QUEUED,
progress: 0,
filePath: null,
downloadedAt: null,
speed: 0,
fileSize: episode.fileSize ?? 0,
error: null,
episodeTitle: episode.title,
audioUrl: episode.audioUrl,
pubDate: episode.pubDate.toISOString(),
podcastTitle: meta?.podcastTitle,
podcastFeedUrl: meta?.podcastFeedUrl,
};
setDownloads((prev) => {
const next = new Map(prev);
next.set(episode.id, entry);
return next;
});
const queueItem: QueueItem = {
episodeId: episode.id,
feedId,
audioUrl: episode.audioUrl,
episodeTitle: episode.title,
};
setQueue((prev) => [...prev, queueItem]);
saveDownloads().catch(() => {});
processQueue();
};
/** Start downloading an episode of a show that is NOT subscribed. The
* download gets a deterministic synthetic feed id (also its file
* subdirectory) plus the show's metadata so it can render under
* "Unsubscribed Show Downloads" and re-classify if the user later
* subscribes to the show. */
const startUnsubscribedDownload = (
episode: Episode,
podcast: Podcast,
): void => {
startDownload(episode, unsubscribedFeedId(podcast), {
podcastTitle: podcast.title,
podcastFeedUrl: podcast.feedUrl || undefined,
});
};
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
if (controller) {
controller.abort();
abortControllers.delete(episodeId);
}
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
updateDownload(episodeId, {
status: DownloadStatus.NONE,
progress: 0,
speed: 0,
error: null,
});
saveDownloads().catch(() => {});
};
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
try {
const { unlink } = await import("fs/promises");
await unlink(dl.filePath);
const dot = dl.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
await unlink(coverPath);
}
} catch {
// File may already be gone
}
}
setDownloads((prev) => {
const next = new Map(prev);
next.delete(episodeId);
return next;
});
saveDownloads().catch(() => {});
};
/** Remove every download (active/queued/completed) belonging to a feed —
* abort in-flight transfers, drop queued items, delete files + metadata.
* Also removes downloads of the same show made while it was unsubscribed
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
* of that show too. */
const removeDownloadsForFeed = async (
feedId: string,
podcastFeedUrl?: string,
): Promise<void> => {
const eps = Array.from(downloads().values()).filter(
(d) =>
d.feedId === feedId ||
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
);
for (const d of eps) {
cancelDownload(d.episodeId);
await removeDownload(d.episodeId);
}
};
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
/** Downloads whose show is not subscribed — the "Unsubscribed Show
* Downloads" list shown in My Shows and the settings download manager.
* Reads feeds() so the list re-classifies (drops out) the moment the
* user subscribes to the show. Matched by feed id, or by the show's
* feed URL (covers downloads made before the show was subscribed). */
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
const feeds = useFeedStore().feeds();
return Array.from(downloads().values()).filter((d) => {
if (feeds.some((f) => f.id === d.feedId)) return false;
if (d.podcastFeedUrl) {
return !feeds.some(
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
);
}
return true;
});
};
const getQueue = (): QueueItem[] => {
return queue();
};
const getActiveCount = (): number => {
return activeCount();
};
return {
// Getters
getDownloadStatus,
getDownloadProgress,
getDownload,
getDownloadedFilePath,
getAllDownloads,
getUnsubscribedDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
startUnsubscribedDownload,
cancelDownload,
removeDownload,
removeDownloadsForFeed,
};
}
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {
if (!downloadStoreInstance) {
downloadStoreInstance = createDownloadStore();
}
return downloadStoreInstance;
}