* 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:
@@ -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 => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user