1376c2dee8
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
4.4 KiB
TypeScript
109 lines
4.4 KiB
TypeScript
/**
|
||
* 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
|
||
* 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)
|
||
*
|
||
* 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).
|
||
*/
|
||
|
||
import type { Source, MediaItem } from '@shared/ipc'
|
||
import { isValidSource, isValidMediaItem } from './validation'
|
||
import { mergeItemsPreservingState } from './indexerCore'
|
||
import { createJsonStore } from './jsonStore'
|
||
|
||
// A generous global cap so a runaway index can't grow the file unbounded; large
|
||
// enough for several big channels. When exceeded, the most-recently-written
|
||
// source's items are kept (they're placed first by replaceMediaItems).
|
||
const MAX_ITEMS = 20000
|
||
|
||
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
||
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
||
// rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
|
||
// cache and writes are batched. Sources are few, so that store is uncapped.
|
||
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
|
||
const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS)
|
||
|
||
// --- Sources ----------------------------------------------------------------
|
||
|
||
export function listSources(): Source[] {
|
||
return sourcesStore.read()
|
||
}
|
||
|
||
export function getSource(id: string): Source | undefined {
|
||
return listSources().find((s) => s.id === id)
|
||
}
|
||
|
||
/** Insert or replace a source by id (a re-index updates the existing record). */
|
||
export function upsertSource(source: Source): Source[] {
|
||
return sourcesStore.write([source, ...listSources().filter((s) => s.id !== source.id)])
|
||
}
|
||
|
||
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
|
||
export function setSourceWatched(id: string, watched: boolean): Source[] {
|
||
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
|
||
}
|
||
|
||
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||
export function removeSource(id: string): Source[] {
|
||
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
||
itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
|
||
return sources
|
||
}
|
||
|
||
// --- Media items ------------------------------------------------------------
|
||
|
||
function listAllItems(): MediaItem[] {
|
||
return itemsStore.read()
|
||
}
|
||
|
||
export function listMediaItems(sourceId: string): MediaItem[] {
|
||
return listAllItems().filter((m) => m.sourceId === sourceId)
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
||
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
||
itemsStore.write([...items, ...others])
|
||
}
|
||
|
||
/**
|
||
* Incrementally merge a freshly-indexed item list into the persisted store,
|
||
* preserving the downloaded state of videos already on disk (see Phase I). The
|
||
* fresh list defines current membership/order; returns the merged items and how
|
||
* many were new since the last index.
|
||
*/
|
||
export function mergeMediaItems(
|
||
sourceId: string,
|
||
fresh: MediaItem[]
|
||
): { items: MediaItem[]; newCount: number } {
|
||
const result = mergeItemsPreservingState(listMediaItems(sourceId), fresh)
|
||
replaceMediaItems(sourceId, result.items)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Mark one media item downloaded, recording its file path + time. Returns the
|
||
* updated list for that item's source (or [] if the id is unknown). Used by the
|
||
* library view once a queued item completes (Phase H/I).
|
||
*/
|
||
export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] {
|
||
const all = listAllItems()
|
||
const target = all.find((m) => m.id === id)
|
||
if (!target) return []
|
||
const updated = all.map((m) =>
|
||
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||
)
|
||
itemsStore.write(updated)
|
||
return updated.filter((m) => m.sourceId === target.sourceId)
|
||
}
|