diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 8901c76..b6f05fd 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -33,7 +33,7 @@ import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; -import type { Episode } from "@/types/episode"; +import type { Episode, DownloadedEpisode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { PaneRow } from "@/components/PaneRow"; @@ -62,9 +62,28 @@ export function MyShowsPage() { const shows = () => feedStore.getFilteredFeeds(); + // Downloads of shows that are NOT subscribed (made from episode search) — + // listed as their own section under the shows list. Reads feeds() so an + // entry drops out the moment the user subscribes to its show. + const unsubs = () => downloadStore.getUnsubscribedDownloads(); + + // Total depth-0 rows: subscribed shows + unsubscribed-show downloads. + const depth0Count = () => shows().length + unsubs().length; + const focusedShowIdx = () => shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); - const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()]; + /** True when the depth-0 cursor sits on an unsubscribed-show download + * row (past the shows list). */ + const focusedOnUnsub = () => + depth() === 0 && focus(0) >= shows().length && unsubs().length > 0; + const focusedUnsub = (): DownloadedEpisode | undefined => { + if (!focusedOnUnsub()) return undefined; + return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)]; + }; + const selectedShow = (): Feed | undefined => { + if (focusedOnUnsub()) return undefined; + return shows()[focusedShowIdx()]; + }; // depth-1 frame ctx = the drilled feed id const drilledShowId = (): string => stack()[1]?.ctx ?? ""; @@ -104,11 +123,11 @@ export function MyShowsPage() { focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; const moreRef = useScrollIntoView(() => focusedOnMore()); - const curLen = () => (depth() === 0 ? shows().length : rowCount()); + const curLen = () => (depth() === 0 ? depth0Count() : rowCount()); const ensureFocus = () => { - if (shows().length > 0 && focus(0) >= shows().length) - nav.setDepthFocus(shows().length - 1, 0); + if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count()) + nav.setDepthFocus(depth0Count() - 1, 0); if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount()) nav.setDepthFocus(rowCount() - 1, 1); }; @@ -116,7 +135,10 @@ export function MyShowsPage() { onMount(() => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { - if (depth() === 0) return shows()[i]?.id; + if (depth() === 0) { + if (i < shows().length) return shows()[i]?.id; + return unsubs()[i - shows().length]?.episodeId; + } return episodes()[i]?.id; }); }); @@ -172,9 +194,31 @@ export function MyShowsPage() { audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); }; + /** Stream an unsubscribed-show download. The record carries only what was + * persisted at download time, so a minimal Episode is reconstructed. */ + const playUnsubscribedDownload = (d: DownloadedEpisode) => { + audio + .play({ + id: d.episodeId, + podcastId: d.feedId, + title: d.episodeTitle ?? d.episodeId, + description: "", + audioUrl: d.audioUrl ?? "", + duration: 0, + pubDate: d.pubDate ? new Date(d.pubDate) : new Date(), + }) + .catch(() => {}); + audioNav.setSource(AudioSource.SEARCH, d.feedId); + }; + // ── drill / open ─────────────────────────────────────────────────────────── function open() { if (depth() === 0) { + const d = focusedUnsub(); + if (d) { + playUnsubscribedDownload(d); + return; + } const show = selectedShow(); if (!show) return; nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame); @@ -215,6 +259,14 @@ export function MyShowsPage() { if (ep) downloadStore.startDownload(ep, drilledShowId()); }, "delete-download": () => { + if (depth() === 0) { + const d = focusedUnsub(); + if (d) { + downloadStore.cancelDownload(d.episodeId); + downloadStore.removeDownload(d.episodeId).catch(() => {}); + } + return; + } if (depth() < 1) return; const ep = focusedEpisode(); if (!ep) return; @@ -283,7 +335,9 @@ export function MyShowsPage() { const currentLabel = () => depth() === 0 - ? `Shows (${shows().length})` + ? `Shows (${shows().length})${ + unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : "" + }` : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── @@ -321,7 +375,7 @@ export function MyShowsPage() { {/* depth 0: shows — stable sibling so the swap disposes cleanly */} 0} + when={depth0Count() > 0} fallback={ @@ -377,6 +431,71 @@ export function MyShowsPage() { ); }} + 0}> + + + Unsubscribed Show Downloads + + + + {(d, index) => { + // Rows continue after the shows list. + const rowIdx = () => shows().length + index(); + const lf = () => nav.depthFocus(0); + const ref = useScrollIntoView(() => rowIdx() === lf()); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(rowIdx(), 0); + }} + > + + + {rowIdx() === lf() ? marker() : " "} + + + {d.episodeTitle ?? d.episodeId} + + + + {downloadLabel(d.episodeId)} + + + + + + {d.podcastTitle ?? d.feedId} + + + + ); + }} + + {/* depth ≥1: episodes */} @@ -491,39 +610,78 @@ export function MyShowsPage() { // ── preview pane ─────────────────────────────────────────────────────────── const previewContent = () => depth() === 0 ? ( - // depth 0 preview: hovered show + // depth 0 preview: hovered unsubscribed-show download, else the + // hovered show. - No show focused - + + No show focused + + } + > + {(show) => ( + + + {showTitle(show())} + + + by {show().podcast.author} + + + {show().episodes.length} episodes + + + {show().podcast.description?.slice(0, 400) ?? + "No description."} + + + + enter/l: open · h: back · x: unsubscribe + {app.state().preferences.autoDownloadScope === + "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? + [] + ).includes(show().id) + ? " · w: un-whitelist" + : " · w: whitelist" + : ""} + + + )} + } > - {(show) => ( + {(d) => ( - {showTitle(show())} + {d().episodeTitle ?? d().episodeId} - - by {show().podcast.author} - - {show().episodes.length} episodes + {d().podcastTitle ?? d().feedId} + + + + {formatDate(new Date(d().pubDate!))} + + + + + {downloadLabel(d().episodeId)} + + + - {show().podcast.description?.slice(0, 400) ?? "No description."} + Downloaded from episode search — the show is not + subscribed. - enter/l: open · h: back · x: unsubscribe - {app.state().preferences.autoDownloadScope === "whitelist" - ? (app.state().preferences.autoDownloadWhitelist ?? - [] - ).includes(show().id) - ? " · w: un-whitelist" - : " · w: whitelist" - : ""} + enter: play · D: delete download · h: back )} diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 31d5da6..7c9b57b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -30,6 +30,10 @@ import { } from "solid-js"; import { useSearchStore } from "@/stores/search"; import { useFeedStore } from "@/stores/feed"; +import { useDownloadStore } from "@/stores/download"; +import { useAudio } from "@/hooks/useAudio"; +import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; +import { DownloadStatus } from "@/types/episode"; import { useToast } from "@/ui/toast"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; @@ -55,6 +59,9 @@ export const SearchPaneCount = 1; function SearchPage() { const searchStore = useSearchStore(); const feedStore = useFeedStore(); + const downloadStore = useDownloadStore(); + const audio = useAudio(); + const audioNav = useAudioNavStore(); const toast = useToast(); const [inputValue, setInputValue] = createSignal(""); const { theme } = useTheme(); @@ -134,6 +141,35 @@ function SearchPage() { // ── helpers ───────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + const downloadLabel = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return "[Q]"; + case DownloadStatus.DOWNLOADING: + return `[${downloadStore.getDownloadProgress(id)}%]`; + case DownloadStatus.COMPLETED: + return "[DL]"; + case DownloadStatus.FAILED: + return "[ERR]"; + default: + return ""; + } + }; + const downloadColor = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return theme.warning; + case DownloadStatus.DOWNLOADING: + return theme.primary; + case DownloadStatus.COMPLETED: + return theme.success; + case DownloadStatus.FAILED: + return theme.error; + default: + return muted(); + } + }; + const runSearch = (query: string) => { const q = query.trim(); if (!q) return; @@ -185,6 +221,49 @@ function SearchPage() { if (feed) searchStore.markSubscribed(result.podcast.id); }; + /** The subscribed feed backing a search result, if any (matched by + * directory id or feed URL). */ + const feedForResult = (r: SearchResult) => + feedStore.feeds().find( + (f) => + f.podcast.id === r.podcast.id || + (!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl), + ); + + /** Download the focused episode: under its subscribed feed when the show + * is subscribed, otherwise as an "unsubscribed show" download (listed + * under Unsubscribed Show Downloads in My Shows / the download manager). */ + const downloadFocusedEpisode = () => { + if (depth() !== 1) return; + const r = focusedResult(); + if (!r || r.kind !== "episode") return; + const feed = feedForResult(r); + if (feed) downloadStore.startDownload(r.episode, feed.id); + else downloadStore.startUnsubscribedDownload(r.episode, r.podcast); + }; + + const playFocusedEpisode = () => { + if (depth() !== 1) return; + const r = focusedResult(); + if (!r || r.kind !== "episode") return; + audio.play(r.episode).catch(() => {}); + audioNav.setSource(AudioSource.SEARCH, r.podcast.id); + }; + + const unsubscribeFocused = () => { + if (depth() !== 1) return; + const r = focusedResult(); + if (!r || !r.podcast.isSubscribed) return; + const feed = feedForResult(r); + if (feed) { + feedStore.removeFeed(feed.id); + downloadStore + .removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined) + .catch(() => {}); + searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl); + } + }; + // ── nav.action handler ────────────────────────────────────────────────────── const PAGE_ACTIONS: Partial void>> = { "move-down": () => step(1), @@ -205,6 +284,17 @@ function SearchPage() { ); } }, + download: () => downloadFocusedEpisode(), + "delete-download": () => { + if (depth() !== 1) return; + const r = focusedResult(); + if (!r || r.kind !== "episode") return; + const id = r.episode.id; + if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; + downloadStore.cancelDownload(id); + downloadStore.removeDownload(id).catch(() => {}); + }, + unsubscribe: () => unsubscribeFocused(), search: () => { // `s` refocuses the query input (typing mode) when on the query depth. if (depth() === 0) nav.setInputFocused(true); @@ -230,7 +320,13 @@ function SearchPage() { } if (depth() === 1) { const r = focusedResult(); - if (r) handleSubscribe(r); + if (!r) return; + if (r.kind === "episode" && r.podcast.isSubscribed) { + // Subscribed show's episode → stream it (matches Feed/My Shows). + playFocusedEpisode(); + return; + } + handleSubscribe(r); } } @@ -474,6 +570,13 @@ function SearchPage() { {(result, index) => { const fi = () => focusedResultIdx(); const ref = useScrollIntoView(() => index() === fi()); + // Episode download status badge ("" when absent). + const dlLabel = () => + result.kind === "episode" + ? downloadLabel(result.episode.id) + : ""; + const dlEpId = () => + result.kind === "episode" ? result.episode.id : ""; return ( + + + {dlLabel()} + + 400 ? "…" : ""} - - Published: {formatDate(r.episode.pubDate)} - + + + Published: {formatDate(r.episode.pubDate)} + + + + {downloadLabel(r.episode.id)} + + + 0}> @@ -594,12 +709,28 @@ function SearchPage() { [+] Subscribe (enter) - Already subscribed + + Subscribed · x: unsubscribe + - - enter: subscribe to show · h: back to query - + + enter: subscribe · d: download · h: back to query + + } + > + + enter: play · d: download · x: unsubscribe + {downloadStore.getDownloadStatus(r.episode.id) !== + DownloadStatus.NONE + ? " · D: delete" + : ""}{" "} + · h: back to query + + ); } @@ -640,10 +771,16 @@ function SearchPage() { [+] Subscribe (enter) - Already subscribed + + Subscribed · x: unsubscribe + - enter: subscribe · h: back to query + + enter: subscribe + {r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "} + · h: back to query + ); }} diff --git a/src/pages/Settings/DownloadManager.tsx b/src/pages/Settings/DownloadManager.tsx index e5c17ac..e7c9a9d 100644 --- a/src/pages/Settings/DownloadManager.tsx +++ b/src/pages/Settings/DownloadManager.tsx @@ -1,14 +1,19 @@ /** * DownloadManager — exposes downloads as SettingItems for the depth-stack. * - * • "Delete All Downloads" — action item; Enter wipes every download. - * • one item per show — action item; Enter deletes all that show's - * downloads (file + metadata, aborts in-flight). - * • one item per episode — action item; Enter deletes a single download. + * • "Delete All Downloads" — action item; Enter wipes every download. + * • one item per subscribed show — action item; Enter deletes all that + * show's downloads (file + metadata, aborts + * in-flight). + * • "Unsubscribed Show Downloads" — downloads made from episode search for + * shows that aren't subscribed, grouped + * under their own header. + * • one item per episode — action item; Enter deletes a single download. * * Titles resolve from the feed store at render time (reactive), falling back - * to the episode id when the feed is no longer loaded. Movement flows through - * nav.action — no own useKeyboard (matches the other panels). + * to the persisted episode/show titles for unsubscribed-show downloads. + * Movement flows through nav.action — no own useKeyboard (matches the other + * panels). */ import { useFeedStore } from "@/stores/feed"; @@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string { } } -/** Episode title for a download, resolved from the feed store (reactive). */ +/** Episode title for a download, resolved from the feed store (reactive); + * falls back to the persisted title (kept for unsubscribed-show downloads). */ function episodeTitle( feedStore: ReturnType, d: DownloadedEpisode, ): string { const feed = feedStore.getFeed(d.feedId); const ep = feed?.episodes.find((e) => e.id === d.episodeId); - return ep?.title ?? d.episodeId; + return ep?.title ?? d.episodeTitle ?? d.episodeId; } -/** Show title for a download's feed id. */ +/** Show title for a download's feed id; falls back to the persisted show + * title (unsubscribed-show downloads have no feed to resolve from). */ function feedTitle( feedStore: ReturnType, - feedId: string, + d: DownloadedEpisode, ): string { - const feed = feedStore.getFeed(feedId); - return feed ? feed.customName || feed.podcast.title : feedId; + const feed = feedStore.getFeed(d.feedId); + if (feed) return feed.customName || feed.podcast.title; + return d.podcastTitle ?? d.feedId; } export function useDownloadItems(): SettingItem[] { @@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] { }, ]; - // Group downloads by feed so each show gets a delete-by-show item. + // Group downloads by feed so each subscribed show gets a delete-by-show + // item. Unsubscribed-show downloads (search downloads, synthetic feed + // ids) are kept out of these groups and listed under their own section + // below. + const unsubscribed = downloadStore.getUnsubscribedDownloads(); + const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId)); const byFeed = new Map(); for (const d of downloads()) { + if (unsubscribedIds.has(d.episodeId)) continue; const arr = byFeed.get(d.feedId) ?? []; arr.push(d); byFeed.set(d.feedId, arr); @@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] { const size = eps.reduce((s, e) => s + e.fileSize, 0); items.push({ id: `feed:${feedId}`, - label: `Show: ${feedTitle(feedStore, feedId)}`, + label: `Show: ${feedTitle(feedStore, eps[0])}`, kind: "action", display: () => `${eps.length} · ${fmtBytes(size)}`, help: () => `Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`, run: () => { - downloadStore.removeDownloadsForFeed(feedId).catch(() => {}); + downloadStore + .removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl) + .catch(() => {}); }, }); } - // One item per individual episode download. + // Unsubscribed-show downloads: a section header + one item per episode. + if (unsubscribed.length > 0) { + items.push({ + id: "unsubscribed-header", + label: "Unsubscribed Show Downloads", + kind: "info", + display: () => `${unsubscribed.length} files`, + help: () => + `Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`, + }); + } + for (const d of unsubscribed) { + items.push({ + id: `unsub:${d.episodeId}`, + label: episodeTitle(feedStore, d), + kind: "action", + display: () => + `${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`, + help: () => + `Delete this single download (file + metadata). Enter to run.`, + run: () => { + downloadStore.removeDownload(d.episodeId).catch(() => {}); + }, + }); + } + + // One item per individual (subscribed-show) episode download. for (const d of downloads()) { + if (unsubscribedIds.has(d.episodeId)) continue; items.push({ id: `ep:${d.episodeId}`, label: episodeTitle(feedStore, d), kind: "action", display: () => - `${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`, + `${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`, help: () => `Delete this single download (file + metadata). Enter to run.`, run: () => { diff --git a/src/stores/download.ts b/src/stores/download.ts index 2d64cf8..83a3bf3 100644 --- a/src/stores/download.ts +++ b/src/stores/download.ts @@ -10,6 +10,7 @@ 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"; @@ -17,6 +18,24 @@ 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; @@ -28,6 +47,12 @@ interface DownloadRecord { 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 */ @@ -81,6 +106,11 @@ function createDownloadStore() { 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; @@ -106,8 +136,11 @@ function createDownloadStore() { downloadedAt: dl.downloadedAt?.toISOString() ?? null, fileSize: dl.fileSize, error: dl.error, - audioUrl: qItem?.audioUrl ?? "", - episodeTitle: qItem?.episodeTitle ?? "", + audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "", + episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "", + pubDate: dl.pubDate, + podcastTitle: dl.podcastTitle, + podcastFeedUrl: dl.podcastFeedUrl, }); } const filePath = getConfigFilePath(DOWNLOADS_FILE); @@ -260,8 +293,20 @@ function createDownloadStore() { 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): void => { + const startDownload = ( + episode: Episode, + feedId: string, + meta?: UnsubscribedMeta, + ): void => { const existing = downloads().get(episode.id); if ( existing?.status === DownloadStatus.DOWNLOADING || @@ -280,6 +325,11 @@ function createDownloadStore() { 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) => { @@ -300,6 +350,21 @@ function createDownloadStore() { 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 @@ -348,10 +413,18 @@ function createDownloadStore() { }; /** Remove every download (active/queued/completed) belonging to a feed — - * abort in-flight transfers, drop queued items, delete files + metadata. */ - const removeDownloadsForFeed = async (feedId: string): Promise => { + * 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, + (d) => + d.feedId === feedId || + (podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl), ); for (const d of eps) { cancelDownload(d.episodeId); @@ -364,6 +437,24 @@ function createDownloadStore() { 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(); @@ -381,11 +472,13 @@ function createDownloadStore() { getDownload, getDownloadedFilePath, getAllDownloads, + getUnsubscribedDownloads, getQueue, getActiveCount, // Actions startDownload, + startUnsubscribedDownload, cancelDownload, removeDownload, removeDownloadsForFeed, diff --git a/src/types/episode.ts b/src/types/episode.ts index 0988ee6..faf452f 100644 --- a/src/types/episode.ts +++ b/src/types/episode.ts @@ -98,7 +98,9 @@ export enum DownloadStatus { export interface DownloadedEpisode { /** Episode ID */ episodeId: string - /** Feed ID the episode belongs to */ + /** Feed ID the episode belongs to. For downloads of shows that aren't + * subscribed (search downloads) this is a deterministic synthetic id + * ("unsub-") that also names the file subdirectory. */ feedId: string /** Current download status */ status: DownloadStatus @@ -114,4 +116,16 @@ export interface DownloadedEpisode { fileSize: number /** Error message if failed */ error: string | null + /** Episode title, persisted so unsubscribed-show downloads render without + * a loaded feed. */ + episodeTitle?: string + /** Audio URL, persisted so queued downloads survive a restart. */ + audioUrl?: string + /** Publication date (ISO), for display of unsubscribed-show downloads. */ + pubDate?: string + /** Show title, kept for downloads whose show isn't subscribed. */ + podcastTitle?: string + /** The show's RSS feed URL, used to re-classify a download as subscribed + * once the user subscribes to its show. */ + podcastFeedUrl?: string } diff --git a/tests/download-unsubscribed.test.ts b/tests/download-unsubscribed.test.ts new file mode 100644 index 0000000..4034019 --- /dev/null +++ b/tests/download-unsubscribed.test.ts @@ -0,0 +1,182 @@ +/** + * Unsubscribed-show download tests — the download store contract behind the + * "Unsubscribed Show Downloads" list (My Shows depth 0 and the settings + * Download Manager): + * + * 1. startUnsubscribedDownload records the episode under a deterministic + * synthetic feed id with the show's metadata, and + * getUnsubscribedDownloads lists it. + * 2. A download made under a real (subscribed) feed id is NOT listed as + * unsubscribed. + * 3. Subscribing to the show re-classifies its unsubscribed download into + * the subscribed group — it drops out of getUnsubscribedDownloads. + * 4. removeDownloadsForFeed with the show's feed URL removes that show's + * unsubscribed downloads too (unsubscribing purges search downloads). + * + * Served over a real local HTTP server, mirroring how the app's other store + * tests exercise the network path. The store singleton is shared with other + * test files, so every added feed/download is removed in afterAll. + */ + +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config/data dirs at throwaway directories BEFORE importing the +// stores (their module-level init reads them). +const configHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-")); +process.env.XDG_CONFIG_HOME = configHome; +const dataHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-data-")); +process.env.XDG_DATA_HOME = dataHome; + +import { useDownloadStore } from "../src/stores/download"; +import { useFeedStore } from "../src/stores/feed"; +import type { Episode } from "../src/types/episode"; +import type { Podcast } from "../src/types/podcast"; + +let server: ReturnType | null = null; +let audioUrl = ""; +const addedFeedIds: string[] = []; +const addedEpisodeIds: string[] = []; + +/** Minimal RSS feed for one show. */ +function feedXml(title: string, origin: string): string { + return ` + +${title} +Test feed + +Ep 1 +2026-08-01T00:00:00Z + + +`; +} + +const makeEpisode = (id: string, title: string): Episode => ({ + id, + podcastId: "pod", + title, + description: "", + audioUrl, + duration: 0, + pubDate: new Date("2026-08-01T00:00:00Z"), +}); + +const makePodcast = (feedUrl: string, title: string): Podcast => ({ + id: `dir-${title}`, + title, + description: "Test feed", + author: "tester", + feedUrl, + lastUpdated: new Date(), + isSubscribed: false, +}); + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith(".xml")) { + return new Response(feedXml("Test Show", url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }); + } + return new Response("audio bytes", { + headers: { "Content-Type": "audio/mpeg" }, + }); + }, + }); + audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`; +}); + +afterAll(() => { + for (const id of addedEpisodeIds) { + const dl = useDownloadStore(); + dl.cancelDownload(id); + dl.removeDownload(id).catch(() => {}); + } + for (const id of addedFeedIds) { + useFeedStore().removeFeed(id); + } + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); + rmSync(dataHome, { recursive: true, force: true }); +}); + +test("startUnsubscribedDownload records a synthetic-feed download with show metadata", () => { + const dl = useDownloadStore(); + const episode = makeEpisode("unsub-ep-1", "Ep 1"); + const podcast = makePodcast("https://example.com/feed.xml", "Unsub Show"); + addedEpisodeIds.push(episode.id); + + dl.startUnsubscribedDownload(episode, podcast); + + const listed = dl.getUnsubscribedDownloads(); + const mine = listed.find((d) => d.episodeId === episode.id); + expect(mine).toBeDefined(); + expect(mine!.feedId).toBe("unsub-https-example-com-feed-xml"); + expect(mine!.podcastTitle).toBe("Unsub Show"); + expect(mine!.podcastFeedUrl).toBe("https://example.com/feed.xml"); + expect(mine!.episodeTitle).toBe("Ep 1"); +}); + +test("downloads under a real feed id are not listed as unsubscribed", async () => { + const feedStore = useFeedStore(); + const dl = useDownloadStore(); + const feedUrl = `http://127.0.0.1:${server!.port}/subbed.xml`; + const feed = await feedStore.addFeed(makePodcast(feedUrl, "Subbed"), "test"); + expect(feed).not.toBeNull(); + addedFeedIds.push(feed!.id); + + const episode = makeEpisode("subbed-ep-1", "Ep 1"); + addedEpisodeIds.push(episode.id); + dl.startDownload(episode, feed!.id); + + expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe( + false, + ); +}); + +test("subscribing to the show re-classifies its unsubscribed download", async () => { + const feedStore = useFeedStore(); + const dl = useDownloadStore(); + const feedUrl = `http://127.0.0.1:${server!.port}/later.xml`; + const episode = makeEpisode("unsub-ep-later", "Ep 1"); + const podcast = makePodcast(feedUrl, "Later Show"); + addedEpisodeIds.push(episode.id); + + // Downloaded while unsubscribed. + dl.startUnsubscribedDownload(episode, podcast); + expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe( + true, + ); + + // Subscribing later (same feed URL) moves it into the subscribed group. + const feed = await feedStore.addFeed(podcast, "test"); + expect(feed).not.toBeNull(); + addedFeedIds.push(feed!.id); + expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe( + false, + ); +}); + +test("removeDownloadsForFeed purges the show's unsubscribed downloads by feed URL", async () => { + const feedStore = useFeedStore(); + const dl = useDownloadStore(); + const feedUrl = `http://127.0.0.1:${server!.port}/purge.xml`; + const episode = makeEpisode("unsub-ep-purge", "Ep 1"); + addedEpisodeIds.push(episode.id); + + dl.startUnsubscribedDownload(episode, makePodcast(feedUrl, "Purge Show")); + expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe( + true, + ); + + // Unsubscribe the show: the feed is gone, but its URL still identifies + // the search downloads made while it was unsubscribed. + await dl.removeDownloadsForFeed("no-such-feed-id", feedUrl); + expect(dl.getAllDownloads().some((d) => d.episodeId === episode.id)).toBe(false); +});