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:
+11
-5
@@ -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
|
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
|
(parallels how Phase C exposed raw extra args). Keeps the existing flat
|
||||||
`filenameTemplate` as the default for non-collection downloads.
|
`filenameTemplate` as the default for non-collection downloads.
|
||||||
- [ ] **"Media Profile" = named download preset (optional sugar).** Pinchflat bundles
|
- [x] **"Media Profile" = named download preset.** Pinchflat bundles quality + subs + output
|
||||||
quality + subs + output template into a reusable Profile. AeroFetch already persists
|
template into a reusable Profile. Implemented: a `MediaProfile` = named bundle of
|
||||||
`DownloadOptions` and named `CommandTemplate`s; a Profile is just a named bundle of
|
`{ kind, quality, options, outputTemplate, extraArgs }` a Source points at via `Source.profileId`.
|
||||||
`{ DownloadOptions, outputTemplate, extraArgs }` a Source can point at. Worth it once
|
Backend mirrors the `CommandTemplate` precedent — `src/main/profiles.ts` on `createJsonStore`
|
||||||
multiple sources want different rules; skip for v1 (use the global defaults).
|
(`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
|
## Phase H — Library view (the media-manager UI) ✅ COMPLETE
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const LOG_MAX_BYTES = 1024 * 1024
|
|||||||
export const ERRORLOG_MAX_ENTRIES = 200
|
export const ERRORLOG_MAX_ENTRIES = 200
|
||||||
/** Saved custom-command templates (templates.json). */
|
/** Saved custom-command templates (templates.json). */
|
||||||
export const TEMPLATES_MAX = 100
|
export const TEMPLATES_MAX = 100
|
||||||
|
/** Saved media profiles (profiles.json). */
|
||||||
|
export const PROFILES_MAX = 100
|
||||||
/** Indexed media items across all sources (media-items.json). */
|
/** Indexed media items across all sources (media-items.json). */
|
||||||
export const MEDIA_ITEMS_MAX = 20_000
|
export const MEDIA_ITEMS_MAX = 20_000
|
||||||
/** Persisted download-queue rows (queue.json, M4) — generous; a whole channel can be queued. */
|
/** Persisted download-queue rows (queue.json, M4) — generous; a whole channel can be queued. */
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isAbsolute } from 'path'
|
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) ---------------------------------
|
// --- 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'
|
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. */
|
/** A persisted sources.json row must have the right shape or it's dropped on read. */
|
||||||
export function isValidSource(o: unknown): o is Source {
|
export function isValidSource(o: unknown): o is Source {
|
||||||
if (!o || typeof o !== 'object') return false
|
if (!o || typeof o !== 'object') return false
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
|||||||
outDir,
|
outDir,
|
||||||
opts.collection,
|
opts.collection,
|
||||||
'%(title)s.%(ext)s',
|
'%(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)
|
: join(outDir, filenameTemplate)
|
||||||
// Per-download override wins; otherwise use the persisted defaults.
|
// Per-download override wins; otherwise use the persisted defaults.
|
||||||
|
|||||||
+11
-1
@@ -19,6 +19,7 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type CommandTemplate,
|
type CommandTemplate,
|
||||||
|
type MediaProfile,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type SystemThemeInfo,
|
type SystemThemeInfo,
|
||||||
type TaskbarProgress,
|
type TaskbarProgress,
|
||||||
@@ -35,6 +36,7 @@ import { runTerminal, cancelTerminal } from './terminal'
|
|||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
|
import { listProfiles, saveProfile, removeProfile } from './profiles'
|
||||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||||
@@ -46,7 +48,8 @@ import {
|
|||||||
removeSource,
|
removeSource,
|
||||||
listMediaItems,
|
listMediaItems,
|
||||||
setMediaItemDownloaded,
|
setMediaItemDownloaded,
|
||||||
setSourceWatched
|
setSourceWatched,
|
||||||
|
setSourceProfile
|
||||||
} from './sources'
|
} from './sources'
|
||||||
import { indexSourceCancelable, cancelIndexing } from './indexer'
|
import { indexSourceCancelable, cancelIndexing } from './indexer'
|
||||||
import { syncWatchedSources } from './sync'
|
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.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) =>
|
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||||
previewCommand(opts)
|
previewCommand(opts)
|
||||||
)
|
)
|
||||||
@@ -269,6 +276,9 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
|||||||
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||||
setSourceWatched(id, watched)
|
setSourceWatched(id, watched)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) =>
|
||||||
|
setSourceProfile(id, profileId)
|
||||||
|
)
|
||||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||||
syncWatchedSources((p) => {
|
syncWatchedSources((p) => {
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, 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)))
|
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. */
|
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||||||
export function removeSource(id: string): Source[] {
|
export function removeSource(id: string): Source[] {
|
||||||
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type CookiesStatus,
|
type CookiesStatus,
|
||||||
type CookiesLoginResult,
|
type CookiesLoginResult,
|
||||||
type CommandTemplate,
|
type CommandTemplate,
|
||||||
|
type MediaProfile,
|
||||||
type CommandPreviewResult,
|
type CommandPreviewResult,
|
||||||
type ErrorLogEntry,
|
type ErrorLogEntry,
|
||||||
type BackupExportResult,
|
type BackupExportResult,
|
||||||
@@ -126,6 +127,14 @@ const api = {
|
|||||||
removeTemplate: (id: string): Promise<CommandTemplate[]> =>
|
removeTemplate: (id: string): Promise<CommandTemplate[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.templatesRemove, id),
|
ipcRenderer.invoke(IpcChannels.templatesRemove, id),
|
||||||
|
|
||||||
|
listProfiles: (): Promise<MediaProfile[]> => ipcRenderer.invoke(IpcChannels.profilesList),
|
||||||
|
|
||||||
|
saveProfile: (profile: MediaProfile): Promise<MediaProfile[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.profilesSave, profile),
|
||||||
|
|
||||||
|
removeProfile: (id: string): Promise<MediaProfile[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.profilesRemove, id),
|
||||||
|
|
||||||
/** Build the exact yt-dlp command line for the given form state, without running it. */
|
/** Build the exact yt-dlp command line for the given form state, without running it. */
|
||||||
previewCommand: (opts: StartDownloadOptions): Promise<CommandPreviewResult> =>
|
previewCommand: (opts: StartDownloadOptions): Promise<CommandPreviewResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.commandPreview, opts),
|
ipcRenderer.invoke(IpcChannels.commandPreview, opts),
|
||||||
@@ -214,6 +223,10 @@ const api = {
|
|||||||
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
||||||
|
|
||||||
|
/** Point a source at a MediaProfile, or clear it (profileId = null). */
|
||||||
|
setSourceProfile: (id: string, profileId: string | null): Promise<Source[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceSetProfile, id, profileId),
|
||||||
|
|
||||||
/** Re-index all watched sources; resolves with the videos found new. Named to
|
/** Re-index all watched sources; resolves with the videos found new. Named to
|
||||||
* match the main function `syncWatchedSources` (L92). */
|
* match the main function `syncWatchedSources` (L92). */
|
||||||
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||||
|
|||||||
@@ -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<Draft | null>(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 (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{profiles.length === 0 && !draft && <EmptyState compact message="No media profiles yet." />}
|
||||||
|
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<div key={p.id} className={styles.row}>
|
||||||
|
<div className={styles.rowBody}>
|
||||||
|
<Text className={styles.rowName}>{p.name}</Text>
|
||||||
|
<Caption1 className={styles.rowMeta}>{summarize(p)}</Caption1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Hint label="Edit" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<EditRegular />}
|
||||||
|
onClick={() => startEdit(p)}
|
||||||
|
aria-label="Edit profile"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
<Hint label="Delete" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => remove(p.id)}
|
||||||
|
aria-label="Delete profile"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<div className={styles.form}>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
placeholder="Profile name, e.g. Archive (1080p)"
|
||||||
|
onChange={(_, d) => setDraft({ ...draft, name: d.value })}
|
||||||
|
/>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<Field label="Format">
|
||||||
|
<SegmentedControl<MediaKind>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Quality">
|
||||||
|
<Select
|
||||||
|
value={draft.quality}
|
||||||
|
options={QUALITY_OPTIONS[draft.kind].map((q) => ({ value: q, label: q }))}
|
||||||
|
onChange={(quality) => setDraft({ ...draft, quality })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label="Collection folder layout (optional)"
|
||||||
|
hint="Raw yt-dlp -o template for this profile's sources. Blank = the global default."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={draft.outputTemplate}
|
||||||
|
placeholder="%(uploader)s/%(playlist)s/%(title)s.%(ext)s"
|
||||||
|
onChange={(_, d) => setDraft({ ...draft, outputTemplate: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Extra yt-dlp args (optional)">
|
||||||
|
<Input
|
||||||
|
value={draft.extraArgs}
|
||||||
|
placeholder="--write-thumbnail --no-mtime"
|
||||||
|
onChange={(_, d) => setDraft({ ...draft, extraArgs: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className={styles.formActions}>
|
||||||
|
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<SaveRegular />}
|
||||||
|
onClick={commit}
|
||||||
|
disabled={!draft.name.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<AddRegular />}
|
||||||
|
onClick={() => setDraft({ ...BLANK_DRAFT })}
|
||||||
|
>
|
||||||
|
Add profile
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
* 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' }
|
{ 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 = {
|
export const mockApi: Api = {
|
||||||
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
||||||
checkForAppUpdate: async () => {
|
checkForAppUpdate: async () => {
|
||||||
@@ -171,6 +181,15 @@ export const mockApi: Api = {
|
|||||||
mockTemplates = mockTemplates.filter((t) => t.id !== id)
|
mockTemplates = mockTemplates.filter((t) => t.id !== id)
|
||||||
return mockTemplates
|
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) => {
|
previewCommand: async (opts) => {
|
||||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||||
@@ -242,6 +261,7 @@ export const mockApi: Api = {
|
|||||||
removeSource: async () => [],
|
removeSource: async () => [],
|
||||||
setMediaItemDownloaded: async () => [],
|
setMediaItemDownloaded: async () => [],
|
||||||
setSourceWatched: async () => [],
|
setSourceWatched: async () => [],
|
||||||
|
setSourceProfile: async () => [],
|
||||||
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
||||||
getScheduledSync: async () => ({ enabled: false }),
|
getScheduledSync: async () => ({ enabled: false }),
|
||||||
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function buildItem(
|
|||||||
scheduledFor,
|
scheduledFor,
|
||||||
incognito: opts?.incognito,
|
incognito: opts?.incognito,
|
||||||
collection: opts?.collection,
|
collection: opts?.collection,
|
||||||
|
collectionOutputTemplate: opts?.collectionOutputTemplate,
|
||||||
mediaItemId: opts?.mediaItemId,
|
mediaItemId: opts?.mediaItemId,
|
||||||
probedMeta
|
probedMeta
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface DownloadItem {
|
|||||||
probedMeta?: DownloadMeta
|
probedMeta?: DownloadMeta
|
||||||
/** media-manager folder context -- files this into <channel>/<playlist>/<NNN> - <title> */
|
/** media-manager folder context -- files this into <channel>/<playlist>/<NNN> - <title> */
|
||||||
collection?: CollectionContext
|
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 */
|
/** when set, the source MediaItem id this download came from -- marked downloaded on completion */
|
||||||
mediaItemId?: string
|
mediaItemId?: string
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,7 @@ export interface AddOptions {
|
|||||||
scheduledFor?: number
|
scheduledFor?: number
|
||||||
incognito?: boolean
|
incognito?: boolean
|
||||||
collection?: CollectionContext
|
collection?: CollectionContext
|
||||||
|
collectionOutputTemplate?: string
|
||||||
mediaItemId?: string
|
mediaItemId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
// detail, and cookies — not just the renderer's history skip (L136).
|
// detail, and cookies — not just the renderer's history skip (L136).
|
||||||
incognito: item.incognito,
|
incognito: item.incognito,
|
||||||
meta: item.probedMeta,
|
meta: item.probedMeta,
|
||||||
collection: item.collection
|
collection: item.collection,
|
||||||
|
collectionOutputTemplate: item.collectionOutputTemplate
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) markError(item.id, res.error)
|
if (!res.ok) markError(item.id, res.error)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
||||||
|
import { resolveProfile } from '@shared/profileResolve'
|
||||||
import { isPreview as PREVIEW } from '../isPreview'
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
|
import { useProfiles } from './profiles'
|
||||||
import { emit, on } from './coordinator'
|
import { emit, on } from './coordinator'
|
||||||
import { logError } from '../reportError'
|
import { logError } from '../reportError'
|
||||||
import { createReconciler } from '../lib/reconcile'
|
import { createReconciler } from '../lib/reconcile'
|
||||||
@@ -99,6 +101,8 @@ interface SourcesState {
|
|||||||
markDownloaded: (itemId: string, filePath?: string) => void
|
markDownloaded: (itemId: string, filePath?: string) => void
|
||||||
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||||
setWatched: (id: string, watched: boolean) => void
|
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) */
|
/** whether a sync is currently running (the "Check for new" button's busy state) */
|
||||||
syncing: boolean
|
syncing: boolean
|
||||||
/**
|
/**
|
||||||
@@ -235,9 +239,29 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
enqueueItems: (sourceId, items, kindOverride) => {
|
enqueueItems: (sourceId, items, kindOverride) => {
|
||||||
const src = get().sources.find((s) => s.id === sourceId)
|
const src = get().sources.find((s) => s.id === sourceId)
|
||||||
const settings = useSettings.getState()
|
const settings = useSettings.getState()
|
||||||
// Per-batch override (Library video/audio toggle) falls back to the global default (M27).
|
// A source's Media Profile (if any) resolves kind/quality/options/output-
|
||||||
const kind = kindOverride ?? settings.defaultKind
|
// template/extra-args from the profile first, falling back to the globals
|
||||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
// 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
|
// 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
|
// direct import. addMany applies it as one batched state update + pump, so
|
||||||
// queuing a whole channel stays O(n).
|
// queuing a whole channel stays O(n).
|
||||||
@@ -251,6 +275,9 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
title: it.title,
|
title: it.title,
|
||||||
channel: src?.channel,
|
channel: src?.channel,
|
||||||
durationLabel: it.durationLabel,
|
durationLabel: it.durationLabel,
|
||||||
|
options: resolved.options,
|
||||||
|
extraArgs: resolved.extraArgs || undefined,
|
||||||
|
collectionOutputTemplate: resolved.outputTemplate || undefined,
|
||||||
collection: {
|
collection: {
|
||||||
channel: src?.channel || src?.title || 'Channel',
|
channel: src?.channel || src?.title || 'Channel',
|
||||||
playlist: it.playlistTitle,
|
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 () => {
|
syncWatched: async () => {
|
||||||
if (get().syncing) return 0
|
if (get().syncing) return 0
|
||||||
set({ syncing: true })
|
set({ syncing: true })
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
||||||
import { useSources } from '../store/sources'
|
import { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useProfiles } from '../store/profiles'
|
||||||
|
import { Select } from '../components/Select'
|
||||||
import { useNav } from '../store/nav'
|
import { useNav } from '../store/nav'
|
||||||
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
|
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
@@ -233,6 +235,8 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const removeSource = useSources((s) => s.removeSource)
|
const removeSource = useSources((s) => s.removeSource)
|
||||||
const enqueueItems = useSources((s) => s.enqueueItems)
|
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||||
const setWatched = useSources((s) => s.setWatched)
|
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 syncWatched = useSources((s) => s.syncWatched)
|
||||||
const syncing = useSources((s) => s.syncing)
|
const syncing = useSources((s) => s.syncing)
|
||||||
const scheduled = useSources((s) => s.scheduledSyncEnabled)
|
const scheduled = useSources((s) => s.scheduledSyncEnabled)
|
||||||
@@ -692,6 +696,20 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
Download {pendingItems.length} pending
|
Download {pendingItems.length} pending
|
||||||
</Button>
|
</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}>
|
<span className={styles.switchRow}>
|
||||||
<Caption1>Watch</Caption1>
|
<Caption1>Watch</Caption1>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { PostProcessingCard } from './settings/PostProcessingCard'
|
|||||||
import { NetworkCard } from './settings/NetworkCard'
|
import { NetworkCard } from './settings/NetworkCard'
|
||||||
import { CookiesCard } from './settings/CookiesCard'
|
import { CookiesCard } from './settings/CookiesCard'
|
||||||
import { CustomCommandsCard } from './settings/CustomCommandsCard'
|
import { CustomCommandsCard } from './settings/CustomCommandsCard'
|
||||||
|
import { MediaProfilesCard } from './settings/MediaProfilesCard'
|
||||||
import { FilenamesCard } from './settings/FilenamesCard'
|
import { FilenamesCard } from './settings/FilenamesCard'
|
||||||
import { BackupCard } from './settings/BackupCard'
|
import { BackupCard } from './settings/BackupCard'
|
||||||
import { DiagnosticsCard } from './settings/DiagnosticsCard'
|
import { DiagnosticsCard } from './settings/DiagnosticsCard'
|
||||||
@@ -69,7 +70,7 @@ const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
|
|||||||
},
|
},
|
||||||
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
|
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
|
||||||
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
|
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
|
||||||
{ id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard] },
|
{ id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard, MediaProfilesCard] },
|
||||||
{
|
{
|
||||||
id: 'app',
|
id: 'app',
|
||||||
title: 'App & maintenance',
|
title: 'App & maintenance',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export const IpcChannels = {
|
|||||||
templatesList: 'templates:list',
|
templatesList: 'templates:list',
|
||||||
templatesSave: 'templates:save',
|
templatesSave: 'templates:save',
|
||||||
templatesRemove: 'templates:remove',
|
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',
|
commandPreview: 'command:preview',
|
||||||
ytdlpUpdate: 'ytdlp:update',
|
ytdlpUpdate: 'ytdlp:update',
|
||||||
/** main → renderer push channel for background yt-dlp auto-update status */
|
/** main → renderer push channel for background yt-dlp auto-update status */
|
||||||
@@ -78,6 +82,8 @@ export const IpcChannels = {
|
|||||||
sourceItemDownloaded: 'sources:item-downloaded',
|
sourceItemDownloaded: 'sources:item-downloaded',
|
||||||
/** toggle whether a source is watched for new uploads (Phase J) */
|
/** toggle whether a source is watched for new uploads (Phase J) */
|
||||||
sourceSetWatched: 'sources:set-watched',
|
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 */
|
/** re-index all watched sources and return the videos that are new */
|
||||||
sourcesSync: 'sources:sync',
|
sourcesSync: 'sources:sync',
|
||||||
/** get / set the Windows scheduled-sync task (Task Scheduler) */
|
/** get / set the Windows scheduled-sync task (Task Scheduler) */
|
||||||
@@ -476,6 +482,12 @@ export interface StartDownloadOptions {
|
|||||||
* filenameTemplate. See CollectionContext.
|
* filenameTemplate. See CollectionContext.
|
||||||
*/
|
*/
|
||||||
collection?: 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 {
|
export interface StartDownloadResult {
|
||||||
@@ -847,6 +859,31 @@ export interface Source {
|
|||||||
watched?: boolean
|
watched?: boolean
|
||||||
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
|
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
|
||||||
feedUrl?: string
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user