/** * 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 (MEDIA_ITEMS_MAX, see constants.ts) 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). import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants' // 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) // Compact (not pretty) -- this store can hold up to MAX_ITEMS rows; indentation // would needlessly inflate its size and write time (R8). const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS, false) // --- 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 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) const budget = Math.max(0, MAX_ITEMS - others.length) itemsStore.write([...items.slice(0, budget), ...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) }