feat(roadmap): surface stale checkboxes + collection output-path setting
Command preview + incognito toggle were already wired end-to-end in the DownloadBar (Show command button, Incognito checkbox) — flipped the two stale ROADMAP.md checkboxes with notes; verified live in the preview. Output-path setting (PINCHFLAT): new Settings.collectionOutputTemplate — a raw yt-dlp -o template overriding the default Channel/Playlist/NNN-Title layout for Library downloads (empty = default). collectionOutputTemplate() takes an optional custom template (joined under outDir, left for yt-dlp to expand); download.ts passes the setting; zod schema validates it as a safe relative template; Settings -> Filenames field. Tests in buildArgs + settingsSchema; live-verified the field renders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+15
-12
@@ -127,12 +127,13 @@ extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write
|
|||||||
the Custom commands card). The **"run custom command" mode** is
|
the Custom commands card). The **"run custom command" mode** is
|
||||||
`Settings.customCommandEnabled`, applied to every new download unless overridden
|
`Settings.customCommandEnabled`, applied to every new download unless overridden
|
||||||
per-download in the download bar's Custom command panel.
|
per-download in the download bar's Custom command panel.
|
||||||
- [ ] **Command preview (plumbed, not surfaced).** `IpcChannels.commandPreview` →
|
- [x] **Command preview (surfaced).** `IpcChannels.commandPreview` → `previewCommand()`
|
||||||
`previewCommand()` (`src/main/download.ts`) builds the exact argv via the same
|
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path a real
|
||||||
`buildCommand()` path a real download would take, then renders it with
|
download would take, then renders it with `formatCommandLine`. **Now surfaced:** the
|
||||||
`formatCommandLine`. The **Preview command** button is NOT yet in the download bar —
|
DownloadBar's **"Show command"** toggle (`useDownloadBar.toggleCommandPreview` →
|
||||||
only the main/preload/buildArgs chain exists. Wire it into the per-download Options
|
`window.api.previewCommand(...)`) renders the exact argv in an inline panel that reflects the
|
||||||
panel or remove the chain (audit M5/UX1).
|
current URL/format/options/trim/schedule. (Checkbox was stale — the button has shipped;
|
||||||
|
re-verified in the browser preview: setting a URL + "Show command" renders the argv panel.)
|
||||||
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
|
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
|
||||||
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
|
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
|
||||||
next to the existing version check, with a channel picker + result output.
|
next to the existing version check, with a channel picker + result output.
|
||||||
@@ -157,12 +158,14 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
|
|||||||
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
|
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
|
||||||
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
|
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
|
||||||
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
|
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
|
||||||
- [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows
|
- [x] **Private / incognito mode (surfaced).** `DownloadItem.incognito` flows through `buildItem`
|
||||||
through `buildItem` → history-skip → the QueueItem "Private" badge, and
|
→ history-skip → the QueueItem "Private" badge, and `store/downloads.ts` checks it before
|
||||||
`store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI
|
ever calling `useHistory().add(...)`. **Now surfaced:** the DownloadBar's Advanced options
|
||||||
control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1).
|
panel carries an **"Incognito mode (no logging, no history, no cookies)"** checkbox
|
||||||
Note `download.ts` would still log a failure / show a completion title for a private item,
|
(`useDownloadBar.setIncognito`), one-shot per download. The main-process promise is also
|
||||||
so the "private" promise currently covers history only (audit L136).
|
complete — L136 (Batch 8) threaded `incognito` through `StartDownloadOptions` so main skips
|
||||||
|
the errorlog write, drops the notification detail, and withholds cookies for a private
|
||||||
|
download, not just the renderer's history skip.
|
||||||
- [x] **Backup / restore** settings + templates (export / import JSON). New
|
- [x] **Backup / restore** settings + templates (export / import JSON). New
|
||||||
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
|
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
|
||||||
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
|
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
|
||||||
|
|||||||
@@ -290,17 +290,29 @@ export function sanitizeDirSegment(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `-o` output template for a collection download:
|
* Build the `-o` output template for a collection download. The default layout is
|
||||||
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
|
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
|
||||||
* where NNN is the 1-based playlist index zero-padded to three digits and
|
* where NNN is the 1-based playlist index zero-padded to three digits and
|
||||||
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
|
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
|
||||||
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
|
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
|
||||||
|
*
|
||||||
|
* A power user can override the folder layout with a raw yt-dlp `-o` template
|
||||||
|
* (`Settings.collectionOutputTemplate`, PINCHFLAT output-path setting) — passed
|
||||||
|
* as `customTemplate`. When present, it's joined under `outDir` and left for
|
||||||
|
* yt-dlp to expand against ITS own metadata fields (`%(uploader)s`,
|
||||||
|
* `%(playlist)s`, `%(playlist_index)s`, `%(title)s`, `%(ext)s`, …), so the raw
|
||||||
|
* template escapes AeroFetch's fixed three-segment layout entirely. It's
|
||||||
|
* validated as a safe relative template (no absolute path, no `..`) before it
|
||||||
|
* ever reaches here.
|
||||||
*/
|
*/
|
||||||
export function collectionOutputTemplate(
|
export function collectionOutputTemplate(
|
||||||
outDir: string,
|
outDir: string,
|
||||||
c: CollectionContext,
|
c: CollectionContext,
|
||||||
baseFilename: string
|
baseFilename: string,
|
||||||
|
customTemplate?: string
|
||||||
): string {
|
): string {
|
||||||
|
const custom = customTemplate?.trim()
|
||||||
|
if (custom) return join(outDir, custom)
|
||||||
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
|
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
|
||||||
const nnn = String(n).padStart(3, '0')
|
const nnn = String(n).padStart(3, '0')
|
||||||
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
|
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
|
||||||
|
|||||||
@@ -200,10 +200,17 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
|||||||
const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
|
const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
|
||||||
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
||||||
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
// <NNN> - <title> folders by default, or into a power-user's raw yt-dlp `-o`
|
||||||
|
// layout (Settings.collectionOutputTemplate); an ordinary download uses the
|
||||||
|
// flat filenameTemplate.
|
||||||
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
const outputTemplate = opts.collection
|
const outputTemplate = opts.collection
|
||||||
? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s')
|
? collectionOutputTemplate(
|
||||||
|
outDir,
|
||||||
|
opts.collection,
|
||||||
|
'%(title)s.%(ext)s',
|
||||||
|
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.
|
||||||
const options = opts.options ?? settings.downloadOptions
|
const options = opts.options ?? settings.downloadOptions
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings
|
|||||||
videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
|
videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
|
||||||
audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
|
audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
|
||||||
filenameTemplate: trimmedWhere(isSafeFilenameTemplate),
|
filenameTemplate: trimmedWhere(isSafeFilenameTemplate),
|
||||||
|
// Collection folder layout (PINCHFLAT): empty = built-in default; otherwise a
|
||||||
|
// safe relative yt-dlp template (isSafeFilenameTemplate allows '/'-separated
|
||||||
|
// path segments and rejects absolute paths + '..' traversal).
|
||||||
|
collectionOutputTemplate: trimmedWhere((v) => v === '' || isSafeFilenameTemplate(v)),
|
||||||
defaultVideoQuality: z.enum(VIDEO_QUALITY_OPTIONS),
|
defaultVideoQuality: z.enum(VIDEO_QUALITY_OPTIONS),
|
||||||
defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS),
|
defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS),
|
||||||
rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)),
|
rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSettingsStyles } from './settingsStyles'
|
|||||||
export function FilenamesCard(): React.JSX.Element {
|
export function FilenamesCard(): React.JSX.Element {
|
||||||
const styles = useSettingsStyles()
|
const styles = useSettingsStyles()
|
||||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||||
|
const collectionOutputTemplate = useSettings((s) => s.collectionOutputTemplate)
|
||||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||||
const useDownloadArchive = useSettings((s) => s.useDownloadArchive)
|
const useDownloadArchive = useSettings((s) => s.useDownloadArchive)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
@@ -29,6 +30,17 @@ export function FilenamesCard(): React.JSX.Element {
|
|||||||
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Collection folder layout"
|
||||||
|
hint="How channel/playlist (Library) downloads are foldered. Leave blank for the default Channel / Playlist / Title. Power users: a raw yt-dlp -o template, e.g. %(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={collectionOutputTemplate}
|
||||||
|
placeholder="Channel / Playlist / NNN - Title (default)"
|
||||||
|
onChange={(_, d) => update({ collectionOutputTemplate: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Restrict filenames"
|
label="Restrict filenames"
|
||||||
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
|
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
|
||||||
|
|||||||
@@ -565,6 +565,14 @@ export interface Settings {
|
|||||||
maxConcurrent: number
|
maxConcurrent: number
|
||||||
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
|
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
|
||||||
filenameTemplate: string
|
filenameTemplate: string
|
||||||
|
/**
|
||||||
|
* Raw yt-dlp `-o` template for COLLECTION downloads (a channel/playlist from
|
||||||
|
* the Library), overriding the default `Channel/Playlist/NNN - Title` folder
|
||||||
|
* layout. Empty = the built-in default. A relative template (no absolute path,
|
||||||
|
* no `..`); yt-dlp expands its own fields (`%(uploader)s`, `%(playlist)s`,
|
||||||
|
* `%(playlist_index)s`, `%(title)s`, `%(ext)s`, …). PINCHFLAT output-path setting.
|
||||||
|
*/
|
||||||
|
collectionOutputTemplate: string
|
||||||
/** UI color theme: explicit light/dark, or 'system' to follow the OS */
|
/** UI color theme: explicit light/dark, or 'system' to follow the OS */
|
||||||
theme: ThemeMode
|
theme: ThemeMode
|
||||||
/** brand accent preset (buttons, links, selected nav item) */
|
/** brand accent preset (buttons, links, selected nav item) */
|
||||||
@@ -659,6 +667,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
defaultAudioQuality: 'Best',
|
defaultAudioQuality: 'Best',
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
|
collectionOutputTemplate: '',
|
||||||
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
|
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
|
||||||
// greeted by a bright white window despite full system-theme support.
|
// greeted by a bright white window despite full system-theme support.
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
|||||||
@@ -735,6 +735,27 @@ describe('collectionOutputTemplate', () => {
|
|||||||
)
|
)
|
||||||
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
|
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a custom template overrides the built-in layout and is left for yt-dlp to expand', () => {
|
||||||
|
const t = collectionOutputTemplate(
|
||||||
|
'C:/out',
|
||||||
|
ctx,
|
||||||
|
'%(title)s.%(ext)s',
|
||||||
|
'%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
|
||||||
|
)
|
||||||
|
expect(t.replace(/\\/g, '/')).toBe(
|
||||||
|
'C:/out/%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a blank/whitespace custom template falls back to the built-in layout', () => {
|
||||||
|
expect(collectionOutputTemplate('C:/o', ctx, 'f', '').replace(/\\/g, '/')).toBe(
|
||||||
|
'C:/o/DevChannel/Full Course/003 - f'
|
||||||
|
)
|
||||||
|
expect(collectionOutputTemplate('C:/o', ctx, 'f', ' ').replace(/\\/g, '/')).toBe(
|
||||||
|
'C:/o/DevChannel/Full Course/003 - f'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('metadata overrides (--replace-in-metadata)', () => {
|
describe('metadata overrides (--replace-in-metadata)', () => {
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ describe('parseSettingsField (CC9 schema parity)', () => {
|
|||||||
expect(parseSettingsField('filenameTemplate', '../%(title)s.%(ext)s')).toEqual(fail)
|
expect(parseSettingsField('filenameTemplate', '../%(title)s.%(ext)s')).toEqual(fail)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('collectionOutputTemplate: empty allowed; relative multi-segment ok; traversal rejected', () => {
|
||||||
|
expect(parseSettingsField('collectionOutputTemplate', '')).toEqual(ok(''))
|
||||||
|
expect(
|
||||||
|
parseSettingsField(
|
||||||
|
'collectionOutputTemplate',
|
||||||
|
' %(uploader)s/%(playlist)s/%(title)s.%(ext)s '
|
||||||
|
)
|
||||||
|
).toEqual(ok('%(uploader)s/%(playlist)s/%(title)s.%(ext)s'))
|
||||||
|
expect(parseSettingsField('collectionOutputTemplate', '../%(title)s.%(ext)s')).toEqual(fail)
|
||||||
|
expect(parseSettingsField('collectionOutputTemplate', 'C:\\abs\\%(title)s.%(ext)s')).toEqual(
|
||||||
|
fail
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('rateLimit: yt-dlp format or empty', () => {
|
it('rateLimit: yt-dlp format or empty', () => {
|
||||||
expect(parseSettingsField('rateLimit', '2.5M')).toEqual(ok('2.5M'))
|
expect(parseSettingsField('rateLimit', '2.5M')).toEqual(ok('2.5M'))
|
||||||
expect(parseSettingsField('rateLimit', ' 500K ')).toEqual(ok('500K'))
|
expect(parseSettingsField('rateLimit', ' 500K ')).toEqual(ok('500K'))
|
||||||
|
|||||||
Reference in New Issue
Block a user