0956bd536a
On-demand maintenance panel (SourceRetention) on the source detail — not a persisted schedule (roadmap's niche-for-desktop scope). Prune: keep the newest N downloaded files, delete the rest's files (download archive prevents silent re-download); dry-run count + two-step confirm before any deletion. Upgrade: re-enqueue items whose recorded downloadedQuality ranks below the source's current target (profile or global). Selection is pure + injected-now in @shared/retention.ts (selectPruneCandidates/selectUpgradeCandidates + qualityRank), unit- tested; pruneMediaItems (main) deletes best-effort and clears state. MediaItem.downloadedQuality now recorded through the completion path. Live-verified the panel renders. 366 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
6.7 KiB
TypeScript
170 lines
6.7 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 { 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 }
|
||
}
|