feat(download): unsubscribed-show downloads from search

Episode search results gain d (download), D (delete), x (unsubscribe)
and enter (play for subscribed shows); downloads of unsubscribed shows
are recorded with the show's metadata under a deterministic synthetic
feed id and listed under an "Unsubscribed Show Downloads" section in
My Shows and the settings Download Manager. Classified at render time
by feed id or feed URL, so subscribing re-classifies the downloads and
unsubscribing purges them.
This commit is contained in:
2026-08-11 13:11:32 -04:00
parent 2d7d49b91c
commit 15f8a098b5
6 changed files with 689 additions and 62 deletions

View File

@@ -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 <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
when={depth0Count() > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
@@ -377,6 +431,71 @@ export function MyShowsPage() {
);
}}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
<text fg={theme.textSecondary}>
Unsubscribed Show Downloads
</text>
</box>
<For each={unsubs()}>
{(d, index) => {
// Rows continue after the shows list.
const rowIdx = () => shows().length + index();
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => rowIdx() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(rowIdx(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
}}
</For>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
@@ -491,7 +610,11 @@ 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.
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
@@ -512,12 +635,14 @@ export function MyShowsPage() {
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
{show().podcast.description?.slice(0, 400) ??
"No description."}
</text>
<box height={1} />
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope === "whitelist"
{app.state().preferences.autoDownloadScope ===
"whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
@@ -528,6 +653,39 @@ export function MyShowsPage() {
</box>
)}
</Show>
}
>
{(d) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>
{d().podcastTitle ?? d().feedId}
</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>
{formatDate(new Date(d().pubDate!))}
</text>
</Show>
<Show when={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not
subscribed.
</text>
<box height={1} />
<text fg={muted()}>
enter: play · D: delete download · h: back
</text>
</box>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>

View File

@@ -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<Record<KeybindActionName, () => 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 (
<box
ref={ref}
@@ -495,6 +598,11 @@ function SearchPage() {
? result.episode.title
: result.podcast.title}
</text>
<Show when={dlLabel()}>
<text fg={downloadColor(dlEpId())}>
{dlLabel()}
</text>
</Show>
<Show when={result.podcast.isSubscribed}>
<text
fg={index() === fi() ? theme.surface : theme.success}
@@ -576,9 +684,16 @@ function SearchPage() {
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<box flexDirection="row" gap={2}>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={downloadLabel(r.episode.id)}>
<text fg={downloadColor(r.episode.id)}>
{downloadLabel(r.episode.id)}
</text>
</Show>
</box>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
@@ -594,12 +709,28 @@ function SearchPage() {
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<Show
when={r.podcast.isSubscribed}
fallback={
<text fg={muted()}>
enter: subscribe to show · h: back to query
enter: subscribe · d: download · h: back to query
</text>
}
>
<text fg={muted()}>
enter: play · d: download · x: unsubscribe
{downloadStore.getDownloadStatus(r.episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· h: back to query
</text>
</Show>
</box>
);
}
@@ -640,10 +771,16 @@ function SearchPage() {
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
<text fg={muted()}>
enter: subscribe
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
· h: back to query
</text>
</box>
);
}}

View File

@@ -2,13 +2,18 @@
* 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 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<typeof useFeedStore>,
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<typeof useFeedStore>,
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<string, DownloadedEpisode[]>();
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: () => {

View File

@@ -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<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;
@@ -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<void> => {
* 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,
(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,

View File

@@ -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-<slug>") 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
}

View File

@@ -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<typeof Bun.serve> | null = null;
let audioUrl = "";
const addedFeedIds: string[] = [];
const addedEpisodeIds: string[] = [];
/** Minimal RSS feed for one show. */
function feedXml(title: string, origin: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>${title}</title>
<description>Test feed</description>
<item>
<title>Ep 1</title>
<pubDate>2026-08-01T00:00:00Z</pubDate>
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
</item>
</channel></rss>`;
}
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);
});