diff --git a/package.json b/package.json index cdf0e4b..867ec26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/download.ts b/src/main/download.ts index 0e24b01..8e70e15 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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 // // - folders; an ordinary download uses the flat filenameTemplate. const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s' diff --git a/src/main/index.ts b/src/main/index.ts index 42ca203..8e7b2f5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) }) diff --git a/src/main/settings.ts b/src/main/settings.ts index 8b4add0..ea0b813 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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) diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 2bf4b8e..a76f075 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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 — won’t 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> ) } diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 519ac20..4e76623 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -118,12 +118,16 @@ 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"> + <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}> diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 6558517..cd8e58d 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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}>