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:
@@ -0,0 +1,71 @@
|
||||
import type { MediaItem } from './ipc'
|
||||
|
||||
/**
|
||||
* Retention / quality-upgrade selection (PINCHFLAT stretch). Pure so the choice
|
||||
* of which items to prune or re-download is unit-tested with an injected `now`
|
||||
* and no filesystem — the impure half (deleting files, re-enqueuing) lives in
|
||||
* the caller.
|
||||
*/
|
||||
|
||||
export interface PrunePolicy {
|
||||
/** keep only items downloaded within the last N days; older ones are pruned */
|
||||
maxAgeDays?: number
|
||||
/** keep only the N most-recently-downloaded items; older ones are pruned */
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Which downloaded items a prune policy would remove. An item is a candidate
|
||||
* when it's downloaded, has a real file on disk to delete, AND falls outside
|
||||
* EITHER limit (age or count) — the two combine as "keep if it passes both".
|
||||
* Ordered newest-first internally so `maxCount` keeps the freshest.
|
||||
*
|
||||
* Returns the items to prune (delete file + clear downloaded state). Never
|
||||
* touches un-downloaded items or ones with no filePath. Both limits omitted =
|
||||
* nothing pruned (retention off).
|
||||
*/
|
||||
export function selectPruneCandidates(
|
||||
items: MediaItem[],
|
||||
policy: PrunePolicy,
|
||||
now: number
|
||||
): MediaItem[] {
|
||||
const { maxAgeDays, maxCount } = policy
|
||||
if (maxAgeDays == null && maxCount == null) return []
|
||||
|
||||
// Only downloaded items with a file are prunable; newest-first for the count cut.
|
||||
const prunable = items
|
||||
.filter((i) => i.downloaded && i.filePath && typeof i.downloadedAt === 'number')
|
||||
.sort((a, b) => (b.downloadedAt ?? 0) - (a.downloadedAt ?? 0))
|
||||
|
||||
const ageCutoff = maxAgeDays != null ? now - maxAgeDays * 86_400_000 : null
|
||||
|
||||
return prunable.filter((item, index) => {
|
||||
const tooOld = ageCutoff != null && (item.downloadedAt ?? 0) < ageCutoff
|
||||
const overCount = maxCount != null && index >= maxCount
|
||||
return tooOld || overCount
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which downloaded items were fetched at a quality BELOW the source's current
|
||||
* target, so they're worth re-downloading to upgrade. `qualityRank` maps a
|
||||
* quality label to a comparable number (higher = better); an item ranks below
|
||||
* target when its recorded `downloadedQuality` ranks lower than `targetQuality`.
|
||||
*
|
||||
* An item with no recorded `downloadedQuality` (downloaded before this was
|
||||
* tracked) is NOT selected — we can't prove it's below target, and re-downloading
|
||||
* on a guess would churn. Only downloaded items are considered.
|
||||
*/
|
||||
export function selectUpgradeCandidates(
|
||||
items: MediaItem[],
|
||||
targetQuality: string,
|
||||
qualityRank: (label: string) => number
|
||||
): MediaItem[] {
|
||||
const target = qualityRank(targetQuality)
|
||||
return items.filter(
|
||||
(i) =>
|
||||
i.downloaded &&
|
||||
typeof i.downloadedQuality === 'string' &&
|
||||
qualityRank(i.downloadedQuality) < target
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user