/** * 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 { existsSync, unlinkSync } from 'fs' import type { Source, MediaItem } from '@shared/ipc' import { isValidSource, isValidMediaItem } from './core/validation' import { mergeItemsPreservingState } from './core/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))) } /** * Point a source at a MediaProfile, or clear it (profileId === null). Returns * all sources. A cleared profile drops the field entirely so the record stays * clean rather than carrying an explicit undefined. */ export function setSourceProfile(id: string, profileId: string | null): Source[] { return sourcesStore.write( listSources().map((s) => { if (s.id !== id) return s const { profileId: _drop, ...rest } = s return profileId ? { ...rest, profileId } : rest }) ) } /** 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, quality?: 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, downloadedQuality: quality } : m ) itemsStore.write(updated) return updated.filter((m) => m.sourceId === target.sourceId) } /** * Prune (delete the files of) the given media items for a source, then clear * their downloaded state so the Library shows them as pending again. The * download-archive guard (Settings.useDownloadArchive) keeps a pruned video from * silently re-downloading on the next "Download new only". Returns the updated * item list for the source and how many files were actually deleted. * * File deletion is the caller's explicit, dry-run-confirmed action (the source * detail's Prune button) — never automatic. */ export function pruneMediaItems( sourceId: string, itemIds: string[] ): { items: MediaItem[]; deleted: number } { const remove = new Set(itemIds) const all = listAllItems() let deleted = 0 const updated = all.map((m) => { if (!remove.has(m.id)) return m if (m.filePath && existsSync(m.filePath)) { try { unlinkSync(m.filePath) deleted++ } catch { /* best-effort — a locked/already-gone file just stays counted as not-deleted */ } } // Clear the on-disk state; the download archive prevents a silent re-download. const { filePath: _f, downloadedAt: _a, downloadedQuality: _q, ...rest } = m return { ...rest, downloaded: false } }) itemsStore.write(updated) return { items: updated.filter((m) => m.sourceId === sourceId), deleted } }