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>
)
}
+1
View File
@@ -262,6 +262,7 @@ export const mockApi: Api = {
setMediaItemDownloaded: async () => [],
setSourceWatched: async () => [],
setSourceProfile: async () => [],
pruneMediaItems: async () => ({ items: [], deleted: 0 }),
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
getScheduledSync: async () => ({ enabled: false }),
setScheduledSync: async (enabled: boolean) => ({ enabled }),
+1 -1
View File
@@ -19,7 +19,7 @@ export interface StoreEvents {
/** sources → downloads: enqueue a batch of media items as downloads */
enqueueDownloads: AddEntry[]
/** downloads → sources: a download tied to a source MediaItem finished */
downloadCompleted: { mediaItemId: string; filePath?: string }
downloadCompleted: { mediaItemId: string; filePath?: string; quality?: string }
/** sources → downloads: a source was removed; cancel its in-flight downloads (L157) */
sourceRemoved: string
}
+7 -1
View File
@@ -54,7 +54,13 @@ function recordCompletion(item: DownloadItem): void {
if (item.mediaItemId) {
// Tell the sources store to mark its MediaItem downloaded — via the event bus
// rather than a direct import, so downloads and sources don't form a cycle (C2).
emit('downloadCompleted', { mediaItemId: item.mediaItemId, filePath: item.filePath })
// The quality is recorded so the retention quality-upgrade check can tell
// which items were fetched below the source's current target.
emit('downloadCompleted', {
mediaItemId: item.mediaItemId,
filePath: item.filePath,
quality: item.quality
})
}
}
+56 -6
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
import { qualityRank } from '@shared/ipc'
import { resolveProfile } from '@shared/profileResolve'
import { selectPruneCandidates, selectUpgradeCandidates, type PrunePolicy } from '@shared/retention'
import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings'
import { useProfiles } from './profiles'
@@ -98,11 +100,19 @@ interface SourcesState {
*/
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
/** mark an item downloaded once its queue download completes (called by the downloads store) */
markDownloaded: (itemId: string, filePath?: string) => void
markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
/** toggle a source's watched-for-new-uploads flag (Phase J) */
setWatched: (id: string, watched: boolean) => void
/** point a source at a MediaProfile, or clear it (profileId = null) */
setProfile: (id: string, profileId: string | null) => void
/** which of a source's downloaded items a retention prune policy would remove */
prunePreview: (sourceId: string, policy: PrunePolicy) => MediaItem[]
/** prune (delete files of) the given items; resolves with how many files were deleted */
pruneItems: (sourceId: string, itemIds: string[]) => Promise<number>
/** which of a source's items were downloaded below its current target quality */
upgradePreview: (sourceId: string) => MediaItem[]
/** re-enqueue the below-target items for a source to upgrade their quality */
upgradeItems: (sourceId: string, items: MediaItem[]) => number
/** whether a sync is currently running (the "Check for new" button's busy state) */
syncing: boolean
/**
@@ -290,7 +300,7 @@ export const useSources = create<SourcesState>((set, get) => ({
return items.length
},
markDownloaded: (itemId, filePath) => {
markDownloaded: (itemId, filePath, quality) => {
// Which loaded source holds this item — also the reconcile key, so overlapping
// completions in different sources never suppress each other's response.
const owner = Object.entries(get().itemsBySource).find(([, list]) =>
@@ -303,7 +313,15 @@ export const useSources = create<SourcesState>((set, get) => ({
if (list.some((m) => m.id === itemId)) {
changed = true
next[sid] = list.map((m) =>
m.id === itemId ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
m.id === itemId
? {
...m,
downloaded: true,
downloadedAt: Date.now(),
filePath,
downloadedQuality: quality
}
: m
)
} else {
next[sid] = list
@@ -314,7 +332,7 @@ export const useSources = create<SourcesState>((set, get) => ({
if (!PREVIEW)
reconcile(
`items:${owner ?? itemId}`,
window.api.setMediaItemDownloaded(itemId, filePath),
window.api.setMediaItemDownloaded(itemId, filePath, quality),
(items) => {
// Main returns the updated list for the item's source, or [] when the id
// is unknown there (e.g. a queue item without a persisted MediaItem).
@@ -349,6 +367,38 @@ export const useSources = create<SourcesState>((set, get) => ({
)
},
prunePreview: (sourceId, policy) =>
selectPruneCandidates(get().itemsBySource[sourceId] ?? [], policy, Date.now()),
pruneItems: async (sourceId, itemIds) => {
if (PREVIEW || itemIds.length === 0) return 0
const res = await window.api.pruneMediaItems(sourceId, itemIds)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: res.items } }))
return res.deleted
},
upgradePreview: (sourceId) => {
const src = get().sources.find((s) => s.id === sourceId)
const settings = useSettings.getState()
const profile = src?.profileId
? (useProfiles.getState().profiles.find((p) => p.id === src.profileId) ?? null)
: null
const resolved = resolveProfile(profile, {
kind: settings.defaultKind,
videoQuality: settings.defaultVideoQuality,
audioQuality: settings.defaultAudioQuality,
options: settings.downloadOptions,
outputTemplate: settings.collectionOutputTemplate
})
return selectUpgradeCandidates(
get().itemsBySource[sourceId] ?? [],
resolved.quality,
qualityRank
)
},
upgradeItems: (sourceId, items) => get().enqueueItems(sourceId, items),
syncWatched: async () => {
if (get().syncing) return 0
set({ syncing: true })
@@ -409,8 +459,8 @@ export const useSources = create<SourcesState>((set, get) => ({
// When a download tied to one of our MediaItems finishes, the downloads store
// emits this on the bus (C2) — mark the item downloaded so the library reflects it.
on('downloadCompleted', ({ mediaItemId, filePath }) =>
useSources.getState().markDownloaded(mediaItemId, filePath)
on('downloadCompleted', ({ mediaItemId, filePath, quality }) =>
useSources.getState().markDownloaded(mediaItemId, filePath, quality)
)
// Load persisted sources on startup, and subscribe to live indexing progress.
+3
View File
@@ -43,6 +43,7 @@ import { StatusChip } from '../components/ui/StatusChip'
import { SegmentedControl } from '../components/ui/SegmentedControl'
import { EmptyState } from '../components/ui/EmptyState'
import { LinkSuggestion } from '../components/ui/LinkSuggestion'
import { SourceRetention } from '../components/SourceRetention'
import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from '../components/ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from '../components/ui/tokens'
@@ -763,6 +764,8 @@ export function LibraryView(): React.JSX.Element {
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
<SourceRetention sourceId={src.id} />
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
// panel so 1000s of rows stay light. A definite height (not