feat: Library bulk download + list virtualization for large channels

Add a source-wide "Select all" across all playlist groups, and make one
click queue every indexed video (the previous 100-per-click cap is gone).
Bulk download skips items that are already downloaded or in flight, so it
can't enqueue duplicates, and it includes failed/canceled ones so it also
retries them.

Make the UI hold up at channel scale (1000s of videos): virtualize both
the Downloads queue and the Library item list with @tanstack/react-virtual
so only on-screen rows hit the DOM, memoize the queue row so they don't all
reconcile on every progress tick, and batch the enqueue so queuing a whole
channel stays O(n) instead of O(n^2).

- VirtualList: reusable windowed list that owns its own scroll container
- DownloadsView: virtualized, flex-filled queue
- LibraryView: groups flattened to rows; virtualized panel above 100 rows,
  inline below so small sources are unchanged
- QueueItem: React.memo
- downloads store: addMany (one state update + one pump); sources:
  enqueue the whole selection via addMany, no cap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:27:52 -04:00
parent 4854fcc947
commit 8e161fe9ce
8 changed files with 354 additions and 143 deletions
+30 -2
View File
@@ -1,16 +1,17 @@
{
"name": "aerofetch",
"version": "0.1.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerofetch",
"version": "0.1.0",
"version": "0.4.1",
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -3373,6 +3374,33 @@
"node": ">=10"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+1
View File
@@ -25,6 +25,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+20 -10
View File
@@ -9,22 +9,28 @@ import { ArrowDownloadRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
const useStyles = makeStyles({
root: {
// Fill the scroll area so the queue list below can flex to the remaining
// height and scroll internally (it's virtualized), instead of the whole page
// growing to thousands of rows.
display: 'flex',
flexDirection: 'column',
gap: '20px'
gap: '20px',
height: '100%'
},
queueHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
listScroll: {
flexGrow: 1,
// min-height:0 lets this flex child shrink below its content height so it,
// not the page, owns the scrolling.
minHeight: 0
},
empty: {
display: 'flex',
@@ -65,11 +71,15 @@ export function DownloadsView(): React.JSX.Element {
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div>
) : (
<div className={styles.list}>
{items.map((item) => (
<QueueItem key={item.id} item={item} />
))}
</div>
<VirtualList
items={items}
className={styles.listScroll}
gap={10}
overscan={6}
estimateSize={() => 100}
getKey={(item) => item.id}
renderItem={(item) => <QueueItem item={item} />}
/>
)}
</div>
)
+131 -58
View File
@@ -28,11 +28,12 @@ import {
LibraryRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -40,6 +41,18 @@ const PREVIEW = typeof window === 'undefined' || !window.electron
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'
/**
* A flattened row for the virtualized item list: either a playlist header or a
* single video. Flattening the groups into one list lets a 1000s-video source
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] }
| { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
@@ -134,7 +147,6 @@ const useStyles = makeStyles({
},
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
actionSpacer: { flexGrow: 1 },
group: { display: 'flex', flexDirection: 'column' },
groupHead: {
display: 'flex',
alignItems: 'center',
@@ -143,13 +155,13 @@ const useStyles = makeStyles({
color: tokens.colorNeutralForeground2
},
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
rows: { display: 'flex', flexDirection: 'column' },
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '5px 4px 5px 18px'
},
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: {
width: '60px',
height: '34px',
@@ -236,6 +248,7 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null)
const [scheduled, setScheduled] = useState(false)
@@ -261,8 +274,11 @@ export function LibraryView(): React.JSX.Element {
if (res.error) setError(res.error)
}
// Reset the selection whenever the expanded source changes.
useEffect(() => setSelected(new Set()), [selectedSourceId])
// Reset the selection (and any batch note) whenever the expanded source changes.
useEffect(() => {
setSelected(new Set())
setBatchNote(null)
}, [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
@@ -273,12 +289,34 @@ export function LibraryView(): React.JSX.Element {
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
const groups = useMemo(() => groupByPlaylist(items), [items])
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
for (const g of groups) {
rows.push({ kind: 'header', title: g.title, items: g.items })
for (const it of g.items) rows.push({ kind: 'item', item: it })
}
return rows
}, [groups])
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// 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/
// downloading items keeps "Select all → Download" from enqueuing duplicates,
// since addFromUrl doesn't dedupe by URL.
const actionable = (it: MediaItem): boolean => {
const st = effStatus(it)
return st === 'pending' || st === 'error' || st === 'canceled'
}
const actionableItems = items.filter(actionable)
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
async function onIndex(): Promise<void> {
setError(null)
const u = url.trim()
@@ -308,16 +346,24 @@ export function LibraryView(): React.JSX.Element {
})
}
// Select / clear every downloadable item across all groups in this source.
function toggleAll(on: boolean): void {
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
}
// One click queues every chosen item — maxConcurrent gates how many actually
// run, the rest wait in the queue. Selection clears since nothing is held back.
function downloadSelected(): void {
if (!selectedSourceId) return
const chosen = items.filter((it) => selected.has(it.id))
enqueueItems(selectedSourceId, chosen)
if (!selectedSourceId || selectedActionable.length === 0) return
const n = enqueueItems(selectedSourceId, selectedActionable)
setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function downloadPending(): void {
if (!selectedSourceId) return
enqueueItems(selectedSourceId, pendingItems)
if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function pillClass(status: ItemStatus): string {
@@ -327,6 +373,52 @@ export function LibraryView(): React.JSX.Element {
return styles.pill
}
// One row of the item list — a playlist header or a video — shared by the
// inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
}
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const allOn = row.items.every((it) => selected.has(it.id))
return (
<div className={styles.groupHead}>
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'}
</Button>
</div>
)
}
const it = row.item
const status = effStatus(it)
return (
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
@@ -418,6 +510,12 @@ export function LibraryView(): React.JSX.Element {
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Checkbox
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
onChange={(_, d) => toggleAll(!!d.checked)}
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
@@ -433,8 +531,9 @@ export function LibraryView(): React.JSX.Element {
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
disabled={selectedActionable.length === 0}
>
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
Download {selectedActionable.length} selected
</Button>
</>
) : (
@@ -445,7 +544,7 @@ export function LibraryView(): React.JSX.Element {
onClick={downloadPending}
disabled={pendingItems.length === 0}
>
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
Download {pendingItems.length} pending
</Button>
)}
<span className={styles.switchRow}>
@@ -475,55 +574,29 @@ export function LibraryView(): React.JSX.Element {
</Button>
</div>
{groups.map((g) => {
const allOn = g.items.every((it) => selected.has(it.id))
return (
<div key={g.title} className={styles.group}>
<div className={styles.groupHead}>
<AppsListRegular />
<span className={styles.groupTitle}>{g.title}</span>
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={() => toggleGroup(g.items, !allOn)}
>
{allOn ? 'None' : 'All'}
</Button>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
// panel so 1000s of rows stay light. A definite height (not
// max-height) gives the virtualizer a viewport to measure.
<VirtualList
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
) : (
// Small source: render inline so the card grows naturally.
<div className={styles.plainList}>
{flatRows.map((row) => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
<div className={styles.rows}>
{g.items.map((it) => {
const status = effStatus(it)
return (
<div key={it.id} className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && (
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
)}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</SourceCard>
))}
</div>
+7 -2
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import {
Text,
Caption1,
@@ -99,7 +100,11 @@ function pct(progress: number): string {
return `${Math.round(progress * 100)}%`
}
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
export const QueueItem = memo(function QueueItem({
item
}: {
item: DownloadItem
}): React.JSX.Element {
const styles = useStyles()
const cancel = useDownloads((s) => s.cancel)
const remove = useDownloads((s) => s.remove)
@@ -234,4 +239,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div>
</div>
)
}
})
@@ -0,0 +1,70 @@
import { useRef, type CSSProperties, type ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
/**
* Windowed list: renders only the rows near the viewport, so a list thousands of
* items long stays light — one DOM subtree per visible row, not per item. This
* keeps the Downloads queue and a big channel's Library list responsive when a
* source has 1000s of videos.
*
* It owns its own scroll container, so size that container via `style`/`className`
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
* the virtualizer anchors at scroll offset 0 and never has to track where the
* list sits within the page — robust even though the app shares one outer
* scroll area with content above each list.
*
* Row heights are measured (measureElement), so variable-height rows are fine;
* `estimateSize` only needs to be a rough first guess before measurement.
*/
export function VirtualList<T>({
items,
estimateSize,
getKey,
renderItem,
overscan = 8,
gap = 0,
className,
style
}: {
items: T[]
estimateSize: (index: number) => number
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => ReactNode
overscan?: number
gap?: number
className?: string
style?: CSSProperties
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[index], index)
})
return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(items[vrow.index], vrow.index)}
</div>
))}
</div>
</div>
)
}
+65 -42
View File
@@ -54,17 +54,8 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState {
items: DownloadItem[]
addFromUrl: (
url: string,
kind: MediaKind,
quality: string,
opts?: {
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
format?: ChosenFormat
title?: string
channel?: string
@@ -75,8 +66,29 @@ interface DownloadState {
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
) => void
}
/** One URL to enqueue in a single addMany batch. */
export interface AddEntry {
url: string
kind: MediaKind
quality: string
opts?: AddOptions
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState {
items: DownloadItem[]
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
/**
* Enqueue many URLs at once with a single state update and a single pump, so
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
* way looping addFromUrl would.
*/
addMany: (entries: AddEntry[]) => void
retry: (id: string) => void
cancel: (id: string) => void
remove: (id: string) => void
@@ -95,6 +107,38 @@ function newId(): string {
return `item-${++idCounter}`
}
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
*/
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
return {
id: newId(),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
}
function titleFromUrl(url: string): string {
try {
const u = new URL(url)
@@ -291,35 +335,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump,
addFromUrl: (url, kind, quality, opts) => {
const id = newId()
// If the caller already probed the URL, carry that metadata so main can skip
// its own redundant probe (audit P2). Only set it when something real was
// provided — a bare paste with no probe leaves it undefined.
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
const item: DownloadItem = {
id,
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
set((s) => ({ items: [item, ...s.items] }))
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
pump()
},
addMany: (entries) => {
if (entries.length === 0) return
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
set((s) => ({ items: [...built, ...s.items] }))
pump()
},
+14 -13
View File
@@ -6,11 +6,6 @@ import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Cap on how many items one "Download" click enqueues, so an entire channel
// can't flood the live queue at once — the rest stay pending for a follow-up
// click. (The automatic incremental feeder is Phase I; see ROADMAP-PINCHFLAT.md.)
export const MAX_ENQUEUE_BATCH = 100
// --- Preview seed data ------------------------------------------------------
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
@@ -87,8 +82,10 @@ interface SourcesState {
reindexSource: (id: string) => Promise<void>
removeSource: (id: string) => void
/**
* Enqueue the given media items into the download queue with folder context,
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued.
* Enqueue the given media items into the download queue with folder context.
* Queues all of them — one click can fill the queue with an entire channel,
* which maxConcurrent then gates into download slots. Returns how many were
* enqueued.
*/
enqueueItems: (sourceId: string, items: MediaItem[]) => number
/** mark an item downloaded once its queue download completes (called by the downloads store) */
@@ -185,10 +182,13 @@ export const useSources = create<SourcesState>((set, get) => ({
const settings = useSettings.getState()
const kind = settings.defaultKind
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
const batch = items.slice(0, MAX_ENQUEUE_BATCH)
const add = useDownloads.getState().addFromUrl
for (const it of batch) {
add(it.url, kind, quality, {
// One batched state update + pump, so queuing a whole channel stays O(n).
useDownloads.getState().addMany(
items.map((it) => ({
url: it.url,
kind,
quality,
opts: {
title: it.title,
channel: src?.channel,
durationLabel: it.durationLabel,
@@ -198,9 +198,10 @@ export const useSources = create<SourcesState>((set, get) => ({
index: it.playlistIndex
},
mediaItemId: it.id
})
}
return batch.length
}))
)
return items.length
},
markDownloaded: (itemId, filePath) => {