From cb25262a2d04e19d74f72d449560db67172f2412 Mon Sep 17 00:00:00 2001 From: debont80 Date: Sun, 5 Jul 2026 17:49:39 -0400 Subject: [PATCH] feat(library): add per-row "Download new" for watched sources Queuing a watched channel's not-yet-downloaded videos previously meant expanding the source and using its "Download N pending" button. Watched source rows now carry a one-click "Download new" that loads the source's items (even while collapsed) and queues everything not already downloaded or in the queue, using the source's resolved kind/profile. Extracts the shared, tested selectUndownloaded selector (also now backs the expanded panel's pending list) and adds ensureSourceItems to load a collapsed source's items without expanding it. --- src/renderer/src/store/librarySelect.ts | 16 +++ src/renderer/src/store/sources.ts | 18 +++- src/renderer/src/views/LibraryView.tsx | 132 ++++++++++++++++++------ test/librarySelect.test.ts | 43 ++++++++ 4 files changed, 176 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/store/librarySelect.ts create mode 100644 test/librarySelect.test.ts diff --git a/src/renderer/src/store/librarySelect.ts b/src/renderer/src/store/librarySelect.ts new file mode 100644 index 0000000..7ca3a9b --- /dev/null +++ b/src/renderer/src/store/librarySelect.ts @@ -0,0 +1,16 @@ +import type { MediaItem } from '@shared/ipc' + +/** + * Which of a source's items are worth queuing for a one-click "Download new": + * everything not already downloaded AND not already represented in the download + * queue. `queuedUrls` is the set of URLs that currently have any queue entry, so + * an item that's downloading, queued, completed, failed, or canceled is left + * alone — matching the expanded panel's "Download N pending" semantics (a failed + * item is retried via its own Retry, not bulk-swept here). + * + * Pure so the choice is unit-tested without the store; the impure enqueue lives + * in the caller (LibraryView). + */ +export function selectUndownloaded(items: MediaItem[], queuedUrls: Set): MediaItem[] { + return items.filter((it) => !it.downloaded && !queuedUrls.has(it.url)) +} diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index 401f36d..cd7d16c 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -39,7 +39,8 @@ const seedSources: Source[] = channel: 'DevChannel', addedAt: Date.now() - 86_400_000, lastIndexedAt: Date.now() - 3_600_000, - itemCount: 9 + itemCount: 9, + watched: true }, { id: 'src-mix', @@ -99,6 +100,11 @@ interface SourcesState { * enqueued. */ enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number + /** + * Load a source's items (lazily fetching + caching once) WITHOUT expanding it, + * so a per-row action can act on a collapsed source. Resolves with the items. + */ + ensureSourceItems: (sourceId: string) => Promise /** mark an item downloaded once its queue download completes (called by the downloads store) */ markDownloaded: (itemId: string, filePath?: string, quality?: string) => void /** toggle a source's watched-for-new-uploads flag (Phase J) */ @@ -300,6 +306,16 @@ export const useSources = create((set, get) => ({ return items.length }, + ensureSourceItems: async (sourceId) => { + const cached = get().itemsBySource[sourceId] + if (cached) return cached + // Preview has no IPC — fall back to whatever seed items exist for the source. + if (PREVIEW) return get().itemsBySource[sourceId] ?? [] + const items = await window.api.listSourceItems(sourceId) + set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: items } })) + return items + }, + markDownloaded: (itemId, filePath, quality) => { // Which loaded source holds this item — also the reconcile key, so overlapping // completions in different sources never suppress each other's response. diff --git a/src/renderer/src/views/LibraryView.tsx b/src/renderer/src/views/LibraryView.tsx index 40914bf..ee21fd3 100644 --- a/src/renderer/src/views/LibraryView.tsx +++ b/src/renderer/src/views/LibraryView.tsx @@ -33,6 +33,7 @@ import { Select } from '../components/Select' import { useNav } from '../store/nav' import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink' import { useDownloads, type DownloadStatus } from '../store/downloads' +import { selectUndownloaded } from '../store/librarySelect' import { thumbUrl } from '../lib/thumb' import { relTime } from '../lib/datetime' import { MediaThumb } from '../components/MediaThumb' @@ -109,13 +110,27 @@ const useStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground1, overflow: 'hidden' }, + // Row wrapping the expand button + an optional trailing action (e.g. "Download + // new"). The action is a sibling of the button, not nested inside it (nested + // + ) : undefined + } >
@@ -802,48 +863,55 @@ function SourceCard({ source, expanded, onToggleExpand, + rowAction, children }: { styles: ReturnType source: Source expanded: boolean onToggleExpand: () => void + /** an optional action rendered beside the header, OUTSIDE the expand button + * (nesting a button inside the header button is invalid) — e.g. "Download new" */ + rowAction?: React.ReactNode children: React.ReactNode }): React.JSX.Element { const focus = useFocusStyles() const text = useTextStyles() return (
- +
+ + {rowAction &&
{rowAction}
} +
{expanded && children}
) diff --git a/test/librarySelect.test.ts b/test/librarySelect.test.ts new file mode 100644 index 0000000..4431ff3 --- /dev/null +++ b/test/librarySelect.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { selectUndownloaded } from '../src/renderer/src/store/librarySelect' +import type { MediaItem } from '../src/shared/ipc' + +const item = (over: Partial & { videoId: string }): MediaItem => ({ + id: `src:${over.videoId}`, + sourceId: 'src', + title: over.videoId, + url: `https://youtube.com/watch?v=${over.videoId}`, + playlistTitle: 'Uploads', + playlistIndex: 1, + downloaded: false, + ...over +}) + +describe('selectUndownloaded', () => { + it('keeps items that are neither downloaded nor already queued', () => { + const items = [item({ videoId: 'a' }), item({ videoId: 'b' })] + const result = selectUndownloaded(items, new Set()) + expect(result.map((i) => i.videoId)).toEqual(['a', 'b']) + }) + + it('excludes already-downloaded items', () => { + const items = [item({ videoId: 'a', downloaded: true }), item({ videoId: 'b' })] + expect(selectUndownloaded(items, new Set()).map((i) => i.videoId)).toEqual(['b']) + }) + + it('excludes items whose URL already has a queue entry (any status)', () => { + const queued = item({ videoId: 'a' }) + const fresh = item({ videoId: 'b' }) + const result = selectUndownloaded([queued, fresh], new Set([queued.url])) + expect(result.map((i) => i.videoId)).toEqual(['b']) + }) + + it('returns an empty array when nothing is queueable', () => { + const items = [item({ videoId: 'a', downloaded: true })] + expect(selectUndownloaded(items, new Set())).toEqual([]) + }) + + it('returns an empty array for no items', () => { + expect(selectUndownloaded([], new Set())).toEqual([]) + }) +})