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:
@@ -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<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: () => {
|
||||
|
||||
Reference in New Issue
Block a user