Add custom download folders and refine RSS dialog #37 (#44)

* Add custom download folders

* Refine RSS defaults and dialog layout

* Record template folder in download path

* Store template folder in history paths

* Resolve template vars for history paths

* Default RSS directory to Subscriptions
This commit is contained in:
Nexmoe
2025-12-20 13:05:12 +08:00
committed by GitHub
parent dbcd77963f
commit 86e6b93476
12 changed files with 342 additions and 84 deletions

View File

@@ -4,8 +4,17 @@ import { resolvePathWithHome } from '../utils/path-helpers'
export const sanitizeFilenameTemplate = (template: string): string => {
const trimmed = template.trim()
const sanitized = trimmed.replace(/[/\\]+/g, '-')
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
if (!trimmed) {
return '%(title)s via VidBee.%(ext)s'
}
const normalized = trimmed.replace(/\\/g, '/')
const safeParts = normalized
.split('/')
.map((part) => part.trim())
.filter((part) => part !== '' && part !== '.' && part !== '..')
.map((part) => part.replace(/[<>:"|?*]/g, '-').replace(/[. ]+$/g, ''))
.filter((part) => part !== '')
return safeParts.length === 0 ? '%(title)s via VidBee.%(ext)s' : safeParts.join('/')
}
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {

View File

@@ -1,6 +1,5 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray'
@@ -12,20 +11,12 @@ class SettingsService extends IpcService {
@IpcMethod()
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
const value = settingsManager.get(key)
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
return sanitizeFilenameTemplate(value) as AppSettings[K]
}
return value
return settingsManager.get(key)
}
@IpcMethod()
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
} else {
settingsManager.set(key, value)
}
settingsManager.set(key, value)
if (key === 'language') {
updateTrayMenu()
@@ -46,22 +37,11 @@ class SettingsService extends IpcService {
@IpcMethod()
getAll(_context: IpcContext): AppSettings {
const settings = settingsManager.getAll()
if (typeof settings.subscriptionFilenameTemplate === 'string') {
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
settings.subscriptionFilenameTemplate
)
}
return settings
return settingsManager.getAll()
}
@IpcMethod()
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
if (typeof settings.subscriptionFilenameTemplate === 'string') {
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
settings.subscriptionFilenameTemplate
)
}
settingsManager.setAll(settings)
if (settings.language) {

View File

@@ -1,3 +1,4 @@
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type {
SubscriptionCreatePayload,
@@ -5,6 +6,7 @@ import type {
SubscriptionRule,
SubscriptionUpdatePayload
} from '../../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
@@ -112,6 +114,7 @@ class SubscriptionService extends IpcService {
): Promise<SubscriptionRule> {
const resolved = resolveFeedFromInput(options.url)
const settings = settingsManager.getAll()
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
const payload: SubscriptionCreatePayload = {
sourceUrl: resolved.sourceUrl,
feedUrl: resolved.feedUrl,
@@ -120,9 +123,9 @@ class SubscriptionService extends IpcService {
tags: options.tags,
onlyDownloadLatest:
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
downloadDirectory: options.downloadDirectory || settings.downloadPath,
downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,
namingTemplate: sanitizeFilenameTemplate(
options.namingTemplate || settings.subscriptionFilenameTemplate
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
),
enabled: options.enabled ?? true
}

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import path from 'node:path'
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
import type {
@@ -12,7 +13,11 @@ import type {
VideoFormat,
VideoInfo
} from '../../shared/types'
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
import {
buildDownloadArgs,
resolveVideoFormatSelector,
sanitizeFilenameTemplate
} from '../download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
@@ -31,6 +36,115 @@ interface DownloadProcess {
process: YTDlpEventEmitter
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
}
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
scopedLoggers.download.error('Failed to ensure download directory:', error)
}
}
const sanitizeFolderName = (value: string, fallback: string): string => {
const trimmed = value.trim()
if (!trimmed) {
return fallback
}
const sanitized = trimmed
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
return sanitized || fallback
}
const isLikelyChannelUrl = (url: string): boolean => {
const normalized = url.toLowerCase()
if (normalized.includes('list=')) {
return false
}
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
}
const resolveAutoPlaylistDownloadPath = (
basePath: string,
info: PlaylistInfo,
url: string
): string => {
const kindFolder = isLikelyChannelUrl(url) ? 'Channels' : 'Playlists'
const title = sanitizeFolderName(
info.title || (kindFolder === 'Channels' ? 'Channel' : 'Playlist'),
kindFolder === 'Channels' ? 'Channel' : 'Playlist'
)
return path.join(basePath, kindFolder, title)
}
const resolveAutoVideoDownloadPath = (basePath: string, info?: VideoInfo): string => {
const root = path.join(basePath, 'Videos')
if (!info) {
return root
}
const label = info.uploader?.trim() || info.title?.trim()
if (!label) {
return root
}
return path.join(root, sanitizeFolderName(label, 'Video'))
}
const sanitizeTemplateValue = (value: string): string =>
value
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
const resolveTemplateToken = (token: string, info?: VideoInfo): string | undefined => {
if (!info) {
return undefined
}
switch (token) {
case 'uploader':
return info.uploader
case 'title':
return info.title
case 'id':
return info.id
case 'channel':
return info.uploader
case 'extractor':
return info.extractor_key
default:
return undefined
}
}
const resolveHistoryDownloadPath = (
basePath: string,
filenameTemplate?: string,
info?: VideoInfo
): string => {
if (!filenameTemplate?.trim()) {
return basePath
}
const safeTemplate = sanitizeFilenameTemplate(filenameTemplate)
const resolvedTemplate = safeTemplate.replace(/%\(([^)]+)\)s/g, (match, token) => {
const value = resolveTemplateToken(token, info)
if (!value) {
return match
}
return sanitizeTemplateValue(value)
})
const templateDir = path.posix.dirname(resolvedTemplate)
if (templateDir === '.' || templateDir === '/') {
return basePath
}
if (/%\([^)]+\)s/.test(templateDir)) {
return basePath
}
return path.join(basePath, templateDir)
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue
@@ -316,6 +430,10 @@ class DownloadEngine extends EventEmitter {
const rangeEnd = Math.max(requestedStart, requestedEnd)
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
const settings = settingsManager.getAll()
const resolvedDownloadPath =
options.customDownloadPath?.trim() ||
resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)
ensureDirectoryExists(resolvedDownloadPath)
const selectedEntries = rawEntries.filter((entry) => {
if (!entry.url) {
@@ -339,7 +457,8 @@ class DownloadEngine extends EventEmitter {
url: entry.url,
type: options.type,
format: options.format,
audioFormat: options.type === 'audio' ? options.format : undefined
audioFormat: options.type === 'audio' ? options.format : undefined,
customDownloadPath: resolvedDownloadPath
}
const createdAt = Date.now()
@@ -370,7 +489,7 @@ class DownloadEngine extends EventEmitter {
title: entry.title,
status: 'pending',
downloadedAt: createdAt,
downloadPath: settings.downloadPath,
downloadPath: resolvedDownloadPath,
playlistId: groupId,
playlistTitle: playlistInfo.title,
playlistIndex: entry.index,
@@ -400,6 +519,12 @@ class DownloadEngine extends EventEmitter {
const settings = settingsManager.getAll()
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
const origin = options.origin ?? 'manual'
const historyDownloadPath = resolveHistoryDownloadPath(
targetDownloadPath,
options.customFilenameTemplate
)
ensureDirectoryExists(targetDownloadPath)
ensureDirectoryExists(historyDownloadPath)
const item: DownloadItem = {
id,
@@ -419,7 +544,7 @@ class DownloadEngine extends EventEmitter {
title: item.title,
status: 'pending',
downloadedAt: createdAt,
downloadPath: targetDownloadPath,
downloadPath: historyDownloadPath,
tags: options.tags,
origin,
subscriptionId: options.subscriptionId
@@ -431,7 +556,7 @@ class DownloadEngine extends EventEmitter {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const defaultDownloadPath = settings.downloadPath
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
// Set environment variables for proper encoding on Windows
if (process.platform === 'win32') {
@@ -482,6 +607,19 @@ class DownloadEngine extends EventEmitter {
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
}
if (!options.customDownloadPath?.trim()) {
resolvedDownloadPath = resolveAutoVideoDownloadPath(defaultDownloadPath, videoInfo)
options.customDownloadPath = resolvedDownloadPath
}
const historyDownloadPath = resolveHistoryDownloadPath(
resolvedDownloadPath,
options.customFilenameTemplate,
videoInfo
)
ensureDirectoryExists(historyDownloadPath)
this.upsertHistoryEntry(id, options, { downloadPath: historyDownloadPath })
const applySelectedFormat = (formatId: string | undefined): boolean => {
if (!formatId) {
return false

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs'
import log from 'electron-log/main'
import Parser from 'rss-parser'
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
import { settingsManager } from '../settings'
import { downloadEngine } from './download-engine'
import { historyManager } from './history-manager'
@@ -406,7 +407,7 @@ export class SubscriptionScheduler extends EventEmitter {
const settings = settingsManager.getAll()
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
const namingTemplate =
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
ensureDirectoryExists(downloadDirectory)
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))

View File

@@ -10,7 +10,6 @@ const ElectronStore = require('electron-store')
const Store = ElectronStore.default || ElectronStore
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
const ensureDirectoryExists = (dir: string) => {
try {
fs.mkdirSync(dir, { recursive: true })

View File

@@ -1,3 +1,9 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@renderer/components/ui/accordion'
import { Button } from '@renderer/components/ui/button'
import {
Dialog,
@@ -13,7 +19,7 @@ import { Switch } from '@renderer/components/ui/switch'
import { ipcServices } from '@renderer/lib/ipc'
import { settingsAtom } from '@renderer/store/settings'
import { resolveFeedAtom } from '@renderer/store/subscriptions'
import type { SubscriptionRule } from '@shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { ChevronRight } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
@@ -26,7 +32,15 @@ const sanitizeCommaList = (value: string) =>
.map((entry) => entry.trim())
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
const sanitizeTemplateInput = (value: string) => value.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
const buildDefaultSubscriptionDirectory = (downloadPath: string) => {
const trimmed = downloadPath.trim().replace(/[\\/]+$/, '')
if (!trimmed) {
return 'Subscriptions'
}
return `${trimmed}/Subscriptions`
}
export interface SubscriptionFormData {
url?: string
@@ -69,7 +83,7 @@ export function SubscriptionFormDialog({
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
const prevDefaultPathRef = useRef(settings.downloadPath)
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
const urlInputId = useId()
// Initialize form values based on mode
@@ -91,22 +105,15 @@ export function SubscriptionFormDialog({
setKeywords('')
setTags('')
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
setDownloadDirectory(settings.downloadPath)
setNamingTemplate(settings.subscriptionFilenameTemplate)
setDownloadDirectory(buildDefaultSubscriptionDirectory(settings.downloadPath))
setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)
}
}, [
open,
mode,
subscription,
settings.subscriptionOnlyLatestDefault,
settings.downloadPath,
settings.subscriptionFilenameTemplate
])
}, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])
// Sync download directory with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
const newPath = settings.downloadPath
const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)
setDownloadDirectory((prev) => {
if (!prev || prev === prevDefaultPathRef.current) {
return newPath
@@ -117,13 +124,6 @@ export function SubscriptionFormDialog({
}
}, [settings.downloadPath, mode])
// Sync naming template with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
setNamingTemplate(settings.subscriptionFilenameTemplate)
}
}, [settings.subscriptionFilenameTemplate, mode])
// Sync onlyLatest with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
@@ -265,14 +265,6 @@ export function SubscriptionFormDialog({
</div>
)}
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.customDirectory')}</Label>
<div className="flex gap-2">
@@ -282,17 +274,34 @@ export function SubscriptionFormDialog({
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
<AccordionContent className="space-y-3">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) =>
setNamingTemplate(sanitizeTemplateInput(event.target.value))
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<DialogFooter>
{mode === 'add' && (

View File

@@ -21,6 +21,8 @@ interface AdvancedOptionsProps {
onStartTimeChange: (value: string) => void
onEndTimeChange: (value: string) => void
onDownloadSubsChange: (value: boolean) => void
customDownloadPath: string
onCustomDownloadPathChange: (value: string) => void
}
export function AdvancedOptions({
@@ -29,7 +31,9 @@ export function AdvancedOptions({
downloadSubs,
onStartTimeChange,
onEndTimeChange,
onDownloadSubsChange
onDownloadSubsChange,
customDownloadPath,
onCustomDownloadPathChange
}: AdvancedOptionsProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
@@ -43,7 +47,19 @@ export function AdvancedOptions({
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
toast.error(t('settings.directorySelectError'))
}
}
const handleSelectCustomLocation = async () => {
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
onCustomDownloadPathChange(path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError'))
}
}
@@ -93,6 +109,34 @@ export function AdvancedOptions({
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('download.customDownloadFolder')}</Label>
{customDownloadPath.trim() && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCustomDownloadPathChange('')}
>
{t('download.useAutoFolder')}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Input
value={customDownloadPath}
readOnly
className="flex-1"
placeholder={t('download.autoFolderPlaceholder')}
/>
<Button onClick={handleSelectCustomLocation} variant="outline">
{t('settings.selectPath')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('download.autoFolderHint')}</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

View File

@@ -14,7 +14,7 @@ import { Separator } from '@renderer/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { useSetAtom } from 'jotai'
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
import { useId, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { VideoInfo } from '../../../../shared/types'
@@ -61,6 +61,11 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
const [startTime, setStartTime] = useState('')
const [endTime, setEndTime] = useState('')
const [downloadSubs, setDownloadSubs] = useState(false)
const [customDownloadPath, setCustomDownloadPath] = useState('')
useEffect(() => {
setCustomDownloadPath('')
}, [videoInfo.id])
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
@@ -87,7 +92,8 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
startTime: startTime || undefined,
endTime: endTime || undefined,
downloadSubs
downloadSubs,
customDownloadPath: customDownloadPath.trim() || undefined
}
addDownload(downloadItem)
@@ -202,6 +208,8 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
customDownloadPath={customDownloadPath}
onCustomDownloadPathChange={setCustomDownloadPath}
/>
<Button
@@ -231,6 +239,8 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
customDownloadPath={customDownloadPath}
onCustomDownloadPathChange={setCustomDownloadPath}
/>
<Button

View File

@@ -123,6 +123,10 @@
"downloadBtn": "Download",
"downloadPending": "Pending",
"downloadQueue": "Download Queue",
"customDownloadFolder": "Custom download folder",
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
"autoFolderHint": "Automatic folders are created from metadata.",
"useAutoFolder": "Use automatic folder",
"downloadVideo": "Download Video",
"downloading": "Downloading...",
"enterUrl": "Enter Video URL",
@@ -420,7 +424,7 @@
"title": "Automation defaults",
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
"downloadDirectory": "Download directory",
"filenameTemplate": "Filename template (file only)",
"filenameTemplate": "Filename template",
"checkInterval": "Check interval (hours)",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
@@ -434,7 +438,7 @@
"keywords": "Keyword filter (comma separated)",
"tags": "Auto tags",
"customDirectory": "Custom directory",
"namingTemplate": "Custom filename template (file only)",
"namingTemplate": "Custom filename template",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
"enabled": "Enabled",

View File

@@ -163,6 +163,7 @@ export function Home({
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const [playlistCustomDownloadPath, setPlaylistCustomDownloadPath] = useState('')
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
@@ -432,12 +433,26 @@ export function Home({
setPlaylistUrl(trimmed)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
setPlaylistCustomDownloadPath('')
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [playlistBusy, t])
const handleSelectPlaylistDirectory = useCallback(async () => {
if (playlistBusy) return
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
setPlaylistCustomDownloadPath(path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError'))
}
}, [playlistBusy, t])
const handleClearPlaylistPreview = useCallback(() => {
setPlaylistInfo(null)
setPlaylistPreviewError(null)
@@ -512,7 +527,8 @@ export function Home({
type: downloadType,
format,
startIndex: range.start,
endIndex: range.end
endIndex: range.end,
customDownloadPath: playlistCustomDownloadPath.trim() || undefined
})
if (result.totalCount === 0) {
@@ -545,7 +561,16 @@ export function Home({
} finally {
setPlaylistDownloadLoading(false)
}
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
}, [
playlistUrl,
playlistInfo,
computePlaylistRange,
downloadType,
settings,
addDownload,
t,
playlistCustomDownloadPath
])
// Auto-focus input on mount
useEffect(() => {
@@ -730,6 +755,7 @@ export function Home({
setPlaylistUrl(e.target.value)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
setPlaylistCustomDownloadPath('')
}}
className="flex-1"
disabled={playlistBusy}
@@ -785,6 +811,40 @@ export function Home({
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('download.customDownloadFolder')}</Label>
{playlistCustomDownloadPath.trim() && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPlaylistCustomDownloadPath('')}
disabled={playlistBusy}
>
{t('download.useAutoFolder')}
</Button>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={playlistCustomDownloadPath}
readOnly
className="flex-1"
placeholder={t('download.autoFolderPlaceholder')}
disabled={playlistBusy}
/>
<Button
onClick={handleSelectPlaylistDirectory}
variant="outline"
disabled={playlistBusy}
>
{t('settings.selectPath')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('download.autoFolderHint')}</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}

View File

@@ -164,6 +164,7 @@ export interface PlaylistDownloadOptions {
endIndex?: number
filenameFormat?: string
folderFormat?: string
customDownloadPath?: string
}
export interface PlaylistDownloadEntry {
@@ -267,12 +268,13 @@ export interface AppSettings {
hideDockIcon: boolean
launchAtLogin: boolean
autoUpdate: boolean
subscriptionFilenameTemplate: string
subscriptionOnlyLatestDefault: boolean
subscriptionCheckIntervalHours: number
enableAnalytics: boolean
}
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
export const defaultSettings: AppSettings = {
downloadPath: '',
showMoreFormats: false,
@@ -291,7 +293,6 @@ export const defaultSettings: AppSettings = {
hideDockIcon: false,
launchAtLogin: false,
autoUpdate: true,
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
subscriptionOnlyLatestDefault: true,
subscriptionCheckIntervalHours: 3,
enableAnalytics: true