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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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
|
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
|
the file contains credentials. *Fixed: `proxy`/`youtubePoToken`/`updateToken` are stripped (set to
|
||||||
`''`) before writing; SettingsView caption updated to say credentials are not included.*
|
`''`) 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
|
`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
|
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.
|
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
|
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
|
("Sunset-to-sea") with a default of **teal**. The whole section documents a palette that no
|
||||||
longer exists.
|
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
|
`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
|
be downloaded as audio without flipping the global default — inconsistent with the DownloadBar
|
||||||
playlist panel, which offers a per-item video/audio toggle.
|
playlist panel, which offers a per-item video/audio toggle.
|
||||||
|
|||||||
+8
-6
@@ -2,11 +2,11 @@
|
|||||||
* Persistence for the media-manager index (Pinchflat-style; see
|
* Persistence for the media-manager index (Pinchflat-style; see
|
||||||
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
|
* 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
|
* 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):
|
* Phase H risk note in the roadmap):
|
||||||
*
|
*
|
||||||
* sources.json — one Source record per added channel/playlist
|
* sources.json -- one Source record per added channel/playlist
|
||||||
* media-items.json — every MediaItem across all sources (queried by sourceId)
|
* 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
|
* 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).
|
* 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
|
* 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
|
* (re)index). Other sources' items are preserved in full; only the source being
|
||||||
* so they survive the MAX_ITEMS cap.
|
* 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 {
|
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
||||||
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
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])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
LinkRegular,
|
LinkRegular,
|
||||||
DismissRegular
|
DismissRegular
|
||||||
} from '@fluentui/react-icons'
|
} 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 { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
||||||
@@ -39,6 +39,7 @@ import { MediaThumb } from './MediaThumb'
|
|||||||
import { VirtualList } from './VirtualList'
|
import { VirtualList } from './VirtualList'
|
||||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
import { StatusChip } from './ui/StatusChip'
|
import { StatusChip } from './ui/StatusChip'
|
||||||
|
import { SegmentedControl } from './ui/SegmentedControl'
|
||||||
import { useFocusStyles } from './ui/focusRing'
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
import { THUMB_XS } from '../thumbSizes'
|
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.
|
// videos -- a library source is a channel/playlist to sync, not a one-off.
|
||||||
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(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<MediaKind>(
|
||||||
|
() => useSettings.getState().defaultKind
|
||||||
|
)
|
||||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||||
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
// 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.
|
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
||||||
function downloadSelected(): void {
|
function downloadSelected(): void {
|
||||||
if (!selectedSourceId || selectedActionable.length === 0) return
|
if (!selectedSourceId || selectedActionable.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, selectedActionable)
|
const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPending(): void {
|
function downloadPending(): void {
|
||||||
if (!selectedSourceId || pendingItems.length === 0) return
|
if (!selectedSourceId || pendingItems.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, pendingItems)
|
const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +439,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
if (!selectedSourceId) return
|
if (!selectedSourceId) return
|
||||||
const toQueue = groupItems.filter(actionable)
|
const toQueue = groupItems.filter(actionable)
|
||||||
if (toQueue.length === 0) return
|
if (toQueue.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, toQueue)
|
const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +640,15 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
{relTime(src.lastIndexedAt)}
|
{relTime(src.lastIndexedAt)}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
<div className={styles.actionSpacer} />
|
<div className={styles.actionSpacer} />
|
||||||
|
<SegmentedControl<MediaKind>
|
||||||
|
value={enqueueKind}
|
||||||
|
options={[
|
||||||
|
{ value: 'video', label: 'Video' },
|
||||||
|
{ value: 'audio', label: 'Audio' }
|
||||||
|
]}
|
||||||
|
onChange={setEnqueueKind}
|
||||||
|
ariaLabel="Download as video or audio"
|
||||||
|
/>
|
||||||
{selected.size > 0 ? (
|
{selected.size > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
|
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
||||||
import { useDownloads } from './downloads'
|
import { useDownloads } from './downloads'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
import { logError } from '../reportError'
|
import { logError } from '../reportError'
|
||||||
@@ -14,7 +14,7 @@ function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo
|
|||||||
id: `${sourceId}:${playlist}-${i + 1}`,
|
id: `${sourceId}:${playlist}-${i + 1}`,
|
||||||
sourceId,
|
sourceId,
|
||||||
videoId: `${playlist}-${i + 1}`,
|
videoId: `${playlist}-${i + 1}`,
|
||||||
title: `${playlist} — part ${i + 1}`,
|
title: `${playlist} -- part ${i + 1}`,
|
||||||
url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
|
url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
|
||||||
playlistTitle: playlist,
|
playlistTitle: playlist,
|
||||||
playlistIndex: i + 1,
|
playlistIndex: i + 1,
|
||||||
@@ -84,11 +84,11 @@ interface SourcesState {
|
|||||||
removeSource: (id: string) => void
|
removeSource: (id: string) => void
|
||||||
/**
|
/**
|
||||||
* Enqueue the given media items into the download queue with folder context.
|
* 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,
|
* 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
|
* which maxConcurrent then gates into download slots. Returns how many were
|
||||||
* enqueued.
|
* enqueued.
|
||||||
*/
|
*/
|
||||||
enqueueItems: (sourceId: string, items: MediaItem[]) => number
|
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
|
||||||
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
||||||
markDownloaded: (itemId: string, filePath?: string) => void
|
markDownloaded: (itemId: string, filePath?: string) => void
|
||||||
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||||
@@ -179,10 +179,11 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
|
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
|
||||||
},
|
},
|
||||||
|
|
||||||
enqueueItems: (sourceId, items) => {
|
enqueueItems: (sourceId, items, kindOverride) => {
|
||||||
const src = get().sources.find((s) => s.id === sourceId)
|
const src = get().sources.find((s) => s.id === sourceId)
|
||||||
const settings = useSettings.getState()
|
const settings = useSettings.getState()
|
||||||
const kind = settings.defaultKind
|
// Per-batch override (Library video/audio toggle) falls back to the global default (M27).
|
||||||
|
const kind = kindOverride ?? settings.defaultKind
|
||||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||||
// One batched state update + pump, so queuing a whole channel stays O(n).
|
// One batched state update + pump, so queuing a whole channel stays O(n).
|
||||||
useDownloads.getState().addMany(
|
useDownloads.getState().addMany(
|
||||||
|
|||||||
Reference in New Issue
Block a user