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:
@@ -61,6 +61,8 @@ export const LOG_MAX_BYTES = 1024 * 1024
|
||||
export const ERRORLOG_MAX_ENTRIES = 200
|
||||
/** Saved custom-command templates (templates.json). */
|
||||
export const TEMPLATES_MAX = 100
|
||||
/** Saved media profiles (profiles.json). */
|
||||
export const PROFILES_MAX = 100
|
||||
/** Indexed media items across all sources (media-items.json). */
|
||||
export const MEDIA_ITEMS_MAX = 20_000
|
||||
/** Persisted download-queue rows (queue.json, M4) — generous; a whole channel can be queued. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,7 +209,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
||||
outDir,
|
||||
opts.collection,
|
||||
'%(title)s.%(ext)s',
|
||||
settings.collectionOutputTemplate
|
||||
// A per-download override (a Media Profile's outputTemplate) wins over the
|
||||
// global collection-layout setting.
|
||||
opts.collectionOutputTemplate ?? settings.collectionOutputTemplate
|
||||
)
|
||||
: join(outDir, filenameTemplate)
|
||||
// Per-download override wins; otherwise use the persisted defaults.
|
||||
|
||||
+11
-1
@@ -19,6 +19,7 @@ import {
|
||||
type Settings,
|
||||
type HistoryEntry,
|
||||
type CommandTemplate,
|
||||
type MediaProfile,
|
||||
type YtdlpUpdateChannel,
|
||||
type SystemThemeInfo,
|
||||
type TaskbarProgress,
|
||||
@@ -35,6 +36,7 @@ import { runTerminal, cancelTerminal } from './terminal'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { listProfiles, saveProfile, removeProfile } from './profiles'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||
@@ -46,7 +48,8 @@ import {
|
||||
removeSource,
|
||||
listMediaItems,
|
||||
setMediaItemDownloaded,
|
||||
setSourceWatched
|
||||
setSourceWatched,
|
||||
setSourceProfile
|
||||
} from './sources'
|
||||
import { indexSourceCancelable, cancelIndexing } from './indexer'
|
||||
import { syncWatchedSources } from './sync'
|
||||
@@ -213,6 +216,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
||||
)
|
||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.profilesList, () => listProfiles())
|
||||
ipcMain.handle(IpcChannels.profilesSave, (_e, profile: MediaProfile) => saveProfile(profile))
|
||||
ipcMain.handle(IpcChannels.profilesRemove, (_e, id: string) => removeProfile(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||
previewCommand(opts)
|
||||
)
|
||||
@@ -269,6 +276,9 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
||||
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||
setSourceWatched(id, watched)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) =>
|
||||
setSourceProfile(id, profileId)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||
syncWatchedSources((p) => {
|
||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MediaProfile, MediaKind, DownloadOptions } from '@shared/ipc'
|
||||
import { isProfileLike } from './core/validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
import { PROFILES_MAX } from './constants'
|
||||
|
||||
// Named download presets a Library Source can point at (PINCHFLAT Media
|
||||
// Profiles). Plain JSON in userData, same shape/pattern as templates.ts —
|
||||
// atomic writes / corruption backup / caching from the shared jsonStore (R1–R3).
|
||||
// A persisted row must at least be an object carrying an id (isProfileLike);
|
||||
// everything else is coerced by sanitize(), so a hand-edited profiles.json can't
|
||||
// inject malformed entries. (audit S5)
|
||||
const store = createJsonStore('profiles.json', isProfileLike, PROFILES_MAX)
|
||||
|
||||
const KINDS: readonly MediaKind[] = ['video', 'audio']
|
||||
|
||||
/** Normalise a persisted/incoming profile so every field is well-formed. */
|
||||
function sanitize(p: MediaProfile): MediaProfile {
|
||||
const out: MediaProfile = {
|
||||
id: String(p.id),
|
||||
name: (p.name ?? '').trim() || 'Untitled profile'
|
||||
}
|
||||
if (p.kind && KINDS.includes(p.kind)) out.kind = p.kind
|
||||
if (typeof p.quality === 'string' && p.quality.trim()) out.quality = p.quality.trim()
|
||||
// options is stored whole when present; the download path re-sanitizes it
|
||||
// through the settings schema before use, so a weak object check is enough here.
|
||||
if (p.options && typeof p.options === 'object') out.options = p.options as DownloadOptions
|
||||
if (typeof p.outputTemplate === 'string' && p.outputTemplate.trim()) {
|
||||
out.outputTemplate = p.outputTemplate.trim()
|
||||
}
|
||||
if (typeof p.extraArgs === 'string' && p.extraArgs.trim()) out.extraArgs = p.extraArgs.trim()
|
||||
return out
|
||||
}
|
||||
|
||||
export function listProfiles(): MediaProfile[] {
|
||||
return store.read().map(sanitize)
|
||||
}
|
||||
|
||||
/** Add a new profile, or update an existing one (matched by id). */
|
||||
export function saveProfile(profile: MediaProfile): MediaProfile[] {
|
||||
const clean = sanitize(profile)
|
||||
return store.write([clean, ...listProfiles().filter((p) => p.id !== clean.id)])
|
||||
}
|
||||
|
||||
export function removeProfile(id: string): MediaProfile[] {
|
||||
return store.write(listProfiles().filter((p) => p.id !== id))
|
||||
}
|
||||
@@ -51,6 +51,21 @@ export function setSourceWatched(id: string, watched: boolean): Source[] {
|
||||
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a source at a MediaProfile, or clear it (profileId === null). Returns
|
||||
* all sources. A cleared profile drops the field entirely so the record stays
|
||||
* clean rather than carrying an explicit undefined.
|
||||
*/
|
||||
export function setSourceProfile(id: string, profileId: string | null): Source[] {
|
||||
return sourcesStore.write(
|
||||
listSources().map((s) => {
|
||||
if (s.id !== id) return s
|
||||
const { profileId: _drop, ...rest } = s
|
||||
return profileId ? { ...rest, profileId } : rest
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||||
export function removeSource(id: string): Source[] {
|
||||
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
||||
|
||||
Reference in New Issue
Block a user