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)