From bd7f4a433c64b0f2df15e6925a5712ff1be8650e Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sat, 8 Nov 2025 21:28:51 +0800 Subject: [PATCH] chore: optimize ui and save structure (#19) * feat: persist and use saved file name for downloads * feat(settings): ensure and migrate download directory to VidBee/Downloads * feat: format download dates with locale date and time for clarity * feat(files): try multiple candidate file paths for file ops and DRY logic * fix(download): handle multi-path file deletion and warn on failure --- src/main/download-engine/args-builder.ts | 2 +- src/main/lib/download-engine.ts | 9 +- src/main/settings.ts | 44 +- .../src/components/download/DownloadItem.tsx | 550 ++++++++++++++---- src/renderer/src/locales/en.json | 29 +- src/renderer/src/store/downloads.ts | 2 + src/shared/types/index.ts | 2 + 7 files changed, 521 insertions(+), 117 deletions(-) diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 0429dc0..860e4fb 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -80,7 +80,7 @@ export const buildDownloadArgs = ( } // Output path with proper encoding handling - const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s') + const outputTemplate = path.join(downloadPath, '%(title)s via VidBee.%(ext)s') args.push('-o', outputTemplate) // Add options for better filename handling diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 7807a39..3c115e9 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -742,13 +742,16 @@ class DownloadEngine extends EventEmitter { fileSize = latestKnownSizeBytes } + const savedFileName = path.basename(actualFilePath) + this.updateDownloadInfo(id, { status: 'completed', completedAt: Date.now(), fileSize, format: willMerge ? 'mp4' : actualFormat || undefined, quality: actualQuality || undefined, - codec: actualCodec || undefined + codec: actualCodec || undefined, + savedFileName }) scopedLoggers.download.info('Download completed successfully for ID:', id) this.emit('download-completed', id) @@ -882,6 +885,9 @@ class DownloadEngine extends EventEmitter { if (updates.selectedFormat !== undefined) { historyUpdates.selectedFormat = updates.selectedFormat } + if (updates.savedFileName !== undefined) { + historyUpdates.savedFileName = updates.savedFileName + } if (Object.keys(historyUpdates).length > 0) { this.upsertHistoryEntry(id, snapshot.options, historyUpdates) @@ -936,6 +942,7 @@ class DownloadEngine extends EventEmitter { type: options.type, status: updates.status || 'pending', downloadPath: updates.downloadPath, + savedFileName: updates.savedFileName, fileSize: updates.fileSize, duration: updates.duration, downloadedAt: updates.downloadedAt ?? Date.now(), diff --git a/src/main/settings.ts b/src/main/settings.ts index 36806e6..4d84a3b 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import type { AppSettings } from '../shared/types' @@ -8,6 +9,24 @@ const ElectronStore = require('electron-store') // Access the default export 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 }) + } catch (error) { + console.error('Failed to ensure download directory:', error) + } +} + +const resolveDefaultDownloadPath = () => { + const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee') + ensureDirectoryExists(downloadDir) + return downloadDir +} + +const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath() + class SettingsManager { // biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import private store: any @@ -16,9 +35,10 @@ class SettingsManager { this.store = new Store({ defaults: { ...defaultSettings, - downloadPath: path.join(os.homedir(), 'Downloads') + downloadPath: DEFAULT_DOWNLOAD_PATH } }) + this.ensureDownloadDirectory() } get(key: K): AppSettings[K] { @@ -26,6 +46,9 @@ class SettingsManager { } set(key: K, value: AppSettings[K]): void { + if (key === 'downloadPath' && typeof value === 'string') { + ensureDirectoryExists(value) + } this.store.set(key, value) } @@ -35,6 +58,9 @@ class SettingsManager { setAll(settings: Partial): void { for (const [key, value] of Object.entries(settings)) { + if (key === 'downloadPath' && typeof value === 'string') { + ensureDirectoryExists(value) + } this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings]) } } @@ -43,8 +69,22 @@ class SettingsManager { this.store.clear() this.store.set({ ...defaultSettings, - downloadPath: path.join(os.homedir(), 'Downloads') + downloadPath: DEFAULT_DOWNLOAD_PATH }) + ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH) + } + + private ensureDownloadDirectory(): void { + try { + const currentPath: string | undefined = this.store.get('downloadPath') + if (!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH) { + this.store.set('downloadPath', DEFAULT_DOWNLOAD_PATH) + return + } + ensureDirectoryExists(currentPath) + } catch (error) { + console.error('Failed to verify download directory:', error) + } } } diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 2b4fc30..8121f83 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -4,8 +4,19 @@ import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeho import { Progress } from '@renderer/components/ui/progress' import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' import { useAtomValue, useSetAtom } from 'jotai' -import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react' -import { useEffect, useState } from 'react' +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronUp, + Copy, + FolderOpen, + Loader2, + Play, + Trash2, + X +} from 'lucide-react' +import { type ReactNode, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail' @@ -17,19 +28,43 @@ import { } from '../../store/downloads' import { settingsAtom } from '../../store/settings' -// Helper function to generate file path with proper path separators -const generateFilePath = (downloadPath: string, title: string, format: string): string => { - const fileName = `${title}.${format}` - // Use proper path joining for cross-platform compatibility - // Handle both forward and backward slashes for cross-platform compatibility +const generateFilePathCandidates = ( + downloadPath: string, + title: string, + format: string, + savedFileName?: string +): string[] => { + const candidateFileNames = savedFileName + ? [savedFileName] + : [`${title} via VidBee.${format}`, `${title}.${format}`] const normalizedDownloadPath = downloadPath.replace(/\\/g, '/') - return `${normalizedDownloadPath}/${fileName}` + return Array.from( + new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`)) + ) +} + +const tryFileOperation = async ( + paths: string[], + operation: (filePath: string) => Promise +): Promise => { + for (const filePath of paths) { + const success = await operation(filePath) + if (success) { + return true + } + } + return false } interface DownloadItemProps { download: DownloadRecord } +type MetadataDetail = { + label: string + value: ReactNode +} + const formatFileSize = (bytes?: number) => { if (!bytes) return '' const sizes = ['B', 'KB', 'MB', 'GB'] @@ -53,6 +88,18 @@ const formatDate = (timestamp?: number) => { return new Date(timestamp).toLocaleString() } +const formatDateShort = (timestamp?: number) => { + if (!timestamp) return '' + const date = new Date(timestamp) + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + export function DownloadItem({ download }: DownloadItemProps) { const { t } = useTranslation() const settings = useAtomValue(settingsAtom) @@ -70,19 +117,32 @@ export function DownloadItem({ download }: DownloadItemProps) { // Track if the file exists const [fileExists, setFileExists] = useState(false) + const [detailsOpen, setDetailsOpen] = useState(false) // Check if file exists when download data changes useEffect(() => { const checkFileExists = async () => { - if (!download.title || !download.downloadPath || !download.format) { + if (!download.title || !download.downloadPath) { setFileExists(false) return } try { - const filePath = generateFilePath(download.downloadPath, download.title, download.format) - const exists = await ipcServices.fs.fileExists(filePath) - setFileExists(exists) + const formatForPath = download.format || (download.type === 'audio' ? 'mp3' : 'mp4') + const filePaths = generateFilePathCandidates( + download.downloadPath, + download.title, + formatForPath, + download.savedFileName + ) + for (const filePath of filePaths) { + const exists = await ipcServices.fs.fileExists(filePath) + if (exists) { + setFileExists(true) + return + } + } + setFileExists(false) } catch (error) { console.error('Failed to check file existence:', error) setFileExists(false) @@ -90,7 +150,13 @@ export function DownloadItem({ download }: DownloadItemProps) { } checkFileExists() - }, [download.title, download.downloadPath, download.format]) + }, [ + download.title, + download.downloadPath, + download.format, + download.savedFileName, + download.type + ]) const handleCancel = async () => { if (isHistory) return @@ -104,12 +170,18 @@ export function DownloadItem({ download }: DownloadItemProps) { const handleOpenFolder = async () => { try { - // Generate file path using downloadPath + title + ext const downloadPath = download.downloadPath || settings.downloadPath const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4') - const filePath = generateFilePath(downloadPath, download.title, format) + const filePaths = generateFilePathCandidates( + downloadPath, + download.title, + format, + download.savedFileName + ) - const success = await ipcServices.fs.openFileLocation(filePath) + const success = await tryFileOperation(filePaths, (filePath) => + ipcServices.fs.openFileLocation(filePath) + ) if (!success) { toast.error(t('notifications.openFolderFailed')) } @@ -120,7 +192,12 @@ export function DownloadItem({ download }: DownloadItemProps) { } // Check if copy to clipboard is available const canCopyToClipboard = () => { - return !!(download.title && download.downloadPath && download.format && fileExists) + return !!( + download.title && + download.downloadPath && + fileExists && + (download.savedFileName || download.format) + ) } // need title, downloadPath, format @@ -142,9 +219,16 @@ export function DownloadItem({ download }: DownloadItemProps) { try { // Generate file path using downloadPath + title + ext - const filePath = generateFilePath(downloadPath, title, format) + const filePaths = generateFilePathCandidates( + downloadPath, + title, + format, + download.savedFileName + ) - const success = await ipcServices.fs.copyFileToClipboard(filePath) + const success = await tryFileOperation(filePaths, (filePath) => + ipcServices.fs.copyFileToClipboard(filePath) + ) if (!success) { toast.error(t('notifications.copyFailed')) return @@ -160,16 +244,25 @@ export function DownloadItem({ download }: DownloadItemProps) { const handleRemoveHistory = async () => { if (!isHistory) return try { - // Generate file path using downloadPath + title + ext const downloadPath = download.downloadPath || settings.downloadPath const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4') - const filePath = generateFilePath(downloadPath, download.title, format) + const filePaths = generateFilePathCandidates( + downloadPath, + download.title, + format, + download.savedFileName + ) // Remove from history first await ipcServices.history.removeHistoryItem(download.id) // Then try to delete the file - await ipcServices.fs.deleteFile(filePath) + const deleted = await tryFileOperation(filePaths, (filePath) => + ipcServices.fs.deleteFile(filePath) + ) + if (!deleted) { + console.warn('Failed to delete download file for history item:', download.id) + } removeHistory(download.id) toast.success(t('notifications.itemRemoved')) @@ -218,6 +311,246 @@ export function DownloadItem({ download }: DownloadItemProps) { const statusIcon = getStatusIcon() const statusText = getStatusText() + const sourceDisplay = + download.uploader && download.channel && download.uploader !== download.channel + ? `${download.uploader} • ${download.channel}` + : download.uploader || download.channel || '' + + const metadataDetails: MetadataDetail[] = [] + + if (timestamp) { + metadataDetails.push({ + label: t('history.date'), + value: formatDate(timestamp) + }) + } + + if (sourceDisplay) { + metadataDetails.push({ + label: t('download.metadata.source'), + value: sourceDisplay + }) + } + + if (download.playlistId) { + metadataDetails.push({ + label: t('download.metadata.playlist'), + value: ( + + {download.playlistTitle || t('playlist.untitled')} + {download.playlistIndex !== undefined && download.playlistSize !== undefined ? ( + + {` ${t('playlist.positionLabel', { + index: download.playlistIndex, + total: download.playlistSize + })}`} + + ) : null} + + ) + }) + } + + if (download.duration) { + metadataDetails.push({ + label: t('history.duration'), + value: formatDuration(download.duration) + }) + } + + const selectedFormatSize = + download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx + const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined + + const formatLabelValue = download.selectedFormat?.ext + ? download.selectedFormat.ext.toUpperCase() + : download.format + ? download.format.toUpperCase() + : undefined + + if (formatLabelValue) { + metadataDetails.push({ + label: t('download.metadata.format'), + value: formatLabelValue + }) + } + + const qualityValue = download.selectedFormat?.height + ? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}` + : download.quality + + if (qualityValue) { + metadataDetails.push({ + label: t('download.metadata.quality'), + value: qualityValue + }) + } + + if (inlineFileSize) { + metadataDetails.push({ + label: t('history.fileSize'), + value: inlineFileSize + }) + } + + if (download.codec) { + metadataDetails.push({ + label: t('download.metadata.codec'), + value: download.codec + }) + } + + if (download.savedFileName) { + metadataDetails.push({ + label: t('download.metadata.savedFile'), + value: download.savedFileName + }) + } + + if (download.url) { + metadataDetails.push({ + label: t('download.metadata.url'), + value: ( + + {download.url} + + ) + }) + } + + // Additional metadata fields + if (download.description) { + metadataDetails.push({ + label: t('download.metadata.description'), + value: {download.description} + }) + } + + if (download.viewCount !== undefined && download.viewCount !== null) { + metadataDetails.push({ + label: t('download.metadata.views'), + value: download.viewCount.toLocaleString() + }) + } + + if (download.tags && download.tags.length > 0) { + metadataDetails.push({ + label: t('download.metadata.tags'), + value: ( +
+ {download.tags.map((tag) => ( + + {tag} + + ))} +
+ ) + }) + } + + if (download.downloadPath) { + metadataDetails.push({ + label: t('download.metadata.downloadPath'), + value: {download.downloadPath} + }) + } + + // Timestamps + if (download.createdAt && download.createdAt !== timestamp) { + metadataDetails.push({ + label: t('download.metadata.createdAt'), + value: formatDate(download.createdAt) + }) + } + + if (download.startedAt) { + metadataDetails.push({ + label: t('download.metadata.startedAt'), + value: formatDate(download.startedAt) + }) + } + + if (download.completedAt && download.completedAt !== timestamp) { + metadataDetails.push({ + label: t('download.metadata.completedAt'), + value: formatDate(download.completedAt) + }) + } + + // Speed + if (download.speed) { + metadataDetails.push({ + label: t('download.metadata.speed'), + value: download.speed + }) + } + + // File size (if different from inlineFileSize) + if (download.fileSize && download.fileSize !== selectedFormatSize) { + metadataDetails.push({ + label: t('download.metadata.fileSize'), + value: formatFileSize(download.fileSize) + }) + } + + // Selected format details + if (download.selectedFormat) { + if (download.selectedFormat.width) { + metadataDetails.push({ + label: t('download.metadata.width'), + value: `${download.selectedFormat.width}px` + }) + } + + if (download.selectedFormat.height && !qualityValue) { + metadataDetails.push({ + label: t('download.metadata.height'), + value: `${download.selectedFormat.height}px` + }) + } + + if (download.selectedFormat.fps) { + metadataDetails.push({ + label: t('download.metadata.fps'), + value: `${download.selectedFormat.fps}` + }) + } + + if (download.selectedFormat.vcodec) { + metadataDetails.push({ + label: t('download.metadata.videoCodec'), + value: download.selectedFormat.vcodec + }) + } + + if (download.selectedFormat.acodec) { + metadataDetails.push({ + label: t('download.metadata.audioCodec'), + value: download.selectedFormat.acodec + }) + } + + if (download.selectedFormat.format_note) { + metadataDetails.push({ + label: t('download.metadata.formatNote'), + value: download.selectedFormat.format_note + }) + } + + if (download.selectedFormat.protocol) { + metadataDetails.push({ + label: t('download.metadata.protocol'), + value: download.selectedFormat.protocol.toUpperCase() + }) + } + } + + const hasMetadataDetails = metadataDetails.length > 0 return (
@@ -242,109 +575,102 @@ export function DownloadItem({ download }: DownloadItemProps) {

- - {download.type} - {(statusIcon || statusText) && (
{statusIcon} {statusText}
)} -
- {download.playlistId && ( -
- - {t('playlist.badgeLabel')} - - - {download.playlistTitle || t('playlist.untitled')} - {download.playlistIndex !== undefined && - download.playlistSize !== undefined && ( - - {t('playlist.positionLabel', { - index: download.playlistIndex, - total: download.playlistSize - })} - - )} - -
- )} -
{timestamp ? ( - {formatDate(timestamp)} + + {formatDateShort(timestamp)} + ) : null} +
+
+ {/* Source link */} + {(sourceDisplay || download.url) && + (download.url ? ( + + + + {sourceDisplay || download.url} + + + +

{download.url}

+
+
+ ) : ( + {sourceDisplay} + ))} - - - - - {download.uploader && - download.channel && - download.uploader !== download.channel - ? `${download.uploader} • ${download.channel}` - : download.uploader - ? `${download.uploader}` - : download.channel - ? `${download.channel}` - : ''} - - - -

{download.url}

-
-
-
- {download.duration ? {formatDuration(download.duration)} : null} - {download.selectedFormat ? ( + {/* Playlist info */} + {download.playlistId && ( <> - {download.selectedFormat.height ? ( - - {download.selectedFormat.height}p - {download.selectedFormat.fps === 60 ? '60' : ''} - - ) : null} - {download.selectedFormat.ext ? ( - - {download.selectedFormat.ext.toUpperCase()} - - ) : null} - {download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? ( - - {formatFileSize( - download.selectedFormat.filesize || - download.selectedFormat.filesize_approx - )} - - ) : null} - - ) : ( - <> - {download.quality ? ( - - {download.quality} - - ) : null} - {download.format ? ( - - {download.format.toUpperCase()} - - ) : null} - {download.codec ? ( - {download.codec} - ) : null} + + {t('playlist.badgeLabel')} + + + {download.playlistTitle || t('playlist.untitled')} + {download.playlistIndex !== undefined && + download.playlistSize !== undefined && + ` (${download.playlistIndex}/${download.playlistSize})`} + )} + + {/* Quality badge */} + {(download.selectedFormat?.height || download.quality) && ( + + {download.selectedFormat?.height + ? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}` + : download.quality} + + )} + + {/* File size */} + {inlineFileSize && {inlineFileSize}} + + {/* Details toggle */} + {hasMetadataDetails && ( + + + + + +

{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}

+
+
+ )}
+ {detailsOpen && hasMetadataDetails && ( +
+ {metadataDetails.map((item, index) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ )}
{isHistory ? ( diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index cf69974..0bc0f0f 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -152,6 +152,8 @@ "preparing": "Preparing...", "processing": "Processing", "progress": "Progress", + "showDetails": "Show details", + "hideDetails": "Hide details", "selectAudioFormat": "Select Audio Format", "selectFormat": "Select Format", "selectVideoFormat": "Select Video Format", @@ -164,7 +166,32 @@ "urlPlaceholder": "https://www.youtube.com/watch?v=...", "video": "Video", "videoInfo": "Video Information", - "videoInfoUpdated": "Video information updated" + "videoInfoUpdated": "Video information updated", + "metadata": { + "source": "Source", + "playlist": "Playlist", + "format": "Format", + "quality": "Quality", + "codec": "Codec", + "savedFile": "Saved file", + "url": "Source URL", + "description": "Description", + "views": "Views", + "tags": "Tags", + "downloadPath": "Download path", + "createdAt": "Created at", + "startedAt": "Started at", + "completedAt": "Completed at", + "speed": "Speed", + "fileSize": "File size", + "width": "Width", + "height": "Height", + "fps": "FPS", + "videoCodec": "Video codec", + "audioCodec": "Audio codec", + "formatNote": "Format note", + "protocol": "Protocol" + } }, "errors": { "clickToCopy": "Click to copy details", diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 0d8e732..18ddadf 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -5,6 +5,7 @@ export type DownloadRecord = DownloadItem & { entryType: 'active' | 'history' downloadedAt?: number downloadPath?: string + savedFileName?: string } const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}` @@ -43,6 +44,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({ playlistTitle: item.playlistTitle, playlistIndex: item.playlistIndex, playlistSize: item.playlistSize, + savedFileName: item.savedFileName, entryType: 'history', downloadedAt: item.downloadedAt }) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 92b2ad2..678edf1 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -65,6 +65,7 @@ export interface DownloadItem { format?: string quality?: string codec?: string + savedFileName?: string // Timestamps createdAt: number startedAt?: number @@ -92,6 +93,7 @@ export interface DownloadHistoryItem { type: 'video' | 'audio' | 'extract' status: DownloadStatus downloadPath?: string + savedFileName?: string fileSize?: number duration?: number downloadedAt: number