/** * 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) }