feat(pinchflat): Retention & quality-upgrade per Library source

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>
This commit is contained in:
2026-07-02 16:14:23 -04:00
parent 15cc96dd10
commit 0956bd536a
13 changed files with 487 additions and 17 deletions
+44 -2
View File
@@ -12,6 +12,7 @@
* 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'
@@ -115,13 +116,54 @@ export function mergeMediaItems(
* 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[] {
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 } : 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 }
}