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
+37
View File
@@ -42,6 +42,10 @@ export const IpcChannels = {
templatesList: 'templates:list',
templatesSave: 'templates:save',
templatesRemove: 'templates:remove',
/** Media profiles: named download presets a Source can point at (PINCHFLAT) */
profilesList: 'profiles:list',
profilesSave: 'profiles:save',
profilesRemove: 'profiles:remove',
commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update',
/** main → renderer push channel for background yt-dlp auto-update status */
@@ -78,6 +82,8 @@ export const IpcChannels = {
sourceItemDownloaded: 'sources:item-downloaded',
/** toggle whether a source is watched for new uploads (Phase J) */
sourceSetWatched: 'sources:set-watched',
/** point a source at a MediaProfile (or clear it) */
sourceSetProfile: 'sources:set-profile',
/** re-index all watched sources and return the videos that are new */
sourcesSync: 'sources:sync',
/** get / set the Windows scheduled-sync task (Task Scheduler) */
@@ -476,6 +482,12 @@ export interface StartDownloadOptions {
* filenameTemplate. See CollectionContext.
*/
collection?: CollectionContext
/**
* Per-download collection folder-layout override (a Media Profile's
* `outputTemplate`). When set, it overrides `Settings.collectionOutputTemplate`
* for this collection download; omit to fall back to the global setting.
*/
collectionOutputTemplate?: string
}
export interface StartDownloadResult {
@@ -847,6 +859,31 @@ export interface Source {
watched?: boolean
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
feedUrl?: string
/** id of the MediaProfile applied to this source's downloads; undefined = global defaults */
profileId?: string
}
/**
* A named download preset a Library Source can point at (PINCHFLAT "Media
* Profile"): a reusable bundle of the per-download choices that would otherwise
* come from the global defaults — kind, quality, post-processing options, the
* collection folder layout, and extra yt-dlp args. A Source with a `profileId`
* resolves those fields from the profile first, then falls back to the global
* settings for any field the profile leaves unset (see resolveProfile).
*/
export interface MediaProfile {
id: string
name: string
/** override the download kind (video/audio); unset = global default */
kind?: MediaKind
/** override the quality label ('1080p', 'Best', …); unset = global default */
quality?: string
/** override post-processing options; unset = global default */
options?: DownloadOptions
/** override the collection folder layout (raw yt-dlp -o template); unset = global default */
outputTemplate?: string
/** extra yt-dlp args appended to the argv (like a CommandTemplate's args); unset = none */
extraArgs?: string
}
/**
+51
View File
@@ -0,0 +1,51 @@
import type { MediaProfile, MediaKind, DownloadOptions } from './ipc'
/**
* The global defaults a Media Profile can override — exactly the fields a
* collection download would otherwise pull from Settings.
*/
export interface ProfileDefaults {
kind: MediaKind
videoQuality: string
audioQuality: string
options: DownloadOptions
/** Settings.collectionOutputTemplate — the global collection folder layout */
outputTemplate: string
}
/** The resolved per-download values a Source's profile (or the globals) produce. */
export interface ResolvedProfile {
kind: MediaKind
quality: string
options: DownloadOptions
/** collection folder layout ('' = the built-in default), or the profile override */
outputTemplate: string
/** extra yt-dlp args from the profile ('' when none) */
extraArgs: string
}
/**
* Resolve a download's effective settings for a Library source: **profile →
* global default**, field by field. A `null`/`undefined` profile (source not
* pointed at one, or a dangling id) yields the pure global defaults. The quality
* default follows the resolved kind, so an audio-kind profile without its own
* quality picks up the global AUDIO default (not video).
*
* Lives in `@shared` because both the renderer's enqueue path (which needs the
* resolved kind/quality for the queued item) and any main-side use share this
* one precedence rule. Pure — unit-tested in test/profileResolve.test.ts.
*/
export function resolveProfile(
profile: MediaProfile | null | undefined,
defaults: ProfileDefaults
): ResolvedProfile {
const kind = profile?.kind ?? defaults.kind
const globalQuality = kind === 'audio' ? defaults.audioQuality : defaults.videoQuality
return {
kind,
quality: profile?.quality ?? globalQuality,
options: profile?.options ?? defaults.options,
outputTemplate: profile?.outputTemplate ?? defaults.outputTemplate,
extraArgs: profile?.extraArgs ?? ''
}
}