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:
+9
-3
@@ -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)
|
||||
|
||||
+44
-2
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user