Separate per-kind video/audio folders, defaulting to Documents\Video and Documents\Audio
Existing installs had outputDir persisted to the Downloads folder (auto-filled by earlier versions), so the v0.3.0 per-kind routing never triggered — downloads kept going to Downloads. Replace the single outputDir setting with independent videoDir and audioDir settings: - Settings model: drop Settings.outputDir; add videoDir + audioDir (both blank by default). A blank value routes that kind into Documents\Video / Documents\Audio; a chosen folder overrides it. The stale outputDir key in old settings files is simply ignored, so existing users now get the Documents defaults. - buildCommand routes by kind: per-download override → per-kind folder → default. - The renderer no longer sends a global outputDir, so main always routes by kind. - Settings: the single "Download folder" field becomes separate "Video folder" and "Audio folder" pickers, each with a Browse + Reset and a Documents\… placeholder. Store gains chooseDir(target) / clearDir(target). - Onboarding: replace the folder picker with a short note about the two default folders (changeable in Settings). - Bump version to 0.3.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
|
||||
@@ -166,10 +166,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
const settings = getSettings()
|
||||
// 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)
|
||||
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||
const outDir = opts.outputDir?.trim() || perKindDir || 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'
|
||||
|
||||
+14
-6
@@ -17,7 +17,10 @@ import {
|
||||
} from '@shared/ipc'
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
outputDir: '', // blank = route by kind into Documents\Video / Documents\Audio (see getDefaultMediaDir)
|
||||
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
||||
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
||||
videoDir: '',
|
||||
audioDir: '',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -126,9 +129,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
|
||||
// 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.
|
||||
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||
// Only an explicit user choice (Settings → folders) 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)
|
||||
@@ -212,8 +215,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
// group (it merges field changes locally before calling setSettings).
|
||||
s.set('downloadOptions', sanitizeOptions(value))
|
||||
break
|
||||
case 'outputDir':
|
||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
||||
case 'videoDir':
|
||||
case 'audioDir':
|
||||
// An empty string is allowed — it clears the override and restores the
|
||||
// Documents\Video / Documents\Audio default for that kind.
|
||||
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||
s.set(key, value.trim())
|
||||
}
|
||||
break
|
||||
case 'filenameTemplate':
|
||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Body1,
|
||||
Caption1,
|
||||
Field,
|
||||
Input,
|
||||
Button,
|
||||
makeStyles,
|
||||
tokens,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
FolderRegular,
|
||||
ClipboardPasteRegular,
|
||||
HistoryRegular,
|
||||
OptionsRegular,
|
||||
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
|
||||
justifyContent: 'center',
|
||||
fontSize: '26px'
|
||||
},
|
||||
folderRow: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
folderInput: {
|
||||
flexGrow: 1
|
||||
folderNote: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
tips: {
|
||||
display: 'flex',
|
||||
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
||||
|
||||
export function Onboarding(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const outputDir = useSettings((s) => s.outputDir)
|
||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
return (
|
||||
@@ -118,22 +109,12 @@ export function Onboarding(): React.JSX.Element {
|
||||
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||
</Body1>
|
||||
|
||||
<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}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Where downloads go">
|
||||
<Caption1 className={styles.folderNote}>
|
||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
||||
in Settings.
|
||||
</Caption1>
|
||||
</Field>
|
||||
|
||||
<div className={styles.tips}>
|
||||
|
||||
@@ -170,8 +170,10 @@ const UPDATE_CHANNEL_OPTIONS = [
|
||||
export function SettingsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
|
||||
const outputDir = useSettings((s) => s.outputDir)
|
||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
||||
const videoDir = useSettings((s) => s.videoDir)
|
||||
const audioDir = useSettings((s) => s.audioDir)
|
||||
const chooseDir = useSettings((s) => s.chooseDir)
|
||||
const clearDir = useSettings((s) => s.clearDir)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||
@@ -329,20 +331,48 @@ export function SettingsView(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
<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."
|
||||
label="Video folder"
|
||||
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={outputDir}
|
||||
placeholder="Documents\Video and Documents\Audio (by type)"
|
||||
value={videoDir}
|
||||
placeholder="Documents\Video (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{videoDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Audio folder"
|
||||
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={audioDir}
|
||||
placeholder="Documents\Audio (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{audioDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import App from './App'
|
||||
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||
if (import.meta.env.DEV && !window.api) {
|
||||
const MOCK_SETTINGS: Settings = {
|
||||
outputDir: 'C:\\Users\\you\\Downloads',
|
||||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -121,11 +122,11 @@ if (import.meta.env.DEV && !window.api) {
|
||||
previewCommand: async (opts) => {
|
||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||||
return {
|
||||
ok: true,
|
||||
command:
|
||||
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
|
||||
`${extra} -- ${opts.url}`
|
||||
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||
}
|
||||
},
|
||||
updateYtdlp: async (channel) => {
|
||||
|
||||
@@ -247,7 +247,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
url: item.url,
|
||||
kind: item.kind,
|
||||
quality: item.quality,
|
||||
outputDir: useSettings.getState().outputDir || undefined,
|
||||
// No outputDir here: main routes each download into the user's per-kind
|
||||
// folder (Settings → Video/Audio folder), or the Documents\… default.
|
||||
formatId: item.formatId,
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
const FALLBACK: Settings = {
|
||||
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
|
||||
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '',
|
||||
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -35,7 +36,10 @@ interface SettingsState extends Settings {
|
||||
/** true once persisted settings have loaded (always true in preview) */
|
||||
loaded: boolean
|
||||
update: (partial: Partial<Settings>) => void
|
||||
chooseOutputDir: () => void
|
||||
/** open the OS folder picker and store the result as the video or audio folder */
|
||||
chooseDir: (target: 'videoDir' | 'audioDir') => void
|
||||
/** clear a per-kind folder override, restoring its Documents\… default */
|
||||
clearDir: (target: 'videoDir' | 'audioDir') => void
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
@@ -47,12 +51,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||
},
|
||||
|
||||
chooseOutputDir: () => {
|
||||
chooseDir: (target) => {
|
||||
if (PREVIEW) return
|
||||
window.api.chooseFolder().then((dir) => {
|
||||
if (dir) get().update({ outputDir: dir })
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
clearDir: (target) => get().update({ [target]: '' })
|
||||
}))
|
||||
|
||||
// Load persisted settings on startup.
|
||||
|
||||
+4
-2
@@ -340,8 +340,10 @@ export type DownloadEvent =
|
||||
|
||||
/** Persisted user settings (electron-store). */
|
||||
export interface Settings {
|
||||
/** absolute output directory (empty string resolves to the OS Downloads folder) */
|
||||
outputDir: string
|
||||
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
||||
videoDir: string
|
||||
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||
audioDir: string
|
||||
defaultKind: MediaKind
|
||||
defaultVideoQuality: string
|
||||
defaultAudioQuality: string
|
||||
|
||||
Reference in New Issue
Block a user