Merge feat/documents-folders-and-ui: Documents folders, collapsible sidebar, theme switch
# Conflicts: # src/main/download.ts # src/renderer/src/components/DownloadBar.tsx
This commit is contained in:
+13
-11
@@ -1,7 +1,7 @@
|
||||
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,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getFfprobePath,
|
||||
getSystem32Path
|
||||
} 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'
|
||||
@@ -175,16 +175,18 @@ 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()
|
||||
// A per-download outputDir override must clear the same safety check the
|
||||
// persisted setting does (absolute path only); a renderer-supplied override is
|
||||
// otherwise untrusted — it dictates where downloaded files get written. An
|
||||
// unsafe override is ignored in favour of the validated setting, then the OS
|
||||
// Downloads folder. (audit F4)
|
||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||
// 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).
|
||||
//
|
||||
// A per-download outputDir override must clear the same safety check the persisted
|
||||
// setting does (absolute path only); a renderer-supplied override is otherwise
|
||||
// untrusted — it dictates where downloaded files get written. An unsafe override is
|
||||
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||
const override = opts.outputDir?.trim()
|
||||
const outDir =
|
||||
(override && isSafeOutputDir(override) ? override : '') ||
|
||||
settings.outputDir ||
|
||||
app.getPath('downloads')
|
||||
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.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.
|
||||
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||
|
||||
+7
-1
@@ -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'
|
||||
@@ -179,6 +179,8 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
@@ -331,6 +333,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)
|
||||
})
|
||||
|
||||
+40
-7
@@ -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,10 @@ import {
|
||||
} from '@shared/ipc'
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
outputDir: '', // resolved to the OS Downloads folder on first read
|
||||
// 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)',
|
||||
@@ -45,6 +49,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 +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
|
||||
if (!cur.outputDir) {
|
||||
// Fill in the real Downloads path the first time, and persist it once.
|
||||
s.set('outputDir', app.getPath('downloads'))
|
||||
}
|
||||
// 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)
|
||||
@@ -187,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())) {
|
||||
|
||||
Reference in New Issue
Block a user