From 42b7b2e01f1854800490f45454864d74e38dfa6a Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 2 Jul 2026 15:44:44 -0400 Subject: [PATCH] feat(roadmap): surface stale checkboxes + collection output-path setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ROADMAP.md | 27 ++++++++++--------- src/main/core/buildArgs.ts | 16 +++++++++-- src/main/download.ts | 11 ++++++-- src/main/settingsSchema.ts | 4 +++ .../src/views/settings/FilenamesCard.tsx | 12 +++++++++ src/shared/ipc.ts | 9 +++++++ test/buildArgs.test.ts | 21 +++++++++++++++ test/settingsSchema.test.ts | 14 ++++++++++ 8 files changed, 98 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9ccdaba..53b262e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,12 +127,13 @@ extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write the Custom commands card). The **"run custom command" mode** is `Settings.customCommandEnabled`, applied to every new download unless overridden per-download in the download bar's Custom command panel. -- [ ] **Command preview (plumbed, not surfaced).** `IpcChannels.commandPreview` → - `previewCommand()` (`src/main/download.ts`) builds the exact argv via the same - `buildCommand()` path a real download would take, then renders it with - `formatCommandLine`. The **Preview command** button is NOT yet in the download bar — - only the main/preload/buildArgs chain exists. Wire it into the per-download Options - panel or remove the chain (audit M5/UX1). +- [x] **Command preview (surfaced).** `IpcChannels.commandPreview` → `previewCommand()` + (`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path a real + download would take, then renders it with `formatCommandLine`. **Now surfaced:** the + DownloadBar's **"Show command"** toggle (`useDownloadBar.toggleCommandPreview` → + `window.api.previewCommand(...)`) renders the exact argv in an inline panel that reflects the + 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 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. @@ -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`, `src/main/download.ts`). Gated by a new **Notify when downloads finish** switch (`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window. -- [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows - through `buildItem` → history-skip → the QueueItem "Private" badge, and - `store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI - control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1). - Note `download.ts` would still log a failure / show a completion title for a private item, - so the "private" promise currently covers history only (audit L136). +- [x] **Private / incognito mode (surfaced).** `DownloadItem.incognito` flows through `buildItem` + → history-skip → the QueueItem "Private" badge, and `store/downloads.ts` checks it before + ever calling `useHistory().add(...)`. **Now surfaced:** the DownloadBar's Advanced options + panel carries an **"Incognito mode (no logging, no history, no cookies)"** checkbox + (`useDownloadBar.setIncognito`), one-shot per download. The main-process promise is also + 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 `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 diff --git a/src/main/core/buildArgs.ts b/src/main/core/buildArgs.ts index 9105f34..e828edc 100644 --- a/src/main/core/buildArgs.ts +++ b/src/main/core/buildArgs.ts @@ -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 * /// - * 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 * 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( outDir: string, c: CollectionContext, - baseFilename: string + baseFilename: string, + customTemplate?: 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 nnn = String(n).padStart(3, '0') const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)] diff --git a/src/main/download.ts b/src/main/download.ts index 2832169..933ea53 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -200,10 +200,17 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim() const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind) // A collection (media-manager) download is filed into // - // - 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 outputTemplate = opts.collection - ? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s') + ? collectionOutputTemplate( + outDir, + opts.collection, + '%(title)s.%(ext)s', + settings.collectionOutputTemplate + ) : join(outDir, filenameTemplate) // Per-download override wins; otherwise use the persisted defaults. const options = opts.options ?? settings.downloadOptions diff --git a/src/main/settingsSchema.ts b/src/main/settingsSchema.ts index 2085664..1544b3e 100644 --- a/src/main/settingsSchema.ts +++ b/src/main/settingsSchema.ts @@ -87,6 +87,10 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)), audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)), 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), defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS), rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)), diff --git a/src/renderer/src/views/settings/FilenamesCard.tsx b/src/renderer/src/views/settings/FilenamesCard.tsx index 5b7f26d..5aebabc 100644 --- a/src/renderer/src/views/settings/FilenamesCard.tsx +++ b/src/renderer/src/views/settings/FilenamesCard.tsx @@ -6,6 +6,7 @@ import { useSettingsStyles } from './settingsStyles' export function FilenamesCard(): React.JSX.Element { const styles = useSettingsStyles() const filenameTemplate = useSettings((s) => s.filenameTemplate) + const collectionOutputTemplate = useSettings((s) => s.collectionOutputTemplate) const restrictFilenames = useSettings((s) => s.restrictFilenames) const useDownloadArchive = useSettings((s) => s.useDownloadArchive) 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')} </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 label="Restrict filenames" hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells." diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 18f1031..6fd1fd5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -565,6 +565,14 @@ export interface Settings { maxConcurrent: number /** yt-dlp output template, e.g. '%(title)s.%(ext)s' */ 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 */ theme: ThemeMode /** brand accent preset (buttons, links, selected nav item) */ @@ -659,6 +667,7 @@ export const DEFAULT_SETTINGS: Settings = { defaultAudioQuality: 'Best', maxConcurrent: 2, filenameTemplate: '%(title)s.%(ext)s', + collectionOutputTemplate: '', // 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. theme: 'system', diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index 50d096a..df80b53 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -735,6 +735,27 @@ describe('collectionOutputTemplate', () => { ) 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)', () => { diff --git a/test/settingsSchema.test.ts b/test/settingsSchema.test.ts index 7847954..64529dd 100644 --- a/test/settingsSchema.test.ts +++ b/test/settingsSchema.test.ts @@ -85,6 +85,20 @@ describe('parseSettingsField (CC9 schema parity)', () => { 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', () => { expect(parseSettingsField('rateLimit', '2.5M')).toEqual(ok('2.5M')) expect(parseSettingsField('rateLimit', ' 500K ')).toEqual(ok('500K'))