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",
|
"name": "aerofetch",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
|
|||||||
@@ -166,10 +166,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
|||||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||||
// global folder (Settings → Download folder), and finally — when both are blank —
|
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||||
// the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||||
const outDir =
|
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||||
opts.outputDir?.trim() || settings.outputDir?.trim() || getDefaultMediaDir(opts.kind)
|
const outDir = opts.outputDir?.trim() || 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; 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'
|
||||||
|
|||||||
+14
-6
@@ -17,7 +17,10 @@ import {
|
|||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
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',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -126,9 +129,9 @@ export function getSettings(): Settings {
|
|||||||
// `set`, so only write when something actually changed — otherwise this churns
|
// `set`, so only write when something actually changed — otherwise this churns
|
||||||
// the settings file on every read. (audit P1)
|
// the settings file on every read. (audit P1)
|
||||||
const cur = s.store
|
const cur = s.store
|
||||||
// outputDir is intentionally left blank by default — an empty value routes each
|
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||||
// download into Documents\Video / Documents\Audio by kind (see buildCommand).
|
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||||
// Only an explicit user choice (Settings → Download folder) overrides that.
|
// Only an explicit user choice (Settings → folders) overrides that.
|
||||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||||
// but only persist when sanitizing actually altered the stored value.
|
// but only persist when sanitizing actually altered the stored value.
|
||||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
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).
|
// group (it merges field changes locally before calling setSettings).
|
||||||
s.set('downloadOptions', sanitizeOptions(value))
|
s.set('downloadOptions', sanitizeOptions(value))
|
||||||
break
|
break
|
||||||
case 'outputDir':
|
case 'videoDir':
|
||||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
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
|
break
|
||||||
case 'filenameTemplate':
|
case 'filenameTemplate':
|
||||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Body1,
|
Body1,
|
||||||
Caption1,
|
Caption1,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
FolderRegular,
|
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '26px'
|
fontSize: '26px'
|
||||||
},
|
},
|
||||||
folderRow: {
|
folderNote: {
|
||||||
display: 'flex',
|
color: tokens.colorNeutralForeground3
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'flex-end'
|
|
||||||
},
|
|
||||||
folderInput: {
|
|
||||||
flexGrow: 1
|
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
|
|
||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
return (
|
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.
|
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
|
||||||
<Field
|
<Field label="Where downloads go">
|
||||||
label="Download folder"
|
<Caption1 className={styles.folderNote}>
|
||||||
hint="Leave blank to sort downloads into Documents\Video and Documents\Audio by type, or pick a folder for everything."
|
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
|
||||||
<div className={styles.folderRow}>
|
in Settings.
|
||||||
<Input
|
</Caption1>
|
||||||
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>
|
</Field>
|
||||||
|
|
||||||
<div className={styles.tips}>
|
<div className={styles.tips}>
|
||||||
|
|||||||
@@ -170,8 +170,10 @@ const UPDATE_CHANNEL_OPTIONS = [
|
|||||||
export function SettingsView(): React.JSX.Element {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
const videoDir = useSettings((s) => s.videoDir)
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
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 defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
@@ -329,20 +331,48 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Download folder"
|
label="Video folder"
|
||||||
hint="Leave blank to sort downloads by type into Documents\Video and Documents\Audio. Choose a folder to send everything there instead."
|
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||||
>
|
>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={outputDir}
|
value={videoDir}
|
||||||
placeholder="Documents\Video and Documents\Audio (by type)"
|
placeholder="Documents\Video (default)"
|
||||||
contentBefore={<FolderRegular />}
|
contentBefore={<FolderRegular />}
|
||||||
/>
|
/>
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
Browse
|
Browse
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import App from './App'
|
|||||||
// In the real Electron app this branch is skipped — preload provides window.api.
|
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||||
if (import.meta.env.DEV && !window.api) {
|
if (import.meta.env.DEV && !window.api) {
|
||||||
const MOCK_SETTINGS: Settings = {
|
const MOCK_SETTINGS: Settings = {
|
||||||
outputDir: 'C:\\Users\\you\\Downloads',
|
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||||||
|
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -121,11 +122,11 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
previewCommand: async (opts) => {
|
previewCommand: async (opts) => {
|
||||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||||
|
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command:
|
command:
|
||||||
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
|
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||||
`${extra} -- ${opts.url}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateYtdlp: async (channel) => {
|
updateYtdlp: async (channel) => {
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
quality: item.quality,
|
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,
|
formatId: item.formatId,
|
||||||
formatHasAudio: item.formatHasAudio,
|
formatHasAudio: item.formatHasAudio,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
|||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
const FALLBACK: Settings = {
|
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',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -35,7 +36,10 @@ interface SettingsState extends Settings {
|
|||||||
/** true once persisted settings have loaded (always true in preview) */
|
/** true once persisted settings have loaded (always true in preview) */
|
||||||
loaded: boolean
|
loaded: boolean
|
||||||
update: (partial: Partial<Settings>) => void
|
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) => ({
|
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(() => {})
|
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||||
},
|
},
|
||||||
|
|
||||||
chooseOutputDir: () => {
|
chooseDir: (target) => {
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
window.api.chooseFolder().then((dir) => {
|
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.
|
// Load persisted settings on startup.
|
||||||
|
|||||||
+4
-2
@@ -340,8 +340,10 @@ export type DownloadEvent =
|
|||||||
|
|
||||||
/** Persisted user settings (electron-store). */
|
/** Persisted user settings (electron-store). */
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** absolute output directory (empty string resolves to the OS Downloads folder) */
|
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
||||||
outputDir: string
|
videoDir: string
|
||||||
|
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||||
|
audioDir: string
|
||||||
defaultKind: MediaKind
|
defaultKind: MediaKind
|
||||||
defaultVideoQuality: string
|
defaultVideoQuality: string
|
||||||
defaultAudioQuality: string
|
defaultAudioQuality: string
|
||||||
|
|||||||
Reference in New Issue
Block a user