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.
This commit is contained in:
@@ -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<string>): MediaItem[] {
|
||||
return items.filter((it) => !it.downloaded && !queuedUrls.has(it.url))
|
||||
}
|
||||
@@ -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<MediaItem[]>
|
||||
/** 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<SourcesState>((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.
|
||||
|
||||
@@ -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
|
||||
// <button>s are invalid), so both stay independently focusable.
|
||||
cardHeadRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
cardHeadAction: {
|
||||
flexShrink: 0,
|
||||
paddingRight: SPACE.cozy
|
||||
},
|
||||
cardHead: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
// List-row padding tier (UI3): cozy on both axes (was 12/14).
|
||||
padding: SPACE.cozy,
|
||||
width: '100%',
|
||||
// Fill the row, but shrink to make room for the trailing action (minWidth:0
|
||||
// lets the title ellipsize instead of pushing the action off the edge).
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
|
||||
@@ -235,6 +250,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
const cancelIndexing = useSources((s) => s.cancelIndexing)
|
||||
const removeSource = useSources((s) => s.removeSource)
|
||||
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||
const ensureSourceItems = useSources((s) => s.ensureSourceItems)
|
||||
const setWatched = useSources((s) => s.setWatched)
|
||||
const setProfile = useSources((s) => s.setProfile)
|
||||
const profiles = useProfiles((s) => s.profiles)
|
||||
@@ -279,6 +295,10 @@ export function LibraryView(): React.JSX.Element {
|
||||
)
|
||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||
// The source whose per-row "Download new" is currently loading items + queuing,
|
||||
// so its button shows a busy state (the action is async: it may fetch a
|
||||
// collapsed source's items before enqueuing).
|
||||
const [busySourceId, setBusySourceId] = useState<string | null>(null)
|
||||
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
||||
// an expanded source shows just its group headers until one is opened.
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -355,7 +375,11 @@ export function LibraryView(): React.JSX.Element {
|
||||
const effStatus = (it: MediaItem): ItemStatus =>
|
||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
||||
|
||||
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
||||
// Every URL that already has a queue entry (any status), so a source's
|
||||
// not-downloaded-and-not-queued items can be picked with the shared selector —
|
||||
// used both here for the expanded panel and by the per-row "Download new".
|
||||
const queuedUrls = useMemo(() => new Set(statusByUrl.keys()), [statusByUrl])
|
||||
const pendingItems = selectUndownloaded(items, queuedUrls)
|
||||
|
||||
// An item is worth (re)queuing when it isn't already downloaded or in flight:
|
||||
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
|
||||
@@ -431,6 +455,30 @@ export function LibraryView(): React.JSX.Element {
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
// Per source-row action: queue every not-yet-downloaded, not-yet-queued video
|
||||
// of a (possibly collapsed) watched source in one click — loading its items
|
||||
// first if they aren't cached. Uses the source's own resolved kind (no
|
||||
// kindOverride), and reports the outcome in the shared sync note.
|
||||
async function downloadNew(src: Source): Promise<void> {
|
||||
if (busySourceId) return
|
||||
setBusySourceId(src.id)
|
||||
setSyncNote(null)
|
||||
try {
|
||||
const its = await ensureSourceItems(src.id)
|
||||
const queueable = selectUndownloaded(its, queuedUrls)
|
||||
if (queueable.length === 0) {
|
||||
setSyncNote(`Nothing new to download for ${src.title}.`)
|
||||
return
|
||||
}
|
||||
const n = enqueueItems(src.id, queueable)
|
||||
setSyncNote(`Queued ${n} from ${src.title}.`)
|
||||
} catch {
|
||||
setSyncNote(`Couldn't load videos for ${src.title}.`)
|
||||
} finally {
|
||||
setBusySourceId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue every actionable (pending/failed/canceled) video in one playlist group,
|
||||
// so "download this whole playlist" is a single click.
|
||||
function downloadGroup(groupItems: MediaItem[]): void {
|
||||
@@ -644,6 +692,19 @@ export function LibraryView(): React.JSX.Element {
|
||||
source={src}
|
||||
expanded={selectedSourceId === src.id}
|
||||
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
||||
rowAction={
|
||||
src.watched ? (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={busySourceId === src.id ? <Spinner size="tiny" /> : <ArrowDownloadRegular />}
|
||||
onClick={() => downloadNew(src)}
|
||||
disabled={busySourceId !== null}
|
||||
>
|
||||
Download new
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.actionRow}>
|
||||
@@ -802,48 +863,55 @@ function SourceCard({
|
||||
source,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
rowAction,
|
||||
children
|
||||
}: {
|
||||
styles: ReturnType<typeof useStyles>
|
||||
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 (
|
||||
<div className={styles.card}>
|
||||
<button
|
||||
type="button"
|
||||
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||
onClick={onToggleExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={source.title}
|
||||
>
|
||||
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<div className={styles.srcIcon}>
|
||||
<VideoClipMultipleRegular />
|
||||
</div>
|
||||
<div className={styles.srcMeta}>
|
||||
<span className={styles.srcTitleRow}>
|
||||
<Text className={styles.srcTitle}>{source.title}</Text>
|
||||
{source.watched && (
|
||||
<span className={styles.watchBadge}>
|
||||
<AlertRegular fontSize={11} /> Watching
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption1 className={text.muted}>
|
||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
||||
{META_SEP}
|
||||
{source.itemCount} videos
|
||||
{source.channel && source.channel !== source.title
|
||||
? `${META_SEP}${source.channel}`
|
||||
: ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.cardHeadRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||
onClick={onToggleExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={source.title}
|
||||
>
|
||||
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<div className={styles.srcIcon}>
|
||||
<VideoClipMultipleRegular />
|
||||
</div>
|
||||
<div className={styles.srcMeta}>
|
||||
<span className={styles.srcTitleRow}>
|
||||
<Text className={styles.srcTitle}>{source.title}</Text>
|
||||
{source.watched && (
|
||||
<span className={styles.watchBadge}>
|
||||
<AlertRegular fontSize={11} /> Watching
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption1 className={text.muted}>
|
||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
||||
{META_SEP}
|
||||
{source.itemCount} videos
|
||||
{source.channel && source.channel !== source.title
|
||||
? `${META_SEP}${source.channel}`
|
||||
: ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
</button>
|
||||
{rowAction && <div className={styles.cardHeadAction}>{rowAction}</div>}
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<MediaItem> & { 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user