From 6ee4dbe78e5127daa390d13e8e57701ce4682848 Mon Sep 17 00:00:00 2001 From: debont80 Date: Tue, 30 Jun 2026 15:51:33 -0400 Subject: [PATCH] Fix M23/M27: cap per-source on re-index; per-batch kind for library enqueue M23: replaceMediaItems wrote [...newItems, ...others] and the JSON store sliced the combined list to MAX_ITEMS, so re-indexing one large source could silently evict ANOTHER source's tail. Now other sources are always kept in full and only the source being (re)indexed is capped to the remaining budget (MAX_ITEMS - others.length). M27: enqueueItems forced settings.defaultKind for every item, so a channel couldn't be queued as audio without flipping the global default. Added an optional `kind` override (falls back to the default) and a video/audio SegmentedControl in the Library action row, seeded from defaultKind so existing behavior is unchanged until toggled. typecheck + 242 tests + eslint green. Co-Authored-By: Claude Opus 4.8 --- CODE-AUDIT.md | 4 ++-- src/main/sources.ts | 14 +++++++------ src/renderer/src/components/LibraryView.tsx | 23 +++++++++++++++++---- src/renderer/src/store/sources.ts | 13 ++++++------ 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index d8df109..e5ba987 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -289,7 +289,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n plain JSON file; the UI caption only says "Does not include download history" — no warning that the file contains credentials. *Fixed: `proxy`/`youtubePoToken`/`updateToken` are stripped (set to `''`) before writing; SettingsView caption updated to say credentials are not included.* -- [ ] **M23 — `MAX_ITEMS` truncation can silently drop a source's items.** [sources.ts](src/main/sources.ts) +- [x] **M23 — `MAX_ITEMS` truncation can silently drop a source's items.** [sources.ts](src/main/sources.ts) `replaceMediaItems` does `[...new, ...others].slice(0, 20000)`; once the global total exceeds the cap, the *tail* (another source's items) is dropped on write and only reappears when that source is re-indexed. Cap per-source, or surface it. @@ -309,7 +309,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n Toffee; the shipped [theme.ts](src/renderer/src/theme.ts) is rose / coral / amber / teal ("Sunset-to-sea") with a default of **teal**. The whole section documents a palette that no longer exists. -- [ ] **M27 — Library batch-enqueue ignores per-item kind.** [sources.ts](src/renderer/src/store/sources.ts) +- [x] **M27 — Library batch-enqueue ignores per-item kind.** [sources.ts](src/renderer/src/store/sources.ts) `enqueueItems` forces `settings.defaultKind` (+ its quality) for *every* item, so a channel can't be downloaded as audio without flipping the global default — inconsistent with the DownloadBar playlist panel, which offers a per-item video/audio toggle. diff --git a/src/main/sources.ts b/src/main/sources.ts index 34a579b..17e181c 100644 --- a/src/main/sources.ts +++ b/src/main/sources.ts @@ -2,11 +2,11 @@ * Persistence for the media-manager index (Pinchflat-style; see * ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the * history.ts pattern (and the same deliberate choice to stay on JSON rather than - * better-sqlite3 — revisit if a user indexes many large channels, see the + * better-sqlite3 -- revisit if a user indexes many large channels, see the * Phase H risk note in the roadmap): * - * sources.json — one Source record per added channel/playlist - * media-items.json — every MediaItem across all sources (queried by sourceId) + * sources.json -- one Source record per added channel/playlist + * media-items.json -- every MediaItem across all sources (queried by sourceId) * * Per-row validation on read (validation.ts) so a hand-edited or corrupted file * can't feed the UI malformed records (same approach as history/errorlog). @@ -68,12 +68,14 @@ export function listMediaItems(sourceId: string): MediaItem[] { /** * Replace all media items for one source with a fresh set (the result of a - * (re)index). Other sources' items are preserved; the new items are placed first - * so they survive the MAX_ITEMS cap. + * (re)index). Other sources' items are preserved in full; only the source being + * written here absorbs the MAX_ITEMS cap, so re-indexing one (large) source can + * never silently evict another source's items past the global limit (M23). */ export function replaceMediaItems(sourceId: string, items: MediaItem[]): void { const others = listAllItems().filter((m) => m.sourceId !== sourceId) - itemsStore.write([...items, ...others]) + const budget = Math.max(0, MAX_ITEMS - others.length) + itemsStore.write([...items.slice(0, budget), ...others]) } /** diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index d08046c..d96776c 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -27,7 +27,7 @@ import { LinkRegular, DismissRegular } from '@fluentui/react-icons' -import type { MediaItem, Source } from '@shared/ipc' +import type { MediaItem, Source, MediaKind } from '@shared/ipc' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' @@ -39,6 +39,7 @@ import { MediaThumb } from './MediaThumb' import { VirtualList } from './VirtualList' import { ScreenHeader, useScreenStyles } from './ui/Screen' import { StatusChip } from './ui/StatusChip' +import { SegmentedControl } from './ui/SegmentedControl' import { useFocusStyles } from './ui/focusRing' import { THUMB_XS } from '../thumbSizes' @@ -279,6 +280,11 @@ export function LibraryView(): React.JSX.Element { // videos -- a library source is a channel/playlist to sync, not a one-off. const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u)) const [selected, setSelected] = useState>(new Set()) + // Per-batch video/audio choice for queueing items, seeded from the global + // default so behavior is unchanged until the user flips it (M27). + const [enqueueKind, setEnqueueKind] = useState( + () => useSettings.getState().defaultKind + ) const [batchNote, setBatchNote] = useState(null) const [syncNote, setSyncNote] = useState(null) // Which playlist groups are expanded. Empty = all collapsed (the default), so @@ -416,14 +422,14 @@ export function LibraryView(): React.JSX.Element { // run, the rest wait in the queue. Selection clears since nothing is held back. function downloadSelected(): void { if (!selectedSourceId || selectedActionable.length === 0) return - const n = enqueueItems(selectedSourceId, selectedActionable) + const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind) setSelected(new Set()) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) } function downloadPending(): void { if (!selectedSourceId || pendingItems.length === 0) return - const n = enqueueItems(selectedSourceId, pendingItems) + const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) } @@ -433,7 +439,7 @@ export function LibraryView(): React.JSX.Element { if (!selectedSourceId) return const toQueue = groupItems.filter(actionable) if (toQueue.length === 0) return - const n = enqueueItems(selectedSourceId, toQueue) + const n = enqueueItems(selectedSourceId, toQueue, enqueueKind) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) } @@ -634,6 +640,15 @@ export function LibraryView(): React.JSX.Element { {relTime(src.lastIndexedAt)}
+ + value={enqueueKind} + options={[ + { value: 'video', label: 'Video' }, + { value: 'audio', label: 'Audio' } + ]} + onChange={setEnqueueKind} + ariaLabel="Download as video or audio" + /> {selected.size > 0 ? ( <>