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,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 & 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'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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user