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>
A MediaProfile bundles { kind, quality, options, outputTemplate, extraArgs }
that a Source points at via Source.profileId, overriding the global defaults
field-by-field for that source's downloads.
Backend mirrors CommandTemplate: profiles.ts (createJsonStore, PROFILES_MAX,
isProfileLike + sanitize), CRUD IPC (profiles:list/save/remove) + preload +
useProfiles store; Source.profileId + sources:set-profile. Pure resolution
in @shared/profileResolve.ts (profile → global, per field; unit-tested).
Applied in the sources-store enqueueItems; a per-download collectionOutput-
Template override threads to buildArgs so a profile's outputTemplate wins.
UI: ProfileManager (mirrors TemplateManager) in a new Settings → Advanced →
Media profiles card, plus a per-source Profile picker in the Library source
detail. Both live-verified in the preview; 354 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
writeJsonAtomic always pretty-printed (JSON.stringify(value, null, 2)), which
needlessly inflates size and write time for the media-items store (up to
MAX_ITEMS=20000 rows). Added an optional `pretty` flag (default true, so
history/sources/templates/errorlog stay hand-inspectable) threaded through
createJsonStore, and set it false for media-items.json.
typecheck + 242 tests + eslint green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M23: replaceMediaItems wrote [...newItems, ...others] and the JSON store sliced
the combined list to MAX_ITEMS, so re-indexing one large source could
silently evict ANOTHER source's tail. Now other sources are always kept in
full and only the source being (re)indexed is capped to the remaining
budget (MAX_ITEMS - others.length).
M27: enqueueItems forced settings.defaultKind for every item, so a channel
couldn't be queued as audio without flipping the global default. Added an
optional `kind` override (falls back to the default) and a video/audio
SegmentedControl in the Library action row, seeded from defaultKind so
existing behavior is unchanged until toggled.
typecheck + 242 tests + eslint green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Index an entire YouTube channel or playlist once, then download it into
organized <Channel>/<Playlist>/<NNN> - <Title> folders, with incremental
re-sync. Built in six rebuild-gated phases (F-K); see ROADMAP-PINCHFLAT.md.
- F: channel-walk indexer (/playlists + /videos) -> persisted Source +
MediaItem JSON stores; pure classify/dedup logic in indexerCore.ts
- G: Windows-safe folder paths + dir sanitizer (collectionOutputTemplate)
- H: Library tab + source tree + "Download pending" into the existing queue
- I: state-preserving re-index merge + persist-downloaded-on-complete
- J: watched sources, RSS fast-check, Task Scheduler + --sync launch
(the OS-level scheduling/RSS need a real-install smoke test)
- K: .info.json / thumbnail / .description sidecars for media servers
Also gitignore .gitea-token. 106 unit tests pass; typecheck + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>