36699531cf
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
113 lines
4.8 KiB
TypeScript
113 lines
4.8 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 (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)
|
||
}
|