diff --git a/ROADMAP-PINCHFLAT.md b/ROADMAP-PINCHFLAT.md index a9f56af..7ebc0f4 100644 --- a/ROADMAP-PINCHFLAT.md +++ b/ROADMAP-PINCHFLAT.md @@ -199,8 +199,18 @@ Re-running a channel should grab only what's new — the everyday media-manager backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`, `getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index can't re-download. -- [ ] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade - quality/metadata, and optional pruning of old items. Defer — niche for a desktop app. +- [x] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade + quality/metadata, and optional pruning of old items. Implemented as an on-demand per-source + maintenance panel (`SourceRetention` on the Library source detail), not a persisted schedule + (matching the "niche for a desktop app" scope). **Prune:** keep the newest N downloaded files, + delete the older ones' files (the download archive stops them silently re-downloading); shows a + dry-run count and a two-step confirm before any deletion. **Upgrade:** re-enqueue items whose + recorded `downloadedQuality` ranks below the source's current target (its profile's quality or + the global default). The selection logic is pure + injected-now (`@shared/retention.ts`: + `selectPruneCandidates`/`selectUpgradeCandidates`, `qualityRank` in `@shared/ipc`), unit-tested + in `test/retention.test.ts`; the impure prune (`pruneMediaItems` in `src/main/sources.ts`) + deletes files best-effort and clears `downloaded`. `MediaItem.downloadedQuality` is now recorded + through the completion path. Live-verified the panel renders on an expanded source. ## Phase J — Subscriptions & scheduled auto-download ✅ COMPLETE (OS wiring needs smoke test) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a3f11e7..8ff528d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -49,7 +49,8 @@ import { listMediaItems, setMediaItemDownloaded, setSourceWatched, - setSourceProfile + setSourceProfile, + pruneMediaItems } from './sources' import { indexSourceCancelable, cancelIndexing } from './indexer' import { syncWatchedSources } from './sync' @@ -254,8 +255,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): ipcMain.handle(IpcChannels.sourcesList, () => listSources()) ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId)) ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id)) - ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) => - setMediaItemDownloaded(id, filePath) + ipcMain.handle( + IpcChannels.sourceItemDownloaded, + (_e, id: string, filePath?: string, quality?: string) => + setMediaItemDownloaded(id, filePath, quality) ) // Indexing pushes live progress to the requesting renderer over `indexProgress` // and resolves with the final result. @@ -279,6 +282,9 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) => setSourceProfile(id, profileId) ) + ipcMain.handle(IpcChannels.sourcePruneItems, (_e, sourceId: string, itemIds: string[]) => + pruneMediaItems(sourceId, itemIds) + ) ipcMain.handle(IpcChannels.sourcesSync, (e) => syncWatchedSources((p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) diff --git a/src/main/sources.ts b/src/main/sources.ts index e7ae718..9477a77 100644 --- a/src/main/sources.ts +++ b/src/main/sources.ts @@ -12,6 +12,7 @@ * can't feed the UI malformed records (same approach as history/errorlog). */ +import { existsSync, unlinkSync } from 'fs' import type { Source, MediaItem } from '@shared/ipc' import { isValidSource, isValidMediaItem } from './core/validation' import { mergeItemsPreservingState } from './core/indexerCore' @@ -115,13 +116,54 @@ export function mergeMediaItems( * updated list for that item's source (or [] if the id is unknown). Used by the * library view once a queued item completes (Phase H/I). */ -export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] { +export function setMediaItemDownloaded( + id: string, + filePath?: string, + quality?: string +): MediaItem[] { const all = listAllItems() const target = all.find((m) => m.id === id) if (!target) return [] const updated = all.map((m) => - m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m + m.id === id + ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath, downloadedQuality: quality } + : m ) itemsStore.write(updated) return updated.filter((m) => m.sourceId === target.sourceId) } + +/** + * Prune (delete the files of) the given media items for a source, then clear + * their downloaded state so the Library shows them as pending again. The + * download-archive guard (Settings.useDownloadArchive) keeps a pruned video from + * silently re-downloading on the next "Download new only". Returns the updated + * item list for the source and how many files were actually deleted. + * + * File deletion is the caller's explicit, dry-run-confirmed action (the source + * detail's Prune button) — never automatic. + */ +export function pruneMediaItems( + sourceId: string, + itemIds: string[] +): { items: MediaItem[]; deleted: number } { + const remove = new Set(itemIds) + const all = listAllItems() + let deleted = 0 + const updated = all.map((m) => { + if (!remove.has(m.id)) return m + if (m.filePath && existsSync(m.filePath)) { + try { + unlinkSync(m.filePath) + deleted++ + } catch { + /* best-effort — a locked/already-gone file just stays counted as not-deleted */ + } + } + // Clear the on-disk state; the download archive prevents a silent re-download. + const { filePath: _f, downloadedAt: _a, downloadedQuality: _q, ...rest } = m + return { ...rest, downloaded: false } + }) + itemsStore.write(updated) + return { items: updated.filter((m) => m.sourceId === sourceId), deleted } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index dfb4604..882db28 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -216,8 +216,8 @@ const api = { /** Persist that a media item has finished downloading (drives incremental sync). * Named to match the main handler `setMediaItemDownloaded` (L92). */ - setMediaItemDownloaded: (id: string, filePath?: string): Promise => - ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath), + setMediaItemDownloaded: (id: string, filePath?: string, quality?: string): Promise => + ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath, quality), /** Toggle whether a source is watched for new uploads. */ setSourceWatched: (id: string, watched: boolean): Promise => @@ -227,6 +227,13 @@ const api = { setSourceProfile: (id: string, profileId: string | null): Promise => ipcRenderer.invoke(IpcChannels.sourceSetProfile, id, profileId), + /** Prune (delete files of) the given media items; returns the source's updated list + count. */ + pruneMediaItems: ( + sourceId: string, + itemIds: string[] + ): Promise<{ items: MediaItem[]; deleted: number }> => + ipcRenderer.invoke(IpcChannels.sourcePruneItems, sourceId, itemIds), + /** Re-index all watched sources; resolves with the videos found new. Named to * match the main function `syncWatchedSources` (L92). */ syncWatchedSources: (): Promise => ipcRenderer.invoke(IpcChannels.sourcesSync), diff --git a/src/renderer/src/components/SourceRetention.tsx b/src/renderer/src/components/SourceRetention.tsx new file mode 100644 index 0000000..a287641 --- /dev/null +++ b/src/renderer/src/components/SourceRetention.tsx @@ -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(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 { + 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 ( +
+ Retention & quality + {confirmIds ? ( +
+ + Delete {confirmIds.length} older downloaded file{confirmIds.length === 1 ? '' : 's'}? + The videos stay indexed and won't re-download. + +
+ + +
+ ) : ( +
+ + { + const v = d.value ?? Number(d.displayValue) + if (typeof v === 'number' && Number.isFinite(v)) + setMaxCount(Math.max(1, Math.round(v))) + }} + /> + + +
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 0af45ed..61e1a41 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -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 }), diff --git a/src/renderer/src/store/coordinator.ts b/src/renderer/src/store/coordinator.ts index 429db20..33d349c 100644 --- a/src/renderer/src/store/coordinator.ts +++ b/src/renderer/src/store/coordinator.ts @@ -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 } diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 44cab3a..f5cbecc 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -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 + }) } } diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index cc52586..401f36d 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -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 + /** 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((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((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((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((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((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. diff --git a/src/renderer/src/views/LibraryView.tsx b/src/renderer/src/views/LibraryView.tsx index 83b5708..40914bf 100644 --- a/src/renderer/src/views/LibraryView.tsx +++ b/src/renderer/src/views/LibraryView.tsx @@ -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 && {batchNote}} + + {flatRows.length > VIRTUALIZE_AT ? ( // Big source: a fixed-height, internally-scrolling virtualized // panel so 1000s of rows stay light. A definite height (not diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 0ec3e86..33d6492 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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). */ diff --git a/src/shared/retention.ts b/src/shared/retention.ts new file mode 100644 index 0000000..c3c4193 --- /dev/null +++ b/src/shared/retention.ts @@ -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 + ) +} diff --git a/test/retention.test.ts b/test/retention.test.ts new file mode 100644 index 0000000..280053f --- /dev/null +++ b/test/retention.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { selectPruneCandidates, selectUpgradeCandidates } from '../src/shared/retention' +import { qualityRank, type MediaItem } from '@shared/ipc' + +const DAY = 86_400_000 +const NOW = 1_700_000_000_000 + +function item(over: Partial): MediaItem { + return { + id: over.id ?? 'x', + sourceId: 's', + videoId: over.id ?? 'x', + title: over.title ?? 'V', + url: `https://y/${over.id ?? 'x'}`, + playlistTitle: 'Uploads', + playlistIndex: 1, + downloaded: true, + downloadedAt: NOW, + filePath: `C:/out/${over.id ?? 'x'}.mp4`, + ...over + } +} + +describe('selectPruneCandidates (retention)', () => { + it('no policy → nothing pruned', () => { + const items = [item({ id: 'a', downloadedAt: NOW - 100 * DAY })] + expect(selectPruneCandidates(items, {}, NOW)).toEqual([]) + }) + + it('maxAgeDays prunes items older than the cutoff', () => { + const items = [ + item({ id: 'fresh', downloadedAt: NOW - 5 * DAY }), + item({ id: 'old', downloadedAt: NOW - 40 * DAY }) + ] + const pruned = selectPruneCandidates(items, { maxAgeDays: 30 }, NOW).map((i) => i.id) + expect(pruned).toEqual(['old']) + }) + + it('maxCount keeps the N most-recent, prunes the rest', () => { + const items = [ + item({ id: 'a', downloadedAt: NOW - 1 * DAY }), + item({ id: 'b', downloadedAt: NOW - 2 * DAY }), + item({ id: 'c', downloadedAt: NOW - 3 * DAY }), + item({ id: 'd', downloadedAt: NOW - 4 * DAY }) + ] + const pruned = selectPruneCandidates(items, { maxCount: 2 }, NOW) + .map((i) => i.id) + .sort() + expect(pruned).toEqual(['c', 'd']) + }) + + it('age + count combine — an item over EITHER limit is pruned', () => { + const items = [ + item({ id: 'a', downloadedAt: NOW - 1 * DAY }), // within both + item({ id: 'b', downloadedAt: NOW - 2 * DAY }), // within age, over count(1) + item({ id: 'c', downloadedAt: NOW - 40 * DAY }) // over age + ] + const pruned = selectPruneCandidates(items, { maxAgeDays: 30, maxCount: 1 }, NOW) + .map((i) => i.id) + .sort() + expect(pruned).toEqual(['b', 'c']) + }) + + it('never prunes un-downloaded items or ones without a filePath', () => { + const items = [ + item({ id: 'pending', downloaded: false, filePath: undefined, downloadedAt: undefined }), + item({ id: 'nofile', filePath: undefined }), + item({ id: 'real', downloadedAt: NOW - 99 * DAY }) + ] + expect(selectPruneCandidates(items, { maxAgeDays: 30 }, NOW).map((i) => i.id)).toEqual(['real']) + }) +}) + +describe('selectUpgradeCandidates (retention)', () => { + it('selects items downloaded below the target quality', () => { + const items = [ + item({ id: 'lo', downloadedQuality: '480p' }), + item({ id: 'ok', downloadedQuality: '1080p' }), + item({ id: 'hi', downloadedQuality: 'Best available' }) + ] + const up = selectUpgradeCandidates(items, '1080p', qualityRank).map((i) => i.id) + expect(up).toEqual(['lo']) + }) + + it('skips items with no recorded downloadedQuality (unknown, not re-churned)', () => { + const items = [ + item({ id: 'unknown', downloadedQuality: undefined }), + item({ id: 'lo', downloadedQuality: '360p' }) + ] + expect(selectUpgradeCandidates(items, '720p', qualityRank).map((i) => i.id)).toEqual(['lo']) + }) + + it('skips un-downloaded items', () => { + const items = [item({ id: 'pending', downloaded: false, downloadedQuality: '360p' })] + expect(selectUpgradeCandidates(items, 'Best available', qualityRank)).toEqual([]) + }) + + it('a "Best" target upgrades anything not already Best', () => { + const items = [ + item({ id: 'a', downloadedQuality: '1080p' }), + item({ id: 'b', downloadedQuality: 'Best available' }) + ] + expect(selectUpgradeCandidates(items, 'Best', qualityRank).map((i) => i.id)).toEqual(['a']) + }) +}) + +describe('qualityRank', () => { + it('ranks Best above all numeric qualities', () => { + expect(qualityRank('Best available')).toBeGreaterThan(qualityRank('1080p')) + expect(qualityRank('Best')).toBeGreaterThan(qualityRank('320 kbps')) + }) + + it('orders numeric qualities bigger-is-better', () => { + expect(qualityRank('1080p')).toBeGreaterThan(qualityRank('720p')) + expect(qualityRank('320 kbps')).toBeGreaterThan(qualityRank('128 kbps')) + }) + + it('unknown labels rank lowest', () => { + expect(qualityRank('mystery')).toBe(0) + expect(qualityRank('mystery')).toBeLessThan(qualityRank('360p')) + }) +})