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:
2026-07-02 15:44:44 -04:00
parent 72feaa62f8
commit 42b7b2e01f
8 changed files with 98 additions and 16 deletions
+14 -2
View File
@@ -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>
* 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)]
+9 -2
View File
@@ -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 <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 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
+4
View File
@@ -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)),