diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 5c498e6..e9a33b5 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -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 => { diff --git a/src/main/ipc/services/settings-service.ts b/src/main/ipc/services/settings-service.ts index c44d270..915af30 100644 --- a/src/main/ipc/services/settings-service.ts +++ b/src/main/ipc/services/settings-service.ts @@ -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(_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(_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): void { - if (typeof settings.subscriptionFilenameTemplate === 'string') { - settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate( - settings.subscriptionFilenameTemplate - ) - } settingsManager.setAll(settings) if (settings.language) { diff --git a/src/main/ipc/services/subscription-service.ts b/src/main/ipc/services/subscription-service.ts index e8669b9..e164021 100644 --- a/src/main/ipc/services/subscription-service.ts +++ b/src/main/ipc/services/subscription-service.ts @@ -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 { 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 } diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 5a75798..0b54e33 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -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 = 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 diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index c4e02f2..2b05cc4 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -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])) diff --git a/src/main/settings.ts b/src/main/settings.ts index 779d4ad..ebb5ee6 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 }) diff --git a/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx index 2801418..429d4eb 100644 --- a/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx +++ b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx @@ -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(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({ )} -
- - setKeywords(event.target.value)} /> -
-
- - setTags(event.target.value)} /> -
@@ -282,17 +274,34 @@ export function SubscriptionFormDialog({
-
- - setNamingTemplate(sanitizeTemplateInput(event.target.value))} - /> -
-
-

{t('subscriptions.fields.onlyLatest')}

- -
+ + + {t('advancedOptions.title')} + +
+ + setKeywords(event.target.value)} /> +
+
+ + setTags(event.target.value)} /> +
+
+ + + setNamingTemplate(sanitizeTemplateInput(event.target.value)) + } + /> +
+
+

{t('subscriptions.fields.onlyLatest')}

+ +
+
+
+
{mode === 'add' && ( diff --git a/src/renderer/src/components/video/AdvancedOptions.tsx b/src/renderer/src/components/video/AdvancedOptions.tsx index ac06fb6..ea77b9c 100644 --- a/src/renderer/src/components/video/AdvancedOptions.tsx +++ b/src/renderer/src/components/video/AdvancedOptions.tsx @@ -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({ + +
+
+ + {customDownloadPath.trim() && ( + + )} +
+
+ + +
+

{t('download.autoFolderHint')}

+
diff --git a/src/renderer/src/components/video/VideoInfoCard.tsx b/src/renderer/src/components/video/VideoInfoCard.tsx index befc4fc..9b8f9a5 100644 --- a/src/renderer/src/components/video/VideoInfoCard.tsx +++ b/src/renderer/src/components/video/VideoInfoCard.tsx @@ -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} /> + )} + +
+ + +
+

{t('download.autoFolderHint')}

+ +