/** * 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): 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; } /** Create download store */ function createDownloadStore() { const [downloads, setDownloads] = createSignal< Map >(new Map()); const [queue, setQueue] = createSignal([]); const [activeCount, setActiveCount] = createSignal(0); /** Active AbortControllers keyed by episodeId */ const abortControllers = new Map(); (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> { 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(); 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 { 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, ): 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); } } /** Execute a single download */ async function executeDownload(item: QueueItem): Promise { 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, }); // Write the podcast cover beside the audio so mpv's // --cover-art-auto=exact picks it up for Now Playing art. const coverUrl = useFeedStore() .feeds() .find((f) => f.id === item.feedId)?.podcast.coverUrl; if (coverUrl && result.filePath) { const dot = result.filePath.lastIndexOf("."); if (dot > 0) { const coverPath = result.filePath.slice(0, dot) + ".jpg"; fetch(coverUrl) .then(async (r) => { if (!r.ok) return; await Bun.write( coverPath, new Uint8Array(await r.arrayBuffer()), ); }) .catch(() => {}); } } } else { updateDownload(item.episodeId, { status: DownloadStatus.FAILED, speed: 0, error: result.error ?? "Unknown error", }); } saveDownloads().catch(() => {}); // Process next items in queue processQueue(); } /** Get download status for an episode */ const getDownloadStatus = (episodeId: string): DownloadStatus => { return downloads().get(episodeId)?.status ?? DownloadStatus.NONE; }; /** Get download progress for an episode (0-100) */ const getDownloadProgress = (episodeId: string): number => { return downloads().get(episodeId)?.progress ?? 0; }; /** Get full download info for an episode */ const getDownload = (episodeId: string): DownloadedEpisode | undefined => { return downloads().get(episodeId); }; /** Get the local file path for a completed download */ 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; } /** Start downloading an episode */ 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, }); }; /** Cancel a download */ 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(() => {}); }; /** Remove a completed download (delete file and metadata) */ const removeDownload = async (episodeId: string): Promise => { 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 => { 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); } }; /** Get all downloads as an array */ 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; }); }; /** Get the current queue */ const getQueue = (): QueueItem[] => { return queue(); }; /** Get count of active downloads */ const getActiveCount = (): number => { return activeCount(); }; return { // Getters getDownloadStatus, getDownloadProgress, getDownload, getDownloadedFilePath, getAllDownloads, getUnsubscribedDownloads, getQueue, getActiveCount, // Actions startDownload, startUnsubscribedDownload, cancelDownload, removeDownload, removeDownloadsForFeed, }; } /** Singleton download store */ let downloadStoreInstance: ReturnType | null = null; export function useDownloadStore() { if (!downloadStoreInstance) { downloadStoreInstance = createDownloadStore(); } return downloadStoreInstance; }