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
+21
View File
@@ -84,6 +84,8 @@ export const IpcChannels = {
sourceSetWatched: 'sources:set-watched',
/** point a source at a MediaProfile (or clear it) */
sourceSetProfile: 'sources:set-profile',
/** prune (delete files of) the given media items for a source (retention) */
sourcePruneItems: 'sources:prune-items',
/** re-index all watched sources and return the videos that are new */
sourcesSync: 'sources:sync',
/** get / set the Windows scheduled-sync task (Task Scheduler) */
@@ -150,6 +152,22 @@ export type VideoQuality = (typeof VIDEO_QUALITY_OPTIONS)[number]
export const AUDIO_QUALITY_OPTIONS = ['Best', '320 kbps', '192 kbps', '128 kbps'] as const
export type AudioQuality = (typeof AUDIO_QUALITY_OPTIONS)[number]
/**
* Rank a quality label so two can be compared for the retention quality-upgrade
* (higher = better). Works across the video and audio ladders: "Best*" tops each,
* then the numeric resolution/bitrate descends. An unknown label ranks 0 (worst),
* so it never blocks an upgrade to a known-better target. Shared so the selector
* (main) and any UI use one ordering.
*/
export function qualityRank(label: string): number {
const l = label.trim().toLowerCase()
if (l.startsWith('best')) return 100_000
// First number in the label is the resolution (1080) or bitrate (320) — both
// sort correctly as "bigger is better" within their own ladder.
const n = parseInt(l.replace(/[^\d]/g, ''), 10)
return Number.isFinite(n) ? n : 0
}
/** UI color theme: an explicit mode, or 'system' to follow the OS preference. */
export type ThemeMode = 'light' | 'dark' | 'system'
@@ -908,6 +926,9 @@ export interface MediaItem {
downloaded: boolean
downloadedAt?: number
filePath?: string
/** the quality label this item was last downloaded at, for the retention
* quality-upgrade check (undefined for items downloaded before this was tracked) */
downloadedQuality?: string
}
/** Live progress pushed while a Source is being indexed (main → renderer). */
+71
View File
@@ -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
)
}