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:
@@ -33,7 +33,7 @@ import { useAudio } from "@/hooks/useAudio";
|
|||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
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 type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
@@ -62,9 +62,28 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
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 = () =>
|
const focusedShowIdx = () =>
|
||||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
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
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||||
@@ -104,11 +123,11 @@ export function MyShowsPage() {
|
|||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? shows().length : rowCount());
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (shows().length > 0 && focus(0) >= shows().length)
|
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||||
nav.setDepthFocus(shows().length - 1, 0);
|
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||||
nav.setDepthFocus(rowCount() - 1, 1);
|
nav.setDepthFocus(rowCount() - 1, 1);
|
||||||
};
|
};
|
||||||
@@ -116,7 +135,10 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
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;
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -172,9 +194,31 @@ export function MyShowsPage() {
|
|||||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
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 ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
playUnsubscribedDownload(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
@@ -215,6 +259,14 @@ export function MyShowsPage() {
|
|||||||
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||||
},
|
},
|
||||||
"delete-download": () => {
|
"delete-download": () => {
|
||||||
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (depth() < 1) return;
|
if (depth() < 1) return;
|
||||||
const ep = focusedEpisode();
|
const ep = focusedEpisode();
|
||||||
if (!ep) return;
|
if (!ep) return;
|
||||||
@@ -283,7 +335,9 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Shows (${shows().length})`
|
? `Shows (${shows().length})${
|
||||||
|
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||||
|
}`
|
||||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── 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 */}
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={depth0Count() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
@@ -377,6 +431,71 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</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>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: episodes */}
|
{/* depth ≥1: episodes */}
|
||||||
@@ -491,7 +610,11 @@ export function MyShowsPage() {
|
|||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered show
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
|
// hovered show.
|
||||||
|
<Show
|
||||||
|
when={focusedUnsub()}
|
||||||
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={selectedShow()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -512,12 +635,14 @@ export function MyShowsPage() {
|
|||||||
{show().episodes.length} episodes
|
{show().episodes.length} episodes
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
{show().podcast.description?.slice(0, 400) ??
|
||||||
|
"No description."}
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
enter/l: open · h: back · x: unsubscribe
|
enter/l: open · h: back · x: unsubscribe
|
||||||
{app.state().preferences.autoDownloadScope === "whitelist"
|
{app.state().preferences.autoDownloadScope ===
|
||||||
|
"whitelist"
|
||||||
? (app.state().preferences.autoDownloadWhitelist ??
|
? (app.state().preferences.autoDownloadWhitelist ??
|
||||||
[]
|
[]
|
||||||
).includes(show().id)
|
).includes(show().id)
|
||||||
@@ -528,6 +653,39 @@ export function MyShowsPage() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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)
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
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 { useToast } from "@/ui/toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -55,6 +59,9 @@ export const SearchPaneCount = 1;
|
|||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -134,6 +141,35 @@ function SearchPage() {
|
|||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
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 runSearch = (query: string) => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -185,6 +221,49 @@ function SearchPage() {
|
|||||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
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 ──────────────────────────────────────────────────────
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
"move-down": () => step(1),
|
"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: () => {
|
search: () => {
|
||||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
if (depth() === 0) nav.setInputFocused(true);
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
@@ -230,7 +320,13 @@ function SearchPage() {
|
|||||||
}
|
}
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
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) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
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 (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -495,6 +598,11 @@ function SearchPage() {
|
|||||||
? result.episode.title
|
? result.episode.title
|
||||||
: result.podcast.title}
|
: result.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={dlLabel()}>
|
||||||
|
<text fg={downloadColor(dlEpId())}>
|
||||||
|
{dlLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<Show when={result.podcast.isSubscribed}>
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : theme.success}
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
@@ -576,9 +684,16 @@ function SearchPage() {
|
|||||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
Published: {formatDate(r.episode.pubDate)}
|
Published: {formatDate(r.episode.pubDate)}
|
||||||
</text>
|
</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}>
|
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||||
@@ -594,12 +709,28 @@ function SearchPage() {
|
|||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={r.podcast.isSubscribed}>
|
<Show when={r.podcast.isSubscribed}>
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
|
<Show
|
||||||
|
when={r.podcast.isSubscribed}
|
||||||
|
fallback={
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
enter: subscribe to show · h: back to query
|
enter: subscribe · d: download · h: back to query
|
||||||
</text>
|
</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>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -640,10 +771,16 @@ function SearchPage() {
|
|||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={r.podcast.isSubscribed}>
|
<Show when={r.podcast.isSubscribed}>
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<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>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||||
*
|
*
|
||||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||||
* • one item per show — action item; Enter deletes all that show's
|
* • one item per subscribed show — action item; Enter deletes all that
|
||||||
* downloads (file + metadata, aborts in-flight).
|
* 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.
|
* • one item per episode — action item; Enter deletes a single download.
|
||||||
*
|
*
|
||||||
* Titles resolve from the feed store at render time (reactive), falling back
|
* 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
|
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||||
* nav.action — no own useKeyboard (matches the other panels).
|
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||||
|
* panels).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
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(
|
function episodeTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
d: DownloadedEpisode,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(d.feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
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(
|
function feedTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
feedId: string,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
if (feed) return feed.customName || feed.podcast.title;
|
||||||
|
return d.podcastTitle ?? d.feedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadItems(): SettingItem[] {
|
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[]>();
|
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
const arr = byFeed.get(d.feedId) ?? [];
|
const arr = byFeed.get(d.feedId) ?? [];
|
||||||
arr.push(d);
|
arr.push(d);
|
||||||
byFeed.set(d.feedId, arr);
|
byFeed.set(d.feedId, arr);
|
||||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||||
items.push({
|
items.push({
|
||||||
id: `feed:${feedId}`,
|
id: `feed:${feedId}`,
|
||||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||||
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()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: `ep:${d.episodeId}`,
|
id: `ep:${d.episodeId}`,
|
||||||
label: episodeTitle(feedStore, d),
|
label: episodeTitle(feedStore, d),
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () =>
|
display: () =>
|
||||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete this single download (file + metadata). Enter to run.`,
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createSignal } from "solid-js";
|
|||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
import type { DownloadedEpisode } from "../types/episode";
|
import type { DownloadedEpisode } from "../types/episode";
|
||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
|
import type { Podcast } from "../types/podcast";
|
||||||
import { downloadEpisode } from "../utils/episode-downloader";
|
import { downloadEpisode } from "../utils/episode-downloader";
|
||||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
@@ -17,6 +18,24 @@ import { useFeedStore } from "./feed";
|
|||||||
const DOWNLOADS_FILE = "downloads.json";
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2;
|
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 */
|
/** Serializable download record for persistence */
|
||||||
interface DownloadRecord {
|
interface DownloadRecord {
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
@@ -28,6 +47,12 @@ interface DownloadRecord {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
episodeTitle: 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 */
|
/** Queue item for pending downloads */
|
||||||
@@ -81,6 +106,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: rec.fileSize,
|
fileSize: rec.fileSize,
|
||||||
error: rec.error,
|
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;
|
return map;
|
||||||
@@ -106,8 +136,11 @@ function createDownloadStore() {
|
|||||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||||
fileSize: dl.fileSize,
|
fileSize: dl.fileSize,
|
||||||
error: dl.error,
|
error: dl.error,
|
||||||
audioUrl: qItem?.audioUrl ?? "",
|
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
|
||||||
episodeTitle: qItem?.episodeTitle ?? "",
|
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
|
||||||
|
pubDate: dl.pubDate,
|
||||||
|
podcastTitle: dl.podcastTitle,
|
||||||
|
podcastFeedUrl: dl.podcastFeedUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||||
@@ -260,8 +293,20 @@ function createDownloadStore() {
|
|||||||
return null;
|
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 */
|
/** 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);
|
const existing = downloads().get(episode.id);
|
||||||
if (
|
if (
|
||||||
existing?.status === DownloadStatus.DOWNLOADING ||
|
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||||
@@ -280,6 +325,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: episode.fileSize ?? 0,
|
fileSize: episode.fileSize ?? 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
episodeTitle: episode.title,
|
||||||
|
audioUrl: episode.audioUrl,
|
||||||
|
pubDate: episode.pubDate.toISOString(),
|
||||||
|
podcastTitle: meta?.podcastTitle,
|
||||||
|
podcastFeedUrl: meta?.podcastFeedUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
@@ -300,6 +350,21 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
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 */
|
/** Cancel a download */
|
||||||
const cancelDownload = (episodeId: string): void => {
|
const cancelDownload = (episodeId: string): void => {
|
||||||
// Abort active download
|
// Abort active download
|
||||||
@@ -348,10 +413,18 @@ function createDownloadStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Remove every download (active/queued/completed) belonging to a feed —
|
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||||
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
* abort in-flight transfers, drop queued items, delete files + metadata.
|
||||||
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
* 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(
|
const eps = Array.from(downloads().values()).filter(
|
||||||
(d) => d.feedId === feedId,
|
(d) =>
|
||||||
|
d.feedId === feedId ||
|
||||||
|
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
|
||||||
);
|
);
|
||||||
for (const d of eps) {
|
for (const d of eps) {
|
||||||
cancelDownload(d.episodeId);
|
cancelDownload(d.episodeId);
|
||||||
@@ -364,6 +437,24 @@ function createDownloadStore() {
|
|||||||
return Array.from(downloads().values());
|
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 */
|
/** Get the current queue */
|
||||||
const getQueue = (): QueueItem[] => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return queue();
|
return queue();
|
||||||
@@ -381,11 +472,13 @@ function createDownloadStore() {
|
|||||||
getDownload,
|
getDownload,
|
||||||
getDownloadedFilePath,
|
getDownloadedFilePath,
|
||||||
getAllDownloads,
|
getAllDownloads,
|
||||||
|
getUnsubscribedDownloads,
|
||||||
getQueue,
|
getQueue,
|
||||||
getActiveCount,
|
getActiveCount,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
startDownload,
|
startDownload,
|
||||||
|
startUnsubscribedDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
removeDownload,
|
removeDownload,
|
||||||
removeDownloadsForFeed,
|
removeDownloadsForFeed,
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ export enum DownloadStatus {
|
|||||||
export interface DownloadedEpisode {
|
export interface DownloadedEpisode {
|
||||||
/** Episode ID */
|
/** Episode ID */
|
||||||
episodeId: string
|
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
|
feedId: string
|
||||||
/** Current download status */
|
/** Current download status */
|
||||||
status: DownloadStatus
|
status: DownloadStatus
|
||||||
@@ -114,4 +116,16 @@ export interface DownloadedEpisode {
|
|||||||
fileSize: number
|
fileSize: number
|
||||||
/** Error message if failed */
|
/** Error message if failed */
|
||||||
error: string | null
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
182
tests/download-unsubscribed.test.ts
Normal file
182
tests/download-unsubscribed.test.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user