From ed1daf8bef905e5526d663d9b55c428904ac8d7a Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 2 Jul 2026 16:02:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(pinchflat):=20Media=20Profiles=20=E2=80=94?= =?UTF-8?q?=20named=20download=20presets=20per=20Library=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ROADMAP-PINCHFLAT.md | 16 +- src/main/constants.ts | 2 + src/main/core/validation.ts | 20 +- src/main/download.ts | 4 +- src/main/ipc.ts | 12 +- src/main/profiles.ts | 46 ++++ src/main/sources.ts | 15 ++ src/preload/index.ts | 13 + .../src/components/ProfileManager.tsx | 228 ++++++++++++++++++ src/renderer/src/mockApi.ts | 22 +- src/renderer/src/store/downloadItem.ts | 1 + src/renderer/src/store/downloadTypes.ts | 3 + src/renderer/src/store/downloads.ts | 3 +- src/renderer/src/store/profiles.ts | 63 +++++ src/renderer/src/store/sources.ts | 46 +++- src/renderer/src/views/LibraryView.tsx | 18 ++ src/renderer/src/views/SettingsView.tsx | 3 +- .../src/views/settings/MediaProfilesCard.tsx | 29 +++ src/shared/ipc.ts | 37 +++ src/shared/profileResolve.ts | 51 ++++ test/profileResolve.test.ts | 67 +++++ 21 files changed, 685 insertions(+), 14 deletions(-) create mode 100644 src/main/profiles.ts create mode 100644 src/renderer/src/components/ProfileManager.tsx create mode 100644 src/renderer/src/store/profiles.ts create mode 100644 src/renderer/src/views/settings/MediaProfilesCard.tsx create mode 100644 src/shared/profileResolve.ts create mode 100644 test/profileResolve.test.ts diff --git a/ROADMAP-PINCHFLAT.md b/ROADMAP-PINCHFLAT.md index 2923c03..a9f56af 100644 --- a/ROADMAP-PINCHFLAT.md +++ b/ROADMAP-PINCHFLAT.md @@ -155,11 +155,17 @@ Turn a `MediaItem` into a real per-item download that lands in `Channel / Playli to `Channel / Playlist / Title`, with the raw yt-dlp `-o` template exposed for power users (parallels how Phase C exposed raw extra args). Keeps the existing flat `filenameTemplate` as the default for non-collection downloads. -- [ ] **"Media Profile" = named download preset (optional sugar).** Pinchflat bundles - quality + subs + output template into a reusable Profile. AeroFetch already persists - `DownloadOptions` and named `CommandTemplate`s; a Profile is just a named bundle of - `{ DownloadOptions, outputTemplate, extraArgs }` a Source can point at. Worth it once - multiple sources want different rules; skip for v1 (use the global defaults). +- [x] **"Media Profile" = named download preset.** Pinchflat bundles quality + subs + output + template into a reusable Profile. Implemented: a `MediaProfile` = named bundle of + `{ kind, quality, options, outputTemplate, extraArgs }` a Source points at via `Source.profileId`. + Backend mirrors the `CommandTemplate` precedent — `src/main/profiles.ts` on `createJsonStore` + (`profiles.json`, `PROFILES_MAX` cap, `isProfileLike` guard + `sanitize()`), CRUD IPC + (`profiles:list/save/remove`) + preload + a `useProfiles` renderer store. Resolution is the pure + `resolveProfile` in `@shared/profileResolve.ts` (profile → global default, field by field; unit-tested + in `test/profileResolve.test.ts`), applied in the sources store's `enqueueItems` so a source's + downloads follow its profile. UI: a `ProfileManager` (mirrors `TemplateManager`) in a new Settings → + Advanced → **Media profiles** card, plus a per-source **Profile** picker on the Library source detail. + Live-verified both surfaces render in the preview. ## Phase H — Library view (the media-manager UI) ✅ COMPLETE diff --git a/src/main/constants.ts b/src/main/constants.ts index fbae5fe..c64867a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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. */ diff --git a/src/main/core/validation.ts b/src/main/core/validation.ts index d9c2406..691aed9 100644 --- a/src/main/core/validation.ts +++ b/src/main/core/validation.ts @@ -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 + 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 diff --git a/src/main/download.ts b/src/main/download.ts index 933ea53..ba86508 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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. diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bfa85c4..a3f11e7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) diff --git a/src/main/profiles.ts b/src/main/profiles.ts new file mode 100644 index 0000000..172c9f9 --- /dev/null +++ b/src/main/profiles.ts @@ -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)) +} diff --git a/src/main/sources.ts b/src/main/sources.ts index a8929a8..e7ae718 100644 --- a/src/main/sources.ts +++ b/src/main/sources.ts @@ -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)) diff --git a/src/preload/index.ts b/src/preload/index.ts index 49e8f85..dfb4604 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,7 @@ import { type CookiesStatus, type CookiesLoginResult, type CommandTemplate, + type MediaProfile, type CommandPreviewResult, type ErrorLogEntry, type BackupExportResult, @@ -126,6 +127,14 @@ const api = { removeTemplate: (id: string): Promise => ipcRenderer.invoke(IpcChannels.templatesRemove, id), + listProfiles: (): Promise => ipcRenderer.invoke(IpcChannels.profilesList), + + saveProfile: (profile: MediaProfile): Promise => + ipcRenderer.invoke(IpcChannels.profilesSave, profile), + + removeProfile: (id: string): Promise => + ipcRenderer.invoke(IpcChannels.profilesRemove, id), + /** Build the exact yt-dlp command line for the given form state, without running it. */ previewCommand: (opts: StartDownloadOptions): Promise => ipcRenderer.invoke(IpcChannels.commandPreview, opts), @@ -214,6 +223,10 @@ const api = { setSourceWatched: (id: string, watched: boolean): Promise => ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched), + /** Point a source at a MediaProfile, or clear it (profileId = null). */ + setSourceProfile: (id: string, profileId: string | null): Promise => + ipcRenderer.invoke(IpcChannels.sourceSetProfile, id, profileId), + /** Re-index all watched sources; resolves with the videos found new. Named to * match the main function `syncWatchedSources` (L92). */ syncWatchedSources: (): Promise => ipcRenderer.invoke(IpcChannels.sourcesSync), diff --git a/src/renderer/src/components/ProfileManager.tsx b/src/renderer/src/components/ProfileManager.tsx new file mode 100644 index 0000000..3d7e28d --- /dev/null +++ b/src/renderer/src/components/ProfileManager.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { + Button, + Field, + Input, + Caption1, + Text, + makeStyles, + tokens, + shorthands +} from '@fluentui/react-components' +import { + AddRegular, + EditRegular, + DeleteRegular, + SaveRegular, + DismissRegular +} from '@fluentui/react-icons' +import type { MediaProfile, MediaKind } from '@shared/ipc' +import { QUALITY_OPTIONS } from '../lib/qualityOptions' +import { useProfiles } from '../store/profiles' +import { newId } from '../lib/id' +import { Hint } from './Hint' +import { Select } from './Select' +import { SegmentedControl } from './ui/SegmentedControl' +import { EmptyState } from './ui/EmptyState' +import { SPACE, META_SEP } from './ui/tokens' + +const useStyles = makeStyles({ + root: { display: 'flex', flexDirection: 'column', gap: '10px' }, + row: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: `${SPACE.tight} ${SPACE.snug}`, + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.borderRadius(tokens.borderRadiusLarge), + border: `1px solid ${tokens.colorNeutralStroke2}` + }, + rowBody: { flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }, + rowName: { fontWeight: tokens.fontWeightSemibold }, + rowMeta: { color: tokens.colorNeutralForeground3 }, + actions: { display: 'flex', gap: '4px', flexShrink: 0 }, + form: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + padding: SPACE.cozy, + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.borderRadius(tokens.borderRadiusLarge), + border: `1px solid ${tokens.colorNeutralStroke2}` + }, + formRow: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' }, + formActions: { display: 'flex', gap: '8px', justifyContent: 'flex-end' } +}) + +interface Draft { + /** null while adding a brand-new profile */ + id: string | null + name: string + kind: MediaKind + quality: string + outputTemplate: string + extraArgs: string +} +const BLANK_DRAFT: Draft = { + id: null, + name: '', + kind: 'video', + quality: 'Best available', + outputTemplate: '', + extraArgs: '' +} + +/** One-line summary of a profile's overrides for the list row. */ +function summarize(p: MediaProfile): string { + const parts = [p.kind === 'audio' ? 'Audio' : 'Video'] + if (p.quality) parts.push(p.quality) + if (p.outputTemplate) parts.push('custom folders') + if (p.extraArgs) parts.push('extra args') + return parts.join(META_SEP) +} + +/** + * Inline (no-modal, per the app's composited-overlay avoidance) CRUD list + + * add/edit form for MediaProfile — named download presets a Library Source can + * point at. Mirrors TemplateManager; used from SettingsView's "Media profiles" + * card. + */ +export function ProfileManager(): React.JSX.Element { + const styles = useStyles() + const profiles = useProfiles((s) => s.profiles) + const save = useProfiles((s) => s.save) + const remove = useProfiles((s) => s.remove) + + const [draft, setDraft] = useState(null) + + function startEdit(p: MediaProfile): void { + setDraft({ + id: p.id, + name: p.name, + kind: p.kind ?? 'video', + quality: p.quality ?? 'Best available', + outputTemplate: p.outputTemplate ?? '', + extraArgs: p.extraArgs ?? '' + }) + } + + function commit(): void { + if (!draft) return + const name = draft.name.trim() + if (!name) return + const outputTemplate = draft.outputTemplate.trim() + const extraArgs = draft.extraArgs.trim() + save({ + id: draft.id ?? newId('prof'), + name, + kind: draft.kind, + quality: draft.quality, + ...(outputTemplate ? { outputTemplate } : {}), + ...(extraArgs ? { extraArgs } : {}) + }) + setDraft(null) + } + + return ( +
+ {profiles.length === 0 && !draft && } + + {profiles.map((p) => ( +
+
+ {p.name} + {summarize(p)} +
+
+ +
+
+ ))} + + {draft ? ( +
+ setDraft({ ...draft, name: d.value })} + /> +
+ + + value={draft.kind} + options={[ + { value: 'video', label: 'Video' }, + { value: 'audio', label: 'Audio' } + ]} + onChange={(kind) => + setDraft({ ...draft, kind, quality: QUALITY_OPTIONS[kind][0] ?? draft.quality }) + } + ariaLabel="Profile format" + /> + + + setDraft({ ...draft, outputTemplate: d.value })} + /> + + + setDraft({ ...draft, extraArgs: d.value })} + /> + +
+ + +
+
+ ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 1b931a2..0af45ed 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -1,4 +1,9 @@ -import { DEFAULT_SETTINGS, type Settings, type CommandTemplate } from '@shared/ipc' +import { + DEFAULT_SETTINGS, + type Settings, + type CommandTemplate, + type MediaProfile +} from '@shared/ipc' /** * The browser-preview stand-in for the real `window.api` IPC bridge (C1). In the @@ -40,6 +45,11 @@ let mockTemplates: CommandTemplate[] = [ { id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' } ] +let mockProfiles: MediaProfile[] = [ + { id: 'p1', name: 'Archive (1080p, subs)', kind: 'video', quality: '1080p' }, + { id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' } +] + export const mockApi: Api = { getAppVersion: async () => MOCK_CURRENT_VERSION, checkForAppUpdate: async () => { @@ -171,6 +181,15 @@ export const mockApi: Api = { mockTemplates = mockTemplates.filter((t) => t.id !== id) return mockTemplates }, + listProfiles: async () => mockProfiles, + saveProfile: async (profile) => { + mockProfiles = [profile, ...mockProfiles.filter((p) => p.id !== profile.id)] + return mockProfiles + }, + removeProfile: async (id) => { + mockProfiles = mockProfiles.filter((p) => p.id !== id) + return mockProfiles + }, previewCommand: async (opts) => { await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip const extra = opts.extraArgs ? ` ${opts.extraArgs}` : '' @@ -242,6 +261,7 @@ export const mockApi: Api = { removeSource: async () => [], setMediaItemDownloaded: async () => [], setSourceWatched: async () => [], + setSourceProfile: async () => [], syncWatchedSources: async () => ({ ok: true, newItems: [] }), getScheduledSync: async () => ({ enabled: false }), setScheduledSync: async (enabled: boolean) => ({ enabled }), diff --git a/src/renderer/src/store/downloadItem.ts b/src/renderer/src/store/downloadItem.ts index 1825c82..c5fb238 100644 --- a/src/renderer/src/store/downloadItem.ts +++ b/src/renderer/src/store/downloadItem.ts @@ -46,6 +46,7 @@ export function buildItem( scheduledFor, incognito: opts?.incognito, collection: opts?.collection, + collectionOutputTemplate: opts?.collectionOutputTemplate, mediaItemId: opts?.mediaItemId, probedMeta } diff --git a/src/renderer/src/store/downloadTypes.ts b/src/renderer/src/store/downloadTypes.ts index 1598e04..d68bd2f 100644 --- a/src/renderer/src/store/downloadTypes.ts +++ b/src/renderer/src/store/downloadTypes.ts @@ -62,6 +62,8 @@ export interface DownloadItem { probedMeta?: DownloadMeta /** media-manager folder context -- files this into // - */ collection?: CollectionContext + /** per-download collection folder-layout override (a MediaProfile's outputTemplate) */ + collectionOutputTemplate?: string /** when set, the source MediaItem id this download came from -- marked downloaded on completion */ mediaItemId?: string } @@ -80,6 +82,7 @@ export interface AddOptions { scheduledFor?: number incognito?: boolean collection?: CollectionContext + collectionOutputTemplate?: string mediaItemId?: string } diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index c2ea243..44cab3a 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -129,7 +129,8 @@ export const useDownloads = create<DownloadState>((set, get) => { // detail, and cookies — not just the renderer's history skip (L136). incognito: item.incognito, meta: item.probedMeta, - collection: item.collection + collection: item.collection, + collectionOutputTemplate: item.collectionOutputTemplate }) .then((res) => { if (!res.ok) markError(item.id, res.error) diff --git a/src/renderer/src/store/profiles.ts b/src/renderer/src/store/profiles.ts new file mode 100644 index 0000000..98a11e1 --- /dev/null +++ b/src/renderer/src/store/profiles.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand' +import type { MediaProfile } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' +import { logError } from '../reportError' +import { createReconciler } from '../lib/reconcile' + +// Sample profiles so the manager + source picker have content during UI design. +const seed: MediaProfile[] = PREVIEW + ? [ + { id: 'p1', name: 'Archive (1080p, subs)', kind: 'video', quality: '1080p' }, + { id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' } + ] + : [] + +interface ProfilesState { + profiles: MediaProfile[] + /** add a new profile or update an existing one (matched by id) */ + save: (profile: MediaProfile) => void + remove: (id: string) => void + /** re-fetch persisted profiles from main */ + reload: () => void +} + +// Mutations apply main's authoritative return (sanitized/capped) via a +// latest-wins reconciler so the optimistic list matches disk (L142). +const reconcile = createReconciler() + +export const useProfiles = create<ProfilesState>((set) => ({ + profiles: seed, + + save: (profile) => { + set((s) => ({ profiles: [profile, ...s.profiles.filter((p) => p.id !== profile.id)] })) + if (!PREVIEW) + reconcile( + 'profiles', + window.api.saveProfile(profile), + (profiles) => set({ profiles }), + logError('saveProfile') + ) + }, + + remove: (id) => { + set((s) => ({ profiles: s.profiles.filter((p) => p.id !== id) })) + if (!PREVIEW) + reconcile( + 'profiles', + window.api.removeProfile(id), + (profiles) => set({ profiles }), + logError('removeProfile') + ) + }, + + reload: () => { + if (PREVIEW) return + window.api + .listProfiles() + .then((profiles) => set({ profiles })) + .catch(logError('listProfiles')) + } +})) + +// Load persisted profiles on startup. +if (!PREVIEW) useProfiles.getState().reload() diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index fc49048..cc52586 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -1,7 +1,9 @@ import { create } from 'zustand' import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc' +import { resolveProfile } from '@shared/profileResolve' import { isPreview as PREVIEW } from '../isPreview' import { useSettings } from './settings' +import { useProfiles } from './profiles' import { emit, on } from './coordinator' import { logError } from '../reportError' import { createReconciler } from '../lib/reconcile' @@ -99,6 +101,8 @@ interface SourcesState { markDownloaded: (itemId: string, filePath?: string) => void /** toggle a source's watched-for-new-uploads flag (Phase J) */ setWatched: (id: string, watched: boolean) => void + /** point a source at a MediaProfile, or clear it (profileId = null) */ + setProfile: (id: string, profileId: string | null) => void /** whether a sync is currently running (the "Check for new" button's busy state) */ syncing: boolean /** @@ -235,9 +239,29 @@ export const useSources = create<SourcesState>((set, get) => ({ enqueueItems: (sourceId, items, kindOverride) => { const src = get().sources.find((s) => s.id === sourceId) const settings = useSettings.getState() - // Per-batch override (Library video/audio toggle) falls back to the global default (M27). - const kind = kindOverride ?? settings.defaultKind - const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality + // A source's Media Profile (if any) resolves kind/quality/options/output- + // template/extra-args from the profile first, falling back to the globals + // field by field (resolveProfile). A per-batch Library video/audio toggle + // (kindOverride) still wins for kind. + const profile = src?.profileId + ? (useProfiles.getState().profiles.find((p) => p.id === src.profileId) ?? null) + : null + const resolved = resolveProfile(profile, { + kind: settings.defaultKind, + videoQuality: settings.defaultVideoQuality, + audioQuality: settings.defaultAudioQuality, + options: settings.downloadOptions, + outputTemplate: settings.collectionOutputTemplate + }) + const kind = kindOverride ?? resolved.kind + // If the toggle flipped kind away from the profile's, re-pick the matching + // global quality unless the profile pinned its own. + const quality = + kindOverride && !profile?.quality + ? kind === 'video' + ? settings.defaultVideoQuality + : settings.defaultAudioQuality + : resolved.quality // Hand the batch to the downloads store via the event bus (C2) rather than a // direct import. addMany applies it as one batched state update + pump, so // queuing a whole channel stays O(n). @@ -251,6 +275,9 @@ export const useSources = create<SourcesState>((set, get) => ({ title: it.title, channel: src?.channel, durationLabel: it.durationLabel, + options: resolved.options, + extraArgs: resolved.extraArgs || undefined, + collectionOutputTemplate: resolved.outputTemplate || undefined, collection: { channel: src?.channel || src?.title || 'Channel', playlist: it.playlistTitle, @@ -309,6 +336,19 @@ export const useSources = create<SourcesState>((set, get) => ({ ) }, + setProfile: (id, profileId) => { + set((s) => ({ + sources: s.sources.map((x) => (x.id === id ? { ...x, profileId: profileId ?? undefined } : x)) + })) + if (!PREVIEW) + reconcile( + 'sources', + window.api.setSourceProfile(id, profileId), + (sources) => set({ sources }), + logError('setSourceProfile') + ) + }, + syncWatched: async () => { if (get().syncing) return 0 set({ syncing: true }) diff --git a/src/renderer/src/views/LibraryView.tsx b/src/renderer/src/views/LibraryView.tsx index 117c7d5..83b5708 100644 --- a/src/renderer/src/views/LibraryView.tsx +++ b/src/renderer/src/views/LibraryView.tsx @@ -28,6 +28,8 @@ import { import type { MediaItem, Source, MediaKind } from '@shared/ipc' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' +import { useProfiles } from '../store/profiles' +import { Select } from '../components/Select' import { useNav } from '../store/nav' import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink' import { useDownloads, type DownloadStatus } from '../store/downloads' @@ -233,6 +235,8 @@ export function LibraryView(): React.JSX.Element { const removeSource = useSources((s) => s.removeSource) const enqueueItems = useSources((s) => s.enqueueItems) const setWatched = useSources((s) => s.setWatched) + const setProfile = useSources((s) => s.setProfile) + const profiles = useProfiles((s) => s.profiles) const syncWatched = useSources((s) => s.syncWatched) const syncing = useSources((s) => s.syncing) const scheduled = useSources((s) => s.scheduledSyncEnabled) @@ -692,6 +696,20 @@ export function LibraryView(): React.JSX.Element { Download {pendingItems.length} pending </Button> )} + {profiles.length > 0 && ( + <span className={styles.switchRow}> + <Caption1>Profile</Caption1> + <Select + value={src.profileId ?? 'none'} + options={[ + { value: 'none', label: 'Default settings' }, + ...profiles.map((p) => ({ value: p.id, label: p.name })) + ]} + onChange={(v) => setProfile(src.id, v === 'none' ? null : v)} + aria-label={`Download profile for ${src.title}`} + /> + </span> + )} <span className={styles.switchRow}> <Caption1>Watch</Caption1> <Switch diff --git a/src/renderer/src/views/SettingsView.tsx b/src/renderer/src/views/SettingsView.tsx index c8c94ba..a9ffa88 100644 --- a/src/renderer/src/views/SettingsView.tsx +++ b/src/renderer/src/views/SettingsView.tsx @@ -20,6 +20,7 @@ import { PostProcessingCard } from './settings/PostProcessingCard' import { NetworkCard } from './settings/NetworkCard' import { CookiesCard } from './settings/CookiesCard' import { CustomCommandsCard } from './settings/CustomCommandsCard' +import { MediaProfilesCard } from './settings/MediaProfilesCard' import { FilenamesCard } from './settings/FilenamesCard' import { BackupCard } from './settings/BackupCard' import { DiagnosticsCard } from './settings/DiagnosticsCard' @@ -69,7 +70,7 @@ const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [ }, { id: 'appearance', title: 'Appearance', cards: [AppearanceCard] }, { id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] }, - { id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard] }, + { id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard, MediaProfilesCard] }, { id: 'app', title: 'App & maintenance', diff --git a/src/renderer/src/views/settings/MediaProfilesCard.tsx b/src/renderer/src/views/settings/MediaProfilesCard.tsx new file mode 100644 index 0000000..652b4b6 --- /dev/null +++ b/src/renderer/src/views/settings/MediaProfilesCard.tsx @@ -0,0 +1,29 @@ +import { Card, Subtitle2, Caption1 } from '@fluentui/react-components' +import { CollectionsRegular } from '@fluentui/react-icons' +import { ProfileManager } from '../../components/ProfileManager' +import { useSettingsStyles } from './settingsStyles' + +/** + * Settings card for Media Profiles (PINCHFLAT): named download presets + * (format/quality/folder-layout/extra-args) that a Library channel or playlist + * can point at, so different sources download by different rules. + */ +export function MediaProfilesCard(): React.JSX.Element { + const styles = useSettingsStyles() + + return ( + <Card className={styles.card}> + <div className={styles.sectionHeader}> + <CollectionsRegular className={styles.sectionIcon} /> + <Subtitle2 as="h3">Media profiles</Subtitle2> + </div> + <Caption1 className={styles.hint}> + Named download presets a channel or playlist can use — bundle a format, quality, folder + layout, and extra yt-dlp args, then point a Library source at it (its downloads follow the + profile instead of the global defaults). + </Caption1> + + <ProfileManager /> + </Card> + ) +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 6fd1fd5..0ec3e86 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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 } /** diff --git a/src/shared/profileResolve.ts b/src/shared/profileResolve.ts new file mode 100644 index 0000000..f8215de --- /dev/null +++ b/src/shared/profileResolve.ts @@ -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 ?? '' + } +} diff --git a/test/profileResolve.test.ts b/test/profileResolve.test.ts new file mode 100644 index 0000000..3a8437b --- /dev/null +++ b/test/profileResolve.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { resolveProfile, type ProfileDefaults } from '../src/shared/profileResolve' +import { DEFAULT_DOWNLOAD_OPTIONS, type MediaProfile } from '@shared/ipc' + +const DEFAULTS: ProfileDefaults = { + kind: 'video', + videoQuality: '1080p', + audioQuality: 'Best', + options: DEFAULT_DOWNLOAD_OPTIONS, + outputTemplate: '' +} + +describe('resolveProfile (Media Profiles precedence)', () => { + it('no profile → pure global defaults', () => { + expect(resolveProfile(null, DEFAULTS)).toEqual({ + kind: 'video', + quality: '1080p', + options: DEFAULT_DOWNLOAD_OPTIONS, + outputTemplate: '', + extraArgs: '' + }) + // undefined behaves the same as null + expect(resolveProfile(undefined, DEFAULTS).kind).toBe('video') + }) + + it('a profile field overrides its global default', () => { + const p: MediaProfile = { id: 'p', name: 'Music', kind: 'audio', quality: 'Best' } + const r = resolveProfile(p, DEFAULTS) + expect(r.kind).toBe('audio') + expect(r.quality).toBe('Best') + }) + + it('an audio-kind profile without its own quality picks up the global AUDIO quality', () => { + const p: MediaProfile = { id: 'p', name: 'Audio', kind: 'audio' } + expect(resolveProfile(p, DEFAULTS).quality).toBe('Best') // not the video 1080p + }) + + it('a video-kind profile without its own quality picks up the global VIDEO quality', () => { + const p: MediaProfile = { id: 'p', name: 'Vid', kind: 'video' } + expect(resolveProfile(p, DEFAULTS).quality).toBe('1080p') + }) + + it('carries the profile outputTemplate + extraArgs; unset extraArgs is ""', () => { + const p: MediaProfile = { + id: 'p', + name: 'Custom', + outputTemplate: '%(uploader)s/%(title)s.%(ext)s' + } + const r = resolveProfile(p, DEFAULTS) + expect(r.outputTemplate).toBe('%(uploader)s/%(title)s.%(ext)s') + expect(r.extraArgs).toBe('') + }) + + it('a profile options override replaces the global options object', () => { + const customOptions = { ...DEFAULT_DOWNLOAD_OPTIONS, embedSubtitles: true } + const p: MediaProfile = { id: 'p', name: 'Subs', options: customOptions } + expect(resolveProfile(p, DEFAULTS).options).toBe(customOptions) + }) + + it('a profile that pins only quality keeps the global kind + options', () => { + const p: MediaProfile = { id: 'p', name: 'HiQ', quality: '720p' } + const r = resolveProfile(p, DEFAULTS) + expect(r.kind).toBe('video') + expect(r.quality).toBe('720p') + expect(r.options).toBe(DEFAULT_DOWNLOAD_OPTIONS) + }) +})