Route downloads into Documents\Video / Documents\Audio and simplify the home page

- Create Documents\Video and Documents\Audio on startup (ensureMediaDirs), and
  route each download into them by kind when no explicit output folder is set.
  An empty outputDir now means "sort by type"; a chosen folder still overrides
  for both kinds.
- Settings/Onboarding "Download folder" gains a placeholder + hint describing
  the blank = route-by-type behaviour.
- Trim the home download bar to the essentials: URL box, Search and Paste
  buttons, format/quality, and the Download button. Removed the private-mode
  toggle, per-download Options panel, custom-command panel, command preview, and
  the saving-to-folder line.
- Bump version to 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 05:17:16 -04:00
parent 831d0a7dc2
commit a2763f10b4
7 changed files with 58 additions and 302 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aerofetch",
"version": "0.2.0",
"version": "0.3.0",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
+7 -3
View File
@@ -1,9 +1,9 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
@@ -165,7 +165,11 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
// Output dir resolution: a per-download override wins, then the user's explicit
// global folder (Settings → Download folder), and finally — when both are blank —
// the per-kind default (Documents\Video for video, Documents\Audio for audio).
const outDir =
opts.outputDir?.trim() || settings.outputDir?.trim() || getDefaultMediaDir(opts.kind)
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
+5 -1
View File
@@ -13,7 +13,7 @@ import {
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
@@ -303,6 +303,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
+30 -5
View File
@@ -1,5 +1,6 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
@@ -16,7 +17,7 @@ import {
} from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
outputDir: '', // blank = route by kind into Documents\Video / Documents\Audio (see getDefaultMediaDir)
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -45,6 +46,31 @@ export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
/**
* The default per-kind download destination: video → Documents\Video,
* audio → Documents\Audio. Used when the user hasn't set an explicit output
* folder (Settings → Download folder), so downloads are sorted by type.
*/
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
}
/**
* Create the Documents\Video and Documents\Audio folders up front (called once
* at startup) so they exist the moment the app opens, not just after the first
* download. Best-effort: yt-dlp also creates the output dir at download time, so
* a failure here (read-only Documents, redirected folder) is non-fatal.
*/
export function ensureMediaDirs(): void {
for (const kind of ['video', 'audio'] as const) {
try {
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
} catch {
/* non-fatal — the download path will be created on demand instead */
}
}
}
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
// defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes.
@@ -100,10 +126,9 @@ export function getSettings(): Settings {
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// outputDir is intentionally left blank by default — an empty value routes each
// download into Documents\Video / Documents\Audio by kind (see buildCommand).
// Only an explicit user choice (Settings → Download folder) overrides that.
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
+5 -290
View File
@@ -3,8 +3,6 @@ import {
Input,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -16,36 +14,19 @@ import {
import {
ArrowDownloadRegular,
ClipboardPasteRegular,
FolderRegular,
SearchRegular,
VideoClipRegular,
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
AppsListRegular
} from '@fluentui/react-icons'
import type {
MediaInfo,
FormatOption,
PlaylistInfo,
DownloadOptions,
CommandPreviewResult,
StartDownloadOptions
} from '@shared/ipc'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { useTemplates } from '../store/templates'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
/** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean {
@@ -184,33 +165,6 @@ const useStyles = makeStyles({
spacer: {
flexGrow: 1
},
// --- per-download options panel ---
optionsBar: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
optionsPanel: {
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- command preview ---
previewPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewCommandText: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
},
// --- playlist selection ---
plPanel: {
display: 'flex',
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3,
maxWidth: '360px'
},
folderPath: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const downloadOptions = useSettings((s) => s.downloadOptions)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Per-download options override (null = use the persisted defaults).
const [override, setOverride] = useState<DownloadOptions | null>(null)
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Private mode — sticky like an incognito tab; the download still runs and
// appears in the queue, but its completion is never recorded to history.
const [incognito, setIncognito] = useState(false)
// Per-download custom-command override: undefined = defer to the settings
// default (customCommandEnabled + defaultTemplateId); null = explicit
// "None" for just this download; a string = a chosen template id override.
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
const [showCustomCommand, setShowCustomCommand] = useState(false)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
const templates = useTemplates((s) => s.templates)
const effectiveTemplateId =
templateOverride !== undefined
? templateOverride
: customCommandEnabled
? settingsDefaultTemplateId
: null
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -393,7 +305,6 @@ export function DownloadBar(): React.JSX.Element {
setSuggestion(null)
}
const folder = outputDir || 'your Downloads folder'
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
const selectedFormat: FormatOption | undefined = usingFormats
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
@@ -468,61 +379,6 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Resolves the per-download custom-command override to the raw extra-args
// string sent to main; undefined defers to the settings default there.
function resolveExtraArgs(): string | undefined {
if (templateOverride === undefined) return undefined
if (templateOverride === null) return ''
return templates.find((t) => t.id === templateOverride)?.args ?? ''
}
// The StartDownloadOptions the current form state would produce — shared by
// the real download() call and the command-preview button so they can never
// drift apart.
function currentStartOptions(): StartDownloadOptions {
const base: StartDownloadOptions = {
id: 'preview',
url: url.trim(),
kind,
quality,
outputDir: outputDir || undefined,
options: override ?? undefined,
extraArgs: resolveExtraArgs()
}
if (usingFormats && selectedFormat) {
return {
...base,
kind: 'video',
quality: selectedFormat.label,
formatId: selectedFormat.id,
formatHasAudio: selectedFormat.hasAudio
}
}
return base
}
async function previewCommand(): Promise<void> {
if (!url.trim() || previewing) return
setPreviewing(true)
setPreviewResult(null)
try {
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
} catch (e) {
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setPreviewing(false)
}
}
async function copyPreview(): Promise<void> {
if (!previewResult?.command) return
try {
await navigator.clipboard.writeText(previewResult.command)
} catch {
/* clipboard blocked — ignore in preview */
}
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
},
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
addFromUrl(trimmed, kind, quality, meta)
}
setUrl('')
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
durationLabel: e.durationLabel
})
}
setUrl('')
@@ -769,136 +614,6 @@ export function DownloadBar(): React.JSX.Element {
</Button>
)}
</div>
<div className={styles.optionsBar}>
{incognito ? <EyeOffRegular /> : <EyeRegular />}
<Switch
checked={incognito}
onChange={(_, d) => setIncognito(d.checked)}
label={incognito ? 'Private — wont be saved to history' : 'Private mode'}
/>
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<OptionsRegular />}
iconPosition="before"
onClick={() => setShowOptions((v) => !v)}
>
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{override && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
Reset to defaults
</Button>
</>
)}
</div>
{showOptions && (
<div className={styles.optionsPanel}>
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
</div>
)}
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<CodeRegular />}
iconPosition="before"
onClick={() => setShowCustomCommand((v) => !v)}
>
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{templateOverride !== undefined && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
Reset to default
</Button>
</>
)}
<div className={styles.spacer} />
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
<Button
appearance="subtle"
size="small"
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
iconPosition="before"
onClick={previewCommand}
disabled={!url.trim() || previewing}
>
Preview command
</Button>
</Hint>
</div>
{showCustomCommand && (
<div className={styles.optionsPanel}>
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
<Select
aria-label="Custom command template"
value={effectiveTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
/>
</Field>
{templates.length === 0 && (
<Caption1 className={styles.previewMeta}>
No templates yet add one in Settings Custom commands.
</Caption1>
)}
</div>
)}
{previewResult && (
<div className={styles.previewPanel}>
<div className={styles.plHeader}>
<Caption1 className={styles.plHeaderText}>
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
</Caption1>
{previewResult.ok && (
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
Copy
</Button>
)}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setPreviewResult(null)}
aria-label="Dismiss preview"
/>
</div>
{previewResult.ok ? (
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
) : (
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
{previewResult.error}
</Caption1>
)}
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
Saving to {folder}
</Caption1>
<Hint label="Change download folder" placement="top" align="start">
<Button size="small" appearance="transparent" onClick={chooseOutputDir}>
Change
</Button>
</Hint>
</div>
</div>
)
}
+5 -1
View File
@@ -118,12 +118,16 @@ export function Onboarding(): React.JSX.Element {
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<Field
label="Download folder"
hint="Leave blank to sort downloads into Documents\Video and Documents\Audio by type, or pick a folder for everything."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
placeholder="Documents\Video and Documents\Audio (by type)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
+5 -1
View File
@@ -328,12 +328,16 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Downloads</Subtitle2>
</div>
<Field label="Download folder">
<Field
label="Download folder"
hint="Leave blank to sort downloads by type into Documents\Video and Documents\Audio. Choose a folder to send everything there instead."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
placeholder="Documents\Video and Documents\Audio (by type)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>