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:
+12
-2
@@ -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`,
|
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
|
||||||
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
||||||
can't re-download.
|
can't re-download.
|
||||||
- [ ] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade
|
- [x] **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.
|
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)
|
## Phase J — Subscriptions & scheduled auto-download ✅ COMPLETE (OS wiring needs smoke test)
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -49,7 +49,8 @@ import {
|
|||||||
listMediaItems,
|
listMediaItems,
|
||||||
setMediaItemDownloaded,
|
setMediaItemDownloaded,
|
||||||
setSourceWatched,
|
setSourceWatched,
|
||||||
setSourceProfile
|
setSourceProfile,
|
||||||
|
pruneMediaItems
|
||||||
} from './sources'
|
} from './sources'
|
||||||
import { indexSourceCancelable, cancelIndexing } from './indexer'
|
import { indexSourceCancelable, cancelIndexing } from './indexer'
|
||||||
import { syncWatchedSources } from './sync'
|
import { syncWatchedSources } from './sync'
|
||||||
@@ -254,8 +255,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
|||||||
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
||||||
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
||||||
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
||||||
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
ipcMain.handle(
|
||||||
setMediaItemDownloaded(id, filePath)
|
IpcChannels.sourceItemDownloaded,
|
||||||
|
(_e, id: string, filePath?: string, quality?: string) =>
|
||||||
|
setMediaItemDownloaded(id, filePath, quality)
|
||||||
)
|
)
|
||||||
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
||||||
// and resolves with the final result.
|
// 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) =>
|
ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) =>
|
||||||
setSourceProfile(id, profileId)
|
setSourceProfile(id, profileId)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourcePruneItems, (_e, sourceId: string, itemIds: string[]) =>
|
||||||
|
pruneMediaItems(sourceId, itemIds)
|
||||||
|
)
|
||||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||||
syncWatchedSources((p) => {
|
syncWatchedSources((p) => {
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
|||||||
+44
-2
@@ -12,6 +12,7 @@
|
|||||||
* can't feed the UI malformed records (same approach as history/errorlog).
|
* 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 type { Source, MediaItem } from '@shared/ipc'
|
||||||
import { isValidSource, isValidMediaItem } from './core/validation'
|
import { isValidSource, isValidMediaItem } from './core/validation'
|
||||||
import { mergeItemsPreservingState } from './core/indexerCore'
|
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
|
* 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).
|
* 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 all = listAllItems()
|
||||||
const target = all.find((m) => m.id === id)
|
const target = all.find((m) => m.id === id)
|
||||||
if (!target) return []
|
if (!target) return []
|
||||||
const updated = all.map((m) =>
|
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)
|
itemsStore.write(updated)
|
||||||
return updated.filter((m) => m.sourceId === target.sourceId)
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ const api = {
|
|||||||
|
|
||||||
/** Persist that a media item has finished downloading (drives incremental sync).
|
/** Persist that a media item has finished downloading (drives incremental sync).
|
||||||
* Named to match the main handler `setMediaItemDownloaded` (L92). */
|
* Named to match the main handler `setMediaItemDownloaded` (L92). */
|
||||||
setMediaItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
|
setMediaItemDownloaded: (id: string, filePath?: string, quality?: string): Promise<MediaItem[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
|
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath, quality),
|
||||||
|
|
||||||
/** Toggle whether a source is watched for new uploads. */
|
/** Toggle whether a source is watched for new uploads. */
|
||||||
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
||||||
@@ -227,6 +227,13 @@ const api = {
|
|||||||
setSourceProfile: (id: string, profileId: string | null): Promise<Source[]> =>
|
setSourceProfile: (id: string, profileId: string | null): Promise<Source[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceSetProfile, id, profileId),
|
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
|
/** Re-index all watched sources; resolves with the videos found new. Named to
|
||||||
* match the main function `syncWatchedSources` (L92). */
|
* match the main function `syncWatchedSources` (L92). */
|
||||||
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||||
|
|||||||
@@ -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 () => [],
|
setMediaItemDownloaded: async () => [],
|
||||||
setSourceWatched: async () => [],
|
setSourceWatched: async () => [],
|
||||||
setSourceProfile: async () => [],
|
setSourceProfile: async () => [],
|
||||||
|
pruneMediaItems: async () => ({ items: [], deleted: 0 }),
|
||||||
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
||||||
getScheduledSync: async () => ({ enabled: false }),
|
getScheduledSync: async () => ({ enabled: false }),
|
||||||
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface StoreEvents {
|
|||||||
/** sources → downloads: enqueue a batch of media items as downloads */
|
/** sources → downloads: enqueue a batch of media items as downloads */
|
||||||
enqueueDownloads: AddEntry[]
|
enqueueDownloads: AddEntry[]
|
||||||
/** downloads → sources: a download tied to a source MediaItem finished */
|
/** 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) */
|
/** sources → downloads: a source was removed; cancel its in-flight downloads (L157) */
|
||||||
sourceRemoved: string
|
sourceRemoved: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ function recordCompletion(item: DownloadItem): void {
|
|||||||
if (item.mediaItemId) {
|
if (item.mediaItemId) {
|
||||||
// Tell the sources store to mark its MediaItem downloaded — via the event bus
|
// 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).
|
// 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 { create } from 'zustand'
|
||||||
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
||||||
|
import { qualityRank } from '@shared/ipc'
|
||||||
import { resolveProfile } from '@shared/profileResolve'
|
import { resolveProfile } from '@shared/profileResolve'
|
||||||
|
import { selectPruneCandidates, selectUpgradeCandidates, type PrunePolicy } from '@shared/retention'
|
||||||
import { isPreview as PREVIEW } from '../isPreview'
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
import { useProfiles } from './profiles'
|
import { useProfiles } from './profiles'
|
||||||
@@ -98,11 +100,19 @@ interface SourcesState {
|
|||||||
*/
|
*/
|
||||||
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
|
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
|
||||||
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
/** 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) */
|
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||||
setWatched: (id: string, watched: boolean) => void
|
setWatched: (id: string, watched: boolean) => void
|
||||||
/** point a source at a MediaProfile, or clear it (profileId = null) */
|
/** point a source at a MediaProfile, or clear it (profileId = null) */
|
||||||
setProfile: (id: string, profileId: string | null) => void
|
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) */
|
/** whether a sync is currently running (the "Check for new" button's busy state) */
|
||||||
syncing: boolean
|
syncing: boolean
|
||||||
/**
|
/**
|
||||||
@@ -290,7 +300,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
return items.length
|
return items.length
|
||||||
},
|
},
|
||||||
|
|
||||||
markDownloaded: (itemId, filePath) => {
|
markDownloaded: (itemId, filePath, quality) => {
|
||||||
// Which loaded source holds this item — also the reconcile key, so overlapping
|
// Which loaded source holds this item — also the reconcile key, so overlapping
|
||||||
// completions in different sources never suppress each other's response.
|
// completions in different sources never suppress each other's response.
|
||||||
const owner = Object.entries(get().itemsBySource).find(([, list]) =>
|
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)) {
|
if (list.some((m) => m.id === itemId)) {
|
||||||
changed = true
|
changed = true
|
||||||
next[sid] = list.map((m) =>
|
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 {
|
} else {
|
||||||
next[sid] = list
|
next[sid] = list
|
||||||
@@ -314,7 +332,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
if (!PREVIEW)
|
if (!PREVIEW)
|
||||||
reconcile(
|
reconcile(
|
||||||
`items:${owner ?? itemId}`,
|
`items:${owner ?? itemId}`,
|
||||||
window.api.setMediaItemDownloaded(itemId, filePath),
|
window.api.setMediaItemDownloaded(itemId, filePath, quality),
|
||||||
(items) => {
|
(items) => {
|
||||||
// Main returns the updated list for the item's source, or [] when the id
|
// 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).
|
// 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 () => {
|
syncWatched: async () => {
|
||||||
if (get().syncing) return 0
|
if (get().syncing) return 0
|
||||||
set({ syncing: true })
|
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
|
// 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.
|
// emits this on the bus (C2) — mark the item downloaded so the library reflects it.
|
||||||
on('downloadCompleted', ({ mediaItemId, filePath }) =>
|
on('downloadCompleted', ({ mediaItemId, filePath, quality }) =>
|
||||||
useSources.getState().markDownloaded(mediaItemId, filePath)
|
useSources.getState().markDownloaded(mediaItemId, filePath, quality)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load persisted sources on startup, and subscribe to live indexing progress.
|
// 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 { SegmentedControl } from '../components/ui/SegmentedControl'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
import { LinkSuggestion } from '../components/ui/LinkSuggestion'
|
import { LinkSuggestion } from '../components/ui/LinkSuggestion'
|
||||||
|
import { SourceRetention } from '../components/SourceRetention'
|
||||||
import { useFocusStyles } from '../components/ui/focusRing'
|
import { useFocusStyles } from '../components/ui/focusRing'
|
||||||
import { useTextStyles } from '../components/ui/text'
|
import { useTextStyles } from '../components/ui/text'
|
||||||
import { SPACE, RADIUS, ICON, META_SEP } from '../components/ui/tokens'
|
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>}
|
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
|
||||||
|
|
||||||
|
<SourceRetention sourceId={src.id} />
|
||||||
|
|
||||||
{flatRows.length > VIRTUALIZE_AT ? (
|
{flatRows.length > VIRTUALIZE_AT ? (
|
||||||
// Big source: a fixed-height, internally-scrolling virtualized
|
// Big source: a fixed-height, internally-scrolling virtualized
|
||||||
// panel so 1000s of rows stay light. A definite height (not
|
// panel so 1000s of rows stay light. A definite height (not
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const IpcChannels = {
|
|||||||
sourceSetWatched: 'sources:set-watched',
|
sourceSetWatched: 'sources:set-watched',
|
||||||
/** point a source at a MediaProfile (or clear it) */
|
/** point a source at a MediaProfile (or clear it) */
|
||||||
sourceSetProfile: 'sources:set-profile',
|
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 */
|
/** re-index all watched sources and return the videos that are new */
|
||||||
sourcesSync: 'sources:sync',
|
sourcesSync: 'sources:sync',
|
||||||
/** get / set the Windows scheduled-sync task (Task Scheduler) */
|
/** 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 const AUDIO_QUALITY_OPTIONS = ['Best', '320 kbps', '192 kbps', '128 kbps'] as const
|
||||||
export type AudioQuality = (typeof AUDIO_QUALITY_OPTIONS)[number]
|
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. */
|
/** UI color theme: an explicit mode, or 'system' to follow the OS preference. */
|
||||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
@@ -908,6 +926,9 @@ export interface MediaItem {
|
|||||||
downloaded: boolean
|
downloaded: boolean
|
||||||
downloadedAt?: number
|
downloadedAt?: number
|
||||||
filePath?: string
|
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). */
|
/** Live progress pushed while a Source is being indexed (main → renderer). */
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>): 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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user