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
+9 -3
View File
@@ -49,7 +49,8 @@ import {
listMediaItems,
setMediaItemDownloaded,
setSourceWatched,
setSourceProfile
setSourceProfile,
pruneMediaItems
} from './sources'
import { indexSourceCancelable, cancelIndexing } from './indexer'
import { syncWatchedSources } from './sync'
@@ -254,8 +255,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
setMediaItemDownloaded(id, filePath)
ipcMain.handle(
IpcChannels.sourceItemDownloaded,
(_e, id: string, filePath?: string, quality?: string) =>
setMediaItemDownloaded(id, filePath, quality)
)
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
@@ -279,6 +282,9 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) =>
setSourceProfile(id, profileId)
)
ipcMain.handle(IpcChannels.sourcePruneItems, (_e, sourceId: string, itemIds: string[]) =>
pruneMediaItems(sourceId, itemIds)
)
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
syncWatchedSources((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
+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 }
}