feat(pinchflat): Media Profiles — named download presets per Library source

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>
This commit is contained in:
2026-07-02 16:02:07 -04:00
parent a02953f6d1
commit ed1daf8bef
21 changed files with 685 additions and 14 deletions
+19 -1
View File
@@ -7,7 +7,14 @@
*/
import { isAbsolute } from 'path'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
import type {
HistoryEntry,
ErrorLogEntry,
CommandTemplate,
MediaProfile,
Source,
MediaItem
} from '@shared/ipc'
// --- Path-traversal sanitization (audit S4) ---------------------------------
@@ -82,6 +89,17 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
return typeof t.id === 'string' || typeof t.id === 'number'
}
/**
* A persisted profiles.json row must at least be an object carrying an id; the
* rest is coerced by profiles.ts's sanitize(), so a weak shape check plus
* normalisation there is enough (same approach as isTemplateLike).
*/
export function isProfileLike(o: unknown): o is MediaProfile {
if (!o || typeof o !== 'object') return false
const p = o as Record<string, unknown>
return typeof p.id === 'string' || typeof p.id === 'number'
}
/** A persisted sources.json row must have the right shape or it's dropped on read. */
export function isValidSource(o: unknown): o is Source {
if (!o || typeof o !== 'object') return false