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
@@ -0,0 +1,131 @@
import { useState } from 'react'
import {
Button,
Field,
SpinButton,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { DeleteRegular, ArrowUpRegular } from '@fluentui/react-icons'
import { useSources } from '../store/sources'
import { toast } from '../store/toasts'
import { SPACE } from './ui/tokens'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: SPACE.cozy,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
heading: { fontWeight: tokens.fontWeightSemibold },
controls: { display: 'flex', alignItems: 'flex-end', gap: '12px', flexWrap: 'wrap' },
spacer: { flexGrow: 1 },
muted: { color: tokens.colorNeutralForeground3 },
narrow: { width: '120px' }
})
/**
* Retention / quality-upgrade panel for one Library source (PINCHFLAT stretch).
* Two opt-in maintenance actions:
*
* - Prune: keep only the N most-recent (and/or last-D-days) downloaded files;
* delete the older ones' files (the download archive stops them silently
* re-downloading). Destructive, so it shows a dry-run count and a two-step
* confirm before any file is deleted.
* - Upgrade: re-download items that were fetched below the source's current
* target quality (from its profile or the global default).
*
* The policy inputs are ephemeral (per-open) — this is an on-demand tidy, not a
* persisted schedule, matching the roadmap's "niche for a desktop app" scope.
*/
export function SourceRetention({ sourceId }: { sourceId: string }): React.JSX.Element {
const styles = useStyles()
const prunePreview = useSources((s) => s.prunePreview)
const pruneItems = useSources((s) => s.pruneItems)
const upgradePreview = useSources((s) => s.upgradePreview)
const upgradeItems = useSources((s) => s.upgradeItems)
const [maxCount, setMaxCount] = useState(25)
const [confirmIds, setConfirmIds] = useState<string[] | null>(null)
function onPrune(): void {
const candidates = prunePreview(sourceId, { maxCount })
if (candidates.length === 0) {
toast(`Nothing to prune — the source has ${maxCount} or fewer downloaded files.`, 'info')
return
}
setConfirmIds(candidates.map((c) => c.id))
}
async function onConfirmPrune(): Promise<void> {
if (!confirmIds) return
const deleted = await pruneItems(sourceId, confirmIds)
toast(`Pruned ${deleted} file${deleted === 1 ? '' : 's'}.`, 'info')
setConfirmIds(null)
}
function onUpgrade(): void {
const candidates = upgradePreview(sourceId)
if (candidates.length === 0) {
toast('Nothing to upgrade — every downloaded video is already at the target quality.', 'info')
return
}
const n = upgradeItems(sourceId, candidates)
toast(`Re-queued ${n} video${n === 1 ? '' : 's'} to upgrade quality.`, 'info')
}
return (
<div className={styles.root}>
<Caption1 className={styles.heading}>Retention &amp; quality</Caption1>
{confirmIds ? (
<div className={styles.controls}>
<Caption1 className={styles.muted}>
Delete {confirmIds.length} older downloaded file{confirmIds.length === 1 ? '' : 's'}?
The videos stay indexed and won&apos;t re-download.
</Caption1>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={onConfirmPrune}
>
Delete {confirmIds.length}
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmIds(null)}>
Cancel
</Button>
</div>
) : (
<div className={styles.controls}>
<Field label="Keep newest">
<SpinButton
className={styles.narrow}
value={maxCount}
min={1}
max={9999}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v))
setMaxCount(Math.max(1, Math.round(v)))
}}
/>
</Field>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={onPrune}>
Prune older files
</Button>
<div className={styles.spacer} />
<Button size="small" appearance="subtle" icon={<ArrowUpRegular />} onClick={onUpgrade}>
Upgrade below-target
</Button>
</div>
)}
</div>
)
}