Files
AeroFetch/src/main/sources.ts
T
debont80 e29374dcfa docs: scrub dangling references to deleted roadmap docs
The ROADMAP/ROADMAP-PINCHFLAT docs were removed; this drops the now-dead
"see ROADMAP-PINCHFLAT.md" breadcrumbs (and one "the roadmap" mention)
from source comments, preserving the surrounding Pinchflat-style context.
Comment-only; typecheck (node+web) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:16:32 -04:00

169 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Persistence for the media-manager index (Pinchflat-style). 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):
*
* 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 (R1R3 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 }
}