diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js index bba8a0a..74daaf2 100755 --- a/scripts/setup-dev-binaries.js +++ b/scripts/setup-dev-binaries.js @@ -35,7 +35,7 @@ const PLATFORM_CONFIG = { ffprobeOutput: 'ffprobe.exe', extract: 'unzip', release: { - repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], + repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'], assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i, binaryName: 'ffmpeg.exe' } @@ -87,7 +87,7 @@ const PLATFORM_CONFIG = { ffprobeOutput: 'ffprobe', extract: 'tar', release: { - repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], + repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'], assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i, binaryName: 'ffmpeg' } diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index f68495f..9a1de13 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -71,14 +71,7 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string => const isBilibiliUrl = (url: string): boolean => { try { const host = new URL(url).hostname.toLowerCase() - return ( - host === 'bilibili.com' || - host.endsWith('.bilibili.com') || - host === 'b23.tv' || - host.endsWith('.b23.tv') || - host === 'bili.tv' || - host.endsWith('.bili.tv') - ) + return host.includes('bilibili.com') || host.includes('b23.tv') || host.includes('bili.tv') } catch { return false } @@ -98,7 +91,9 @@ export const buildDownloadArgs = ( // Format selection if (options.type === 'video') { const formatSelector = resolveVideoFormatSelector(options) - args.push('-f', formatSelector) + if (formatSelector) { + args.push('-f', formatSelector) + } if (options.audioFormatIds && options.audioFormatIds.length > 0) { args.push('--audio-multistreams') } else if (formatSelector.includes('mergeall')) { diff --git a/src/main/index.ts b/src/main/index.ts index 3c60d25..c4ebe85 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -253,6 +253,10 @@ function setupDownloadEvents(): void { mainWindow?.webContents.send('download:progress', { id, progress }) }) + downloadEngine.on('download-log', (id: string, logText: string) => { + mainWindow?.webContents.send('download:log', { id, log: logText }) + }) + downloadEngine.on('download-completed', (id: string) => { mainWindow?.webContents.send('download:completed', id) }) diff --git a/src/main/ipc/services/file-system-service.ts b/src/main/ipc/services/file-system-service.ts index b345f76..bc42f61 100644 --- a/src/main/ipc/services/file-system-service.ts +++ b/src/main/ipc/services/file-system-service.ts @@ -106,6 +106,35 @@ class FileSystemService extends IpcService { } } + @IpcMethod() + async openFile(_context: IpcContext, filePath: string): Promise { + try { + if (!filePath) { + return false + } + + const sanitizedPath = this.sanitizePath(filePath) + const normalizedPath = path.normalize(sanitizedPath) + const stats = await fs.stat(normalizedPath).catch(() => null) + + if (!stats || (!stats.isFile() && !stats.isDirectory())) { + scopedLoggers.system.error('File does not exist:', normalizedPath) + return false + } + + const result = await shell.openPath(normalizedPath) + if (result) { + scopedLoggers.system.error('Failed to open file:', result) + return false + } + + return true + } catch (error) { + scopedLoggers.system.error('Failed to open file:', error) + return false + } + } + @IpcMethod() async copyFileToClipboard(_context: IpcContext, filePath: string): Promise { try { diff --git a/src/main/lib/database/schema.ts b/src/main/lib/database/schema.ts index c7927e7..5c04cab 100644 --- a/src/main/lib/database/schema.ts +++ b/src/main/lib/database/schema.ts @@ -16,6 +16,7 @@ export const downloadHistoryTable = sqliteTable('download_history', { sortKey: integer('sort_key', { mode: 'number' }).notNull(), error: text('error'), ytDlpCommand: text('yt_dlp_command'), + ytDlpLog: text('yt_dlp_log'), description: text('description'), channel: text('channel'), uploader: text('uploader'), diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index c3a961c..db369d8 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -769,6 +769,46 @@ class DownloadEngine extends EventEmitter { let totalParts = estimateProgressParts(options) let completedParts = 0 let lastPercent = 0 + let ytDlpLog = '' + let logFlushTimer: NodeJS.Timeout | null = null + let lastFlushedLog = '' + + const normalizeLogChunk = (chunk: string): string => + chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + const flushLogUpdate = (): void => { + if (logFlushTimer) { + clearTimeout(logFlushTimer) + logFlushTimer = null + } + if (ytDlpLog === lastFlushedLog) { + return + } + lastFlushedLog = ytDlpLog + this.updateDownloadInfo(id, { ytDlpLog }) + this.emit('download-log', id, ytDlpLog) + } + + const scheduleLogUpdate = (): void => { + if (logFlushTimer) { + return + } + logFlushTimer = setTimeout(() => { + flushLogUpdate() + }, 500) + } + + const appendLogChunk = (chunk: string | Buffer): void => { + if (!chunk) { + return + } + const text = typeof chunk === 'string' ? chunk : chunk.toString() + if (!text) { + return + } + ytDlpLog += normalizeLogChunk(text) + scheduleLogUpdate() + } // First, get detailed video info to capture basic metadata and formats try { @@ -943,6 +983,14 @@ class DownloadEngine extends EventEmitter { this.activeDownloads.set(id, { controller, process: ytdlpProcess }) + ytdlpProcess.ytDlpProcess?.stdout?.on('data', (data: Buffer) => { + appendLogChunk(data) + }) + + ytdlpProcess.ytDlpProcess?.stderr?.on('data', (data: Buffer) => { + appendLogChunk(data) + }) + this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() }) this.scheduleSessionPersist() this.emit('download-started', id) @@ -1042,6 +1090,7 @@ class DownloadEngine extends EventEmitter { // Handle completion ytdlpProcess.on('close', async (code: number | null) => { + flushLogUpdate() this.activeDownloads.delete(id) this.queue.downloadCompleted(id) @@ -1172,6 +1221,7 @@ class DownloadEngine extends EventEmitter { // Handle errors ytdlpProcess.on('error', (error: Error) => { + flushLogUpdate() scopedLoggers.download.error('Download process error for ID:', id, error) this.activeDownloads.delete(id) this.queue.downloadCompleted(id) @@ -1347,6 +1397,9 @@ class DownloadEngine extends EventEmitter { if (updates.ytDlpCommand !== undefined) { historyUpdates.ytDlpCommand = updates.ytDlpCommand } + if (updates.ytDlpLog !== undefined) { + historyUpdates.ytDlpLog = updates.ytDlpLog + } if (updates.savedFileName !== undefined) { historyUpdates.savedFileName = updates.savedFileName } @@ -1395,7 +1448,7 @@ class DownloadEngine extends EventEmitter { ): void { // Get the download item from the queue to get additional info const completedDownload = this.queue.getCompletedDownload(id) - scopedLoggers.download.info('Completed download:', completedDownload) + // scopedLoggers.download.info('Completed download:', completedDownload) const completedAt = Date.now() this.upsertHistoryEntry(id, options, { @@ -1443,6 +1496,7 @@ class DownloadEngine extends EventEmitter { completedAt: updates.completedAt, error: updates.error, ytDlpCommand: updates.ytDlpCommand, + ytDlpLog: updates.ytDlpLog, description: updates.description, channel: updates.channel, uploader: updates.uploader, diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index 625d1fc..1a64290 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -37,6 +37,7 @@ const createDownloadHistoryTableSql = sql` sort_key INTEGER NOT NULL, error TEXT, yt_dlp_command TEXT, + yt_dlp_log TEXT, description TEXT, channel TEXT, uploader TEXT, @@ -219,20 +220,53 @@ class HistoryManager { } const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec'] - const requiredColumns = ['yt_dlp_command'] + const requiredColumns = ['yt_dlp_command', 'yt_dlp_log'] const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name)) - const missingRequired = requiredColumns.some( + const missingRequired = requiredColumns.filter( (columnName) => !columns.some((column) => column.name === columnName) ) - const needsRebuild = hasDeprecated || missingRequired - if (needsRebuild) { + if (hasDeprecated) { this.rebuildDownloadHistoryTable() + return + } + if (missingRequired.length > 0) { + this.addMissingColumns(missingRequired) } } catch (error) { logger.error('history-db failed to inspect schema', error) } } + private addMissingColumns(columns: string[]): void { + if (columns.length === 0) { + return + } + + const database = this.getDatabase() + const definitions: Record = { + yt_dlp_command: 'TEXT', + yt_dlp_log: 'TEXT' + } + + try { + database.transaction( + (tx) => { + for (const column of columns) { + const definition = definitions[column] + if (!definition) { + continue + } + tx.run(sql.raw(`ALTER TABLE download_history ADD COLUMN ${column} ${definition}`)) + } + }, + { behavior: 'immediate' } + ) + logger.info(`history-db added missing columns: ${columns.join(', ')}`) + } catch (error) { + logger.error('history-db failed to add missing columns', error) + } + } + private migrateLegacyPayloadTable(): void { const database = this.getDatabase() logger.info('history-db migrating legacy payload schema to structured columns') @@ -394,6 +428,7 @@ class HistoryManager { sortKey: item.completedAt ?? item.downloadedAt, error: item.error ?? null, ytDlpCommand: item.ytDlpCommand ?? null, + ytDlpLog: item.ytDlpLog ?? null, description: item.description ?? null, channel: item.channel ?? null, uploader: item.uploader ?? null, @@ -441,6 +476,7 @@ class HistoryManager { completedAt: row.completedAt ?? undefined, error: row.error ?? undefined, ytDlpCommand: row.ytDlpCommand ?? undefined, + ytDlpLog: row.ytDlpLog ?? undefined, description: row.description ?? undefined, channel: row.channel ?? undefined, uploader: row.uploader ?? undefined, diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 1208649..d425dc0 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -5,6 +5,13 @@ import { import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { Checkbox } from '@renderer/components/ui/checkbox' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@renderer/components/ui/context-menu' import { Progress } from '@renderer/components/ui/progress' import { RemoteImage } from '@renderer/components/ui/remote-image' import { @@ -14,24 +21,30 @@ import { SheetHeader, SheetTitle } from '@renderer/components/ui/sheet' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' import { useAtomValue, useSetAtom } from 'jotai' import { AlertCircle, CheckCircle2, Copy, + File, + FileText, FolderOpen, Info, + Link2, Loader2, Play, + RotateCw, Trash2, X } from 'lucide-react' -import { type ReactNode, useEffect, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcServices } from '../../lib/ipc' import { + addDownloadAtom, type DownloadRecord, removeDownloadAtom, removeHistoryRecordAtom @@ -207,6 +220,7 @@ const formatDateShort = (timestamp?: number) => { export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) { const { t } = useTranslation() const settings = useAtomValue(settingsAtom) + const addDownload = useSetAtom(addDownloadAtom) const removeDownload = useSetAtom(removeDownloadAtom) const removeHistory = useSetAtom(removeHistoryRecordAtom) const isHistory = download.entryType === 'history' @@ -222,6 +236,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D // Track if the file exists const [fileExists, setFileExists] = useState(false) const [sheetOpen, setSheetOpen] = useState(false) + const [activeTab, setActiveTab] = useState<'details' | 'logs'>('details') + const [pendingTab, setPendingTab] = useState<'details' | 'logs' | null>(null) + const [logAutoScroll, setLogAutoScroll] = useState(true) + const logContainerRef = useRef(null) + const lastSheetOpenRef = useRef(false) // Check if file exists when download data changes useEffect(() => { @@ -266,6 +285,56 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D } } + const handleRetryDownload = async () => { + if (!download.url) { + toast.error(t('errors.emptyUrl')) + return + } + const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` + const customDownloadPath = download.downloadPath?.trim() || undefined + const formatId = download.selectedFormat?.format_id + + addDownload({ + id, + url: download.url, + title: download.title || t('download.fetchingVideoInfo'), + thumbnail: download.thumbnail, + type: download.type, + status: 'pending', + progress: { percent: 0 }, + duration: download.duration, + description: download.description, + channel: download.channel, + uploader: download.uploader, + viewCount: download.viewCount, + tags: download.tags, + selectedFormat: download.selectedFormat, + playlistId: download.playlistId, + playlistTitle: download.playlistTitle, + playlistIndex: download.playlistIndex, + playlistSize: download.playlistSize, + origin: download.origin, + subscriptionId: download.subscriptionId, + createdAt: Date.now() + }) + + try { + await ipcServices.download.startDownload(id, { + url: download.url, + type: download.type, + format: formatId, + audioFormat: download.type === 'video' ? 'best' : undefined, + customDownloadPath, + tags: download.tags, + origin: download.origin, + subscriptionId: download.subscriptionId + }) + } catch (error) { + console.error('Failed to retry download:', error) + toast.error(t('notifications.downloadFailed')) + } + } + const handleOpenFolder = async () => { try { const downloadPath = download.downloadPath || settings.downloadPath @@ -288,6 +357,53 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D toast.error(t('notifications.openFolderFailed')) } } + + const handleOpenFile = async () => { + try { + const downloadPath = download.downloadPath || settings.downloadPath + if (!downloadPath || !download.title) { + toast.error(t('notifications.openFileFailed')) + return + } + const format = resolvedExtension + const filePaths = generateFilePathCandidates( + downloadPath, + download.title, + format, + download.savedFileName + ) + + const success = await tryFileOperation(filePaths, (filePath) => + ipcServices.fs.openFile(filePath) + ) + if (!success) { + toast.error(t('notifications.openFileFailed')) + } + } catch (error) { + console.error('Failed to open file:', error) + toast.error(t('notifications.openFileFailed')) + } + } + + const handleCopyLink = async () => { + if (!download.url) { + toast.error(t('notifications.copyFailed')) + return + } + + if (!navigator.clipboard?.writeText) { + toast.error(t('notifications.copyFailed')) + return + } + + try { + await navigator.clipboard.writeText(download.url) + toast.success(t('notifications.urlCopied')) + } catch (error) { + console.error('Failed to copy link:', error) + toast.error(t('notifications.copyFailed')) + } + } // Check if copy to clipboard is available const canCopyToClipboard = () => { return Boolean(download.title && download.downloadPath && fileExists) @@ -333,11 +449,14 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D } } - // need id - const handleRemoveHistory = async () => { - if (!isHistory) return + const handleDeleteFile = async () => { try { const downloadPath = download.downloadPath || settings.downloadPath + if (!downloadPath || !download.title) { + toast.error(t('notifications.removeFailed')) + return + } + const format = resolvedExtension const filePaths = generateFilePathCandidates( downloadPath, @@ -346,21 +465,33 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D download.savedFileName ) - // Remove from history first - await ipcServices.history.removeHistoryItem(download.id) - - // Then try to delete the file const deleted = await tryFileOperation(filePaths, (filePath) => ipcServices.fs.deleteFile(filePath) ) + if (!deleted) { - console.warn('Failed to delete download file for history item:', download.id) + toast.error(t('notifications.removeFailed')) + return } - removeHistory(download.id) + setFileExists(false) + } catch (error) { + console.error('Failed to delete file:', error) + toast.error(t('notifications.removeFailed')) + } + } + + const handleDeleteRecord = async () => { + try { + if (isHistory) { + await ipcServices.history.removeHistoryItem(download.id) + removeHistory(download.id) + } else { + removeDownload(download.id) + } toast.success(t('notifications.itemRemoved')) } catch (error) { - console.error('Failed to remove item:', error) + console.error('Failed to remove record:', error) toast.error(t('notifications.removeFailed')) } } @@ -405,9 +536,22 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D const statusIcon = getStatusIcon() const statusText = getStatusText() const progressInfo = download.progress + const isInProgressStatus = + download.status === 'downloading' || + download.status === 'processing' || + download.status === 'pending' + const isCompletedStatus = download.status === 'completed' + const canRetry = download.status === 'error' + const showCopyAction = download.status === 'completed' && fileExists + const showOpenFolderAction = Boolean( + download.title && (download.downloadPath || settings.downloadPath) + ) const showInlineProgress = Boolean( progressInfo && download.status !== 'completed' && download.status !== 'error' ) + const canCopyLink = Boolean(download.url) + const canOpenFile = isCompletedStatus && fileExists + const canDeleteFile = isCompletedStatus && fileExists const sourceDisplay = download.uploader && download.channel && download.uploader !== download.channel ? `${download.uploader} • ${download.channel}` @@ -650,342 +794,508 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D } const hasMetadataDetails = metadataDetails.length > 0 + const logContent = download.ytDlpLog ?? '' + const hasLogContent = logContent.trim().length > 0 + const ytDlpCommand = download.ytDlpCommand?.trim() + const hasYtDlpCommand = Boolean(ytDlpCommand) + const canShowSheet = hasMetadataDetails || isInProgressStatus || hasLogContent const isSelectedHistory = selectionEnabled && isSelected + useEffect(() => { + const wasOpen = lastSheetOpenRef.current + lastSheetOpenRef.current = sheetOpen + if (!sheetOpen || wasOpen) { + return + } + const defaultTab = hasMetadataDetails ? 'details' : 'logs' + setActiveTab(pendingTab ?? defaultTab) + setPendingTab(null) + setLogAutoScroll(true) + }, [hasMetadataDetails, pendingTab, sheetOpen]) + + useEffect(() => { + if (!sheetOpen || !logAutoScroll || !logContent) { + return + } + const container = logContainerRef.current + if (container) { + container.scrollTop = container.scrollHeight + } + }, [logAutoScroll, logContent, sheetOpen]) + + const handleLogScroll = () => { + const container = logContainerRef.current + if (!container) { + return + } + const { scrollTop, scrollHeight, clientHeight } = container + const isNearBottom = scrollHeight - scrollTop - clientHeight < 24 + setLogAutoScroll(isNearBottom) + } + + const openLogsSheet = () => { + if (!canShowSheet) { + return + } + setPendingTab(sheetOpen ? null : 'logs') + setActiveTab('logs') + setLogAutoScroll(true) + setSheetOpen(true) + } + return ( -
-
onToggleSelect?.(download.id), - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggleSelect?.(download.id) + + +
+
onToggleSelect?.(download.id), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggleSelect?.(download.id) + } + }, + role: 'button', + tabIndex: 0, + 'aria-label': t('history.selectItem') } - }, - role: 'button', - tabIndex: 0, - 'aria-label': t('history.selectItem') - } - : {})} - > - {/* Thumbnail */} -
- {selectionEnabled && ( -
- onToggleSelect?.(download.id)} - onClick={(event) => event.stopPropagation()} - aria-label={t('history.selectItem')} + : {})} + > + {/* Thumbnail */} +
+ {selectionEnabled && ( +
+ onToggleSelect?.(download.id)} + onClick={(event) => event.stopPropagation()} + aria-label={t('history.selectItem')} + /> +
+ )} + } />
- )} - } - /> -
- {/* Content */} -
-
-
-
-

- {download.title} -

- {download.type === 'audio' && ( - - {t('download.audio')} - - )} - {isSubscriptionDownload && ( - - {t('subscriptions.labels.subscription')} - - )} -
-
- {/* Status */} - {statusIcon && ( - - -
{statusIcon}
-
- -

{statusText}

-
-
- )} - {showInlineProgress && ( -
- - {(progressInfo?.percent ?? 0).toFixed(1)}% - - {progressInfo?.downloaded && progressInfo?.total && ( - - {progressInfo.downloaded} / {progressInfo.total} - + {/* Content */} +
+
+
+
+

+ {download.title} +

+ {download.type === 'audio' && ( + + {t('download.audio')} + )} - {progressInfo?.currentSpeed && ( - {progressInfo.currentSpeed} - )} - {progressInfo?.eta && ( - ETA: {progressInfo.eta} + {isSubscriptionDownload && ( + + {t('subscriptions.labels.subscription')} + )}
- )} - {/* Timestamp */} - {timestamp && ( - {formatDateShort(timestamp)} - )} - {/* Quality */} - {qualityLabel && ( - <> - {(statusIcon || timestamp) && ( - +
+ {/* Status */} + {statusIcon && ( + + +
{statusIcon}
+
+ +

{statusText}

+
+
)} - {qualityLabel} - - )} - {/* File size */} - {inlineFileSize && ( - <> - {(statusIcon || timestamp || qualityLabel) && ( - + {showInlineProgress && ( +
+ + {(progressInfo?.percent ?? 0).toFixed(1)}% + + {progressInfo?.downloaded && progressInfo?.total && ( + + {progressInfo.downloaded} / {progressInfo.total} + + )} + {progressInfo?.currentSpeed && ( + {progressInfo.currentSpeed} + )} + {progressInfo?.eta && ( + ETA: {progressInfo.eta} + )} +
)} - {inlineFileSize} - - )} + {/* Timestamp */} + {timestamp && ( + {formatDateShort(timestamp)} + )} + {/* Quality */} + {qualityLabel && ( + <> + {(statusIcon || timestamp) && ( + + )} + {qualityLabel} + + )} + {/* File size */} + {inlineFileSize && ( + <> + {(statusIcon || timestamp || qualityLabel) && ( + + )} + {inlineFileSize} + + )} +
+
+
+ {canRetry && ( + + + + + +

{t('download.retry')}

+
+
+ )} + {isHistory ? ( + <> + {showCopyAction && ( + + + + + +

{t('history.copyToClipboard')}

+
+
+ )} + {showOpenFolderAction && ( + + + + + +

{t('history.openFolder')}

+
+
+ )} + + ) : ( + <> + {showCopyAction && ( + + + + + +

{t('history.copyToClipboard')}

+
+
+ )} + {showOpenFolderAction && ( + + + + + +

{t('history.openFolder')}

+
+
+ )} + {(download.status === 'downloading' || + download.status === 'pending' || + download.status === 'processing') && ( + + )} + + )} +
-
-
- {/* Info button - show details in sheet */} - {hasMetadataDetails && ( - - - - - -

{t('download.showDetails')}

-
-
- )} - {isHistory ? ( - <> - {download.status === 'completed' && ( - <> - - - - - -

{t('history.copyToClipboard')}

-
-
- - - - - -

{t('history.openFolder')}

-
-
- - )} - - - - - -

{t('history.removeItem')}

-
-
- - ) : ( - <> - {download.status === 'completed' && ( - <> - - - - - -

{t('history.copyToClipboard')}

-
-
- - - - - -

{t('history.openFolder')}

-
-
- - )} - {(download.status === 'downloading' || - download.status === 'pending' || - download.status === 'processing') && ( - - )} - + + {/* Progress */} + {download.progress && + download.status !== 'completed' && + download.status !== 'error' && ( +
+ +
+ )} + + {/* Error message */} + {download.status === 'error' && download.error && ( +
+

+ {download.error} +

+
+ + {t('download.feedback.title')} + +
+ {canShowSheet && ( + + )} + event.stopPropagation()} + /> +
+
+
)}
- {/* Progress */} - {download.progress && download.status !== 'completed' && download.status !== 'error' && ( -
- -
- )} - - {/* Error message */} - {download.status === 'error' && download.error && ( -
-

- {download.error} -

-
- - {t('download.feedback.title')} - -
- event.stopPropagation()} - /> + {/* Video Details Sheet */} + {canShowSheet && ( + + +
+ + {download.title} + {t('download.videoInfo')} + + setActiveTab(value as 'details' | 'logs')} + className="flex-1 overflow-hidden" + > +
+ + + {t('download.detailsTab')} + + {t('download.logsTab')} + +
+ +
+ {metadataDetails.map((item, index) => ( +
+ + {item.label} + +
{item.value}
+
+ ))} +
+
+ +
+ + {isInProgressStatus + ? t('download.logs.live') + : t('download.logs.history')} + + {logAutoScroll ? null : ( + + {t('download.logs.scrollPaused')} + + )} +
+ {hasYtDlpCommand && ( +
+
+ {t('download.logs.command')} +
+
+ {ytDlpCommand} +
+
+ )} +
+
+ {hasLogContent ? logContent : t('download.logs.empty')} +
+
+
+
-
-
+ + )}
-
- - {/* Video Details Sheet */} - {hasMetadataDetails && ( - - -
- - {download.title} - {t('download.videoInfo')} - -
-
- {metadataDetails.map((item, index) => ( -
- - {item.label} - -
{item.value}
-
- ))} -
-
-
-
-
- )} -
+ + + {isInProgressStatus ? ( + <> + + + {t('history.openFileLocation')} + + + + {t('history.copyUrl')} + + {canShowSheet && ( + setSheetOpen(true)}> + + {t('download.showDetails')} + + )} + {canRetry && ( + + + {t('download.retry')} + + )} + + + + {t('download.cancel')} + + + ) : ( + <> + + + {t('history.openFile')} + + {canRetry && ( + + + {t('download.retry')} + + )} + + + + {t('history.openFileLocation')} + + + + {t('history.copyUrl')} + + {canShowSheet && ( + setSheetOpen(true)}> + + {t('download.showDetails')} + + )} + + + + {t('history.deleteFile')} + + + + {t('history.deleteRecord')} + + + )} + + ) } diff --git a/src/renderer/src/components/download/SingleVideoDownload.tsx b/src/renderer/src/components/download/SingleVideoDownload.tsx index 35bfabc..57fa5f8 100644 --- a/src/renderer/src/components/download/SingleVideoDownload.tsx +++ b/src/renderer/src/components/download/SingleVideoDownload.tsx @@ -68,6 +68,12 @@ const getCodecShortName = (codec?: string): string => { return codec.split('.')[0].toUpperCase() } +const isHlsFormat = (format: VideoFormat): boolean => + format.protocol === 'm3u8' || format.protocol === 'm3u8_native' + +const isHttpProtocol = (format: VideoFormat): boolean => + !!format.protocol && format.protocol.startsWith('http') + const filterFormatsByType = ( formats: VideoInfo['formats'], activeTab: 'video' | 'audio' @@ -270,6 +276,30 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo return `${mb.toFixed(2)} MB` } + const formatMetaLabel = (format: VideoFormat) => { + const parts: string[] = [] + const pushPart = (label: string, value?: string) => { + if (!value) return + parts.push(`${label}:${value}`) + } + pushPart('proto', format.protocol) + pushPart('lang', format.language?.trim()) + if (format.tbr) { + pushPart('tbr', `${Math.round(format.tbr)}k`) + } + if (typeof format.quality === 'number') { + pushPart('q', String(format.quality)) + } + if (format.vcodec && format.vcodec !== 'none') { + pushPart('vcodec', format.vcodec) + } + if (format.acodec && format.acodec !== 'none') { + pushPart('acodec', format.acodec) + } + + return parts.join(' • ') + } + const formatVideoQuality = (format: VideoFormat) => { if (format.height) { return `${format.height}p${format.fps === 60 ? '60' : ''}` @@ -339,6 +369,7 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo ? format.acodec.split('.')[0].toUpperCase() : '' const sizeLabel = formatSize(format.filesize || format.filesize_approx) + const metaLabel = formatMetaLabel(format) const isSelected = selectedFormat === format.format_id return ( @@ -363,12 +394,19 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo {qualityLabel} -
- {detailLabel} - {thirdColumnLabel && thirdColumnLabel !== '-' && ( - - {thirdColumnLabel} - +
+
+ {detailLabel} + {thirdColumnLabel && thirdColumnLabel !== '-' && ( + + {thirdColumnLabel} + + )} +
+ {metaLabel && ( +
+ {metaLabel} +
)}
@@ -401,7 +439,16 @@ export function SingleVideoDownload({ const relevantFormats = useMemo(() => { if (!videoInfo?.formats) return [] - return filterFormatsByType(videoInfo.formats, activeTab) + const baseFormats = filterFormatsByType(videoInfo.formats, activeTab) + if (baseFormats.length === 0) return [] + + const hasHttpFormats = baseFormats.some(isHttpProtocol) + if (!hasHttpFormats) { + return baseFormats + } + + const nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format)) + return nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats }, [videoInfo?.formats, activeTab]) const containers = useMemo(() => { diff --git a/src/renderer/src/hooks/use-download-events.ts b/src/renderer/src/hooks/use-download-events.ts index 03bbc92..4e9a038 100644 --- a/src/renderer/src/hooks/use-download-events.ts +++ b/src/renderer/src/hooks/use-download-events.ts @@ -96,6 +96,16 @@ export function useDownloadEvents() { }) } + const handleLog = (rawData: unknown) => { + const data = rawData as { id?: string; log?: string } + const id = typeof data?.id === 'string' ? data.id : '' + if (!id) { + return + } + const logText = typeof data?.log === 'string' ? data.log : '' + updateDownload({ id, changes: { ytDlpLog: logText } }) + } + const handleCompleted = (rawId: unknown) => { const id = typeof rawId === 'string' ? rawId : '' if (!id) { @@ -129,13 +139,14 @@ export function useDownloadEvents() { const startedSubscription = ipcEvents.on('download:started', handleStarted) const progressSubscription = ipcEvents.on('download:progress', handleProgress) + const logSubscription = ipcEvents.on('download:log', handleLog) const completedSubscription = ipcEvents.on('download:completed', handleCompleted) const errorSubscription = ipcEvents.on('download:error', handleError) const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled) - return () => { ipcEvents.removeListener('download:started', startedSubscription) ipcEvents.removeListener('download:progress', progressSubscription) + ipcEvents.removeListener('download:log', logSubscription) ipcEvents.removeListener('download:completed', completedSubscription) ipcEvents.removeListener('download:error', errorSubscription) ipcEvents.removeListener('download:cancelled', cancelledSubscription) diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index e689db0..cf77bff 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -119,6 +119,7 @@ "downloadPending": "قيد الانتظار", "downloadQueue": "قائمة انتظار التحميل", "customDownloadFolder": "مجلد تنزيل مخصص", + "retry": "إعادة المحاولة", "autoFolderPlaceholder": "مجلد تلقائي (استنادًا إلى البيانات الوصفية)", "autoFolderHint": "يتم إنشاء المجلدات التلقائية من البيانات الوصفية.", "useAutoFolder": "استخدام المجلد التلقائي", @@ -158,6 +159,16 @@ "progress": "التقدم", "showDetails": "إظهار التفاصيل", "hideDetails": "إخفاء التفاصيل", + "viewLogs": "عرض السجلات", + "detailsTab": "التفاصيل", + "logsTab": "السجلات", + "logs": { + "live": "سجلات مباشرة", + "history": "سجلات محفوظة", + "command": "أمر yt-dlp", + "empty": "لا توجد سجلات بعد.", + "scrollPaused": "تم إيقاف التمرير" + }, "selectAudioFormat": "اختر تنسيق الصوت", "selectDownloadType": "اختر نوع التنزيل", "selectFormat": "اختر التنسيق", @@ -269,6 +280,8 @@ "openInBrowser": "انقر لفتح في المتصفح", "removeAction": "إزالة", "removeItem": "إزالة العنصر", + "deleteFile": "حذف الملف", + "deleteRecord": "إزالة من القائمة", "select": "تحديد", "selectAll": "تحديد الكل", "selectVisible": "تحديد المرئي", diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index 959d11a..f59a60c 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -119,6 +119,7 @@ "downloadPending": "Ausstehend", "downloadQueue": "Download-Warteschlange", "customDownloadFolder": "Benutzerdefinierter Download-Ordner", + "retry": "Download erneut versuchen", "autoFolderPlaceholder": "Automatischer Ordner (basierend auf Metadaten)", "autoFolderHint": "Automatische Ordner werden aus Metadaten erstellt.", "useAutoFolder": "Automatischen Ordner verwenden", @@ -158,6 +159,16 @@ "progress": "Fortschritt", "showDetails": "Details anzeigen", "hideDetails": "Details ausblenden", + "viewLogs": "Logs anzeigen", + "detailsTab": "Details", + "logsTab": "Logs", + "logs": { + "live": "Live-Logs", + "history": "Gespeicherte Logs", + "command": "yt-dlp-Befehl", + "empty": "Noch keine Logs.", + "scrollPaused": "Scrollen pausiert" + }, "selectAudioFormat": "Audio-Format auswählen", "selectDownloadType": "Download-Typ auswählen", "selectFormat": "Format auswählen", @@ -269,6 +280,8 @@ "openInBrowser": "Klicken, um im Browser zu öffnen", "removeAction": "Entfernen", "removeItem": "Element entfernen", + "deleteFile": "Datei löschen", + "deleteRecord": "Aus Liste entfernen", "select": "Auswählen", "selectAll": "Alle auswählen", "selectVisible": "Sichtbare auswählen", diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 0f412f8..0abfd8d 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -119,6 +119,7 @@ "downloadPending": "Pending", "downloadQueue": "Download Queue", "customDownloadFolder": "Custom download folder", + "retry": "Retry Download", "autoFolderPlaceholder": "Automatic folder (based on metadata)", "autoFolderHint": "Automatic folders are created from metadata.", "useAutoFolder": "Use automatic folder", @@ -158,6 +159,16 @@ "progress": "Progress", "showDetails": "Show details", "hideDetails": "Hide details", + "viewLogs": "View logs", + "detailsTab": "Details", + "logsTab": "Logs", + "logs": { + "live": "Live logs", + "history": "Saved logs", + "command": "yt-dlp command", + "empty": "No logs yet.", + "scrollPaused": "Scroll paused" + }, "selectAudioFormat": "Select Audio Format", "selectDownloadType": "Select download type", "selectFormat": "Select Format", @@ -269,6 +280,8 @@ "openInBrowser": "Click to open in browser", "removeAction": "Remove", "removeItem": "Remove Item", + "deleteFile": "Delete File", + "deleteRecord": "Remove from List", "select": "Select", "selectAll": "Select All", "selectVisible": "Select visible", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index b10e958..f999636 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -119,6 +119,7 @@ "downloadPending": "Pendiente", "downloadQueue": "Cola de Descarga", "customDownloadFolder": "Carpeta de descarga personalizada", + "retry": "Reintentar descarga", "autoFolderPlaceholder": "Carpeta automática (basada en metadatos)", "autoFolderHint": "Las carpetas automáticas se crean a partir de metadatos.", "useAutoFolder": "Usar carpeta automática", @@ -158,6 +159,16 @@ "progress": "Progreso", "showDetails": "Mostrar detalles", "hideDetails": "Ocultar detalles", + "viewLogs": "Ver registros", + "detailsTab": "Detalles", + "logsTab": "Registros", + "logs": { + "live": "Registros en vivo", + "history": "Registros guardados", + "command": "Comando de yt-dlp", + "empty": "Aún no hay registros.", + "scrollPaused": "Desplazamiento en pausa" + }, "selectAudioFormat": "Seleccionar Formato de Audio", "selectDownloadType": "Seleccionar tipo de descarga", "selectFormat": "Seleccionar Formato", @@ -269,6 +280,8 @@ "openInBrowser": "Haz clic para abrir en el navegador", "removeAction": "Eliminar", "removeItem": "Eliminar Elemento", + "deleteFile": "Eliminar Archivo", + "deleteRecord": "Eliminar de la Lista", "select": "Seleccionar", "selectAll": "Seleccionar todo", "selectVisible": "Seleccionar visibles", diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index 32efef2..fa15916 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -119,6 +119,7 @@ "downloadPending": "En attente", "downloadQueue": "File de Téléchargement", "customDownloadFolder": "Dossier de téléchargement personnalisé", + "retry": "Relancer le téléchargement", "autoFolderPlaceholder": "Dossier automatique (basé sur les métadonnées)", "autoFolderHint": "Les dossiers automatiques sont créés à partir des métadonnées.", "useAutoFolder": "Utiliser le dossier automatique", @@ -158,6 +159,16 @@ "progress": "Progrès", "showDetails": "Afficher les détails", "hideDetails": "Masquer les détails", + "viewLogs": "Voir les journaux", + "detailsTab": "Détails", + "logsTab": "Journaux", + "logs": { + "live": "Journaux en direct", + "history": "Journaux enregistrés", + "command": "Commande yt-dlp", + "empty": "Aucun journal pour le moment.", + "scrollPaused": "Défilement en pause" + }, "selectAudioFormat": "Sélectionner le Format Audio", "selectDownloadType": "Sélectionner le type de téléchargement", "selectFormat": "Sélectionner le Format", @@ -269,6 +280,8 @@ "openInBrowser": "Cliquez pour ouvrir dans le navigateur", "removeAction": "Supprimer", "removeItem": "Supprimer l'Élément", + "deleteFile": "Supprimer le Fichier", + "deleteRecord": "Supprimer de la Liste", "select": "Sélectionner", "selectAll": "Tout sélectionner", "selectVisible": "Sélectionner les visibles", diff --git a/src/renderer/src/locales/id.json b/src/renderer/src/locales/id.json index 33238d4..f91face 100644 --- a/src/renderer/src/locales/id.json +++ b/src/renderer/src/locales/id.json @@ -119,6 +119,7 @@ "downloadPending": "Menunggu", "downloadQueue": "Antrian Unduhan", "customDownloadFolder": "Folder unduhan khusus", + "retry": "Coba ulang unduhan", "autoFolderPlaceholder": "Folder otomatis (berdasarkan metadata)", "autoFolderHint": "Folder otomatis dibuat dari metadata.", "useAutoFolder": "Gunakan folder otomatis", @@ -158,6 +159,16 @@ "progress": "Kemajuan", "showDetails": "Tampilkan detail", "hideDetails": "Sembunyikan detail", + "viewLogs": "Lihat log", + "detailsTab": "Detail", + "logsTab": "Log", + "logs": { + "live": "Log langsung", + "history": "Log tersimpan", + "command": "Perintah yt-dlp", + "empty": "Belum ada log.", + "scrollPaused": "Pengguliran dijeda" + }, "selectAudioFormat": "Pilih Format Audio", "selectDownloadType": "Pilih jenis unduhan", "selectFormat": "Pilih Format", @@ -269,6 +280,8 @@ "openInBrowser": "Klik untuk membuka di browser", "removeAction": "Hapus", "removeItem": "Hapus Item", + "deleteFile": "Hapus File", + "deleteRecord": "Hapus dari Daftar", "select": "Pilih", "selectAll": "Pilih semua", "selectVisible": "Pilih yang terlihat", diff --git a/src/renderer/src/locales/it.json b/src/renderer/src/locales/it.json index 508cb50..34be635 100644 --- a/src/renderer/src/locales/it.json +++ b/src/renderer/src/locales/it.json @@ -119,6 +119,7 @@ "downloadPending": "In attesa", "downloadQueue": "Coda Download", "customDownloadFolder": "Cartella di download personalizzata", + "retry": "Riprova download", "autoFolderPlaceholder": "Cartella automatica (in base ai metadati)", "autoFolderHint": "Le cartelle automatiche vengono create dai metadati.", "useAutoFolder": "Usa cartella automatica", @@ -158,6 +159,16 @@ "progress": "Progresso", "showDetails": "Mostra dettagli", "hideDetails": "Nascondi dettagli", + "viewLogs": "Visualizza log", + "detailsTab": "Dettagli", + "logsTab": "Log", + "logs": { + "live": "Log in tempo reale", + "history": "Log salvati", + "command": "Comando yt-dlp", + "empty": "Nessun log ancora.", + "scrollPaused": "Scorrimento in pausa" + }, "selectAudioFormat": "Seleziona Formato Audio", "selectDownloadType": "Seleziona tipo di download", "selectFormat": "Seleziona Formato", @@ -269,6 +280,8 @@ "openInBrowser": "Clicca per aprire nel browser", "removeAction": "Rimuovi", "removeItem": "Rimuovi Elemento", + "deleteFile": "Elimina File", + "deleteRecord": "Rimuovi dall'Elenco", "select": "Seleziona", "selectAll": "Seleziona tutto", "selectVisible": "Seleziona visibili", diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index 52a26af..b2e67e8 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -119,6 +119,7 @@ "downloadPending": "保留中", "downloadQueue": "ダウンロードキュー", "customDownloadFolder": "カスタムダウンロードフォルダー", + "retry": "ダウンロードを再試行", "autoFolderPlaceholder": "自動フォルダー(メタデータに基づく)", "autoFolderHint": "自動フォルダーはメタデータから作成されます。", "useAutoFolder": "自動フォルダーを使用", @@ -158,6 +159,16 @@ "progress": "進行状況", "showDetails": "詳細を表示", "hideDetails": "詳細を隠す", + "viewLogs": "ログを表示", + "detailsTab": "詳細", + "logsTab": "ログ", + "logs": { + "live": "ライブログ", + "history": "保存済みログ", + "command": "yt-dlp コマンド", + "empty": "ログはまだありません。", + "scrollPaused": "スクロールを一時停止" + }, "selectAudioFormat": "オーディオフォーマットを選択", "selectDownloadType": "ダウンロードの種類を選択", "selectFormat": "フォーマットを選択", @@ -269,6 +280,8 @@ "openInBrowser": "ブラウザで開くにはクリック", "removeAction": "削除", "removeItem": "アイテムを削除", + "deleteFile": "ファイルを削除", + "deleteRecord": "リストから削除", "select": "選択", "selectAll": "すべて選択", "selectVisible": "表示中を選択", diff --git a/src/renderer/src/locales/ko.json b/src/renderer/src/locales/ko.json index 8d8cc1b..ae406f3 100644 --- a/src/renderer/src/locales/ko.json +++ b/src/renderer/src/locales/ko.json @@ -119,6 +119,7 @@ "downloadPending": "대기 중", "downloadQueue": "다운로드 큐", "customDownloadFolder": "사용자 지정 다운로드 폴더", + "retry": "다운로드 재시도", "autoFolderPlaceholder": "자동 폴더(메타데이터 기반)", "autoFolderHint": "자동 폴더는 메타데이터에서 생성됩니다.", "useAutoFolder": "자동 폴더 사용", @@ -158,6 +159,16 @@ "progress": "진행률", "showDetails": "세부정보 표시", "hideDetails": "세부정보 숨기기", + "viewLogs": "로그 보기", + "detailsTab": "세부정보", + "logsTab": "로그", + "logs": { + "live": "실시간 로그", + "history": "저장된 로그", + "command": "yt-dlp 명령어", + "empty": "아직 로그가 없습니다.", + "scrollPaused": "스크롤 일시 중지" + }, "selectAudioFormat": "오디오 형식 선택", "selectDownloadType": "다운로드 유형 선택", "selectFormat": "형식 선택", @@ -269,6 +280,8 @@ "openInBrowser": "브라우저에서 열려면 클릭", "removeAction": "제거", "removeItem": "항목 제거", + "deleteFile": "파일 삭제", + "deleteRecord": "목록에서 제거", "select": "선택", "selectAll": "모두 선택", "selectVisible": "표시된 항목 선택", diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index c85db6b..c086e4e 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -119,6 +119,7 @@ "downloadPending": "Pendente", "downloadQueue": "Fila de Download", "customDownloadFolder": "Pasta de download personalizada", + "retry": "Tentar baixar novamente", "autoFolderPlaceholder": "Pasta automática (com base nos metadados)", "autoFolderHint": "Pastas automáticas são criadas a partir de metadados.", "useAutoFolder": "Usar pasta automática", @@ -158,6 +159,16 @@ "progress": "Progresso", "showDetails": "Mostrar detalhes", "hideDetails": "Ocultar detalhes", + "viewLogs": "Ver logs", + "detailsTab": "Detalhes", + "logsTab": "Logs", + "logs": { + "live": "Logs ao vivo", + "history": "Logs salvos", + "command": "Comando do yt-dlp", + "empty": "Ainda não há logs.", + "scrollPaused": "Rolagem pausada" + }, "selectAudioFormat": "Selecionar Formato de Áudio", "selectDownloadType": "Selecionar tipo de download", "selectFormat": "Selecionar Formato", @@ -269,6 +280,8 @@ "openInBrowser": "Clique para abrir no navegador", "removeAction": "Remover", "removeItem": "Remover Item", + "deleteFile": "Remover Arquivo", + "deleteRecord": "Remover da Lista", "select": "Selecionar", "selectAll": "Selecionar tudo", "selectVisible": "Selecionar visíveis", diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index a8a6dbc..cfce6a3 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -119,6 +119,7 @@ "downloadPending": "Ожидание", "downloadQueue": "Очередь загрузки", "customDownloadFolder": "Пользовательская папка загрузки", + "retry": "Повторить загрузку", "autoFolderPlaceholder": "Автоматическая папка (на основе метаданных)", "autoFolderHint": "Автоматические папки создаются из метаданных.", "useAutoFolder": "Использовать автоматическую папку", @@ -158,6 +159,16 @@ "progress": "Прогресс", "showDetails": "Показать детали", "hideDetails": "Скрыть детали", + "viewLogs": "Посмотреть логи", + "detailsTab": "Детали", + "logsTab": "Логи", + "logs": { + "live": "Логи в реальном времени", + "history": "Сохранённые логи", + "command": "Команда yt-dlp", + "empty": "Пока нет логов.", + "scrollPaused": "Прокрутка приостановлена" + }, "selectAudioFormat": "Выбрать формат аудио", "selectDownloadType": "Выберите тип загрузки", "selectFormat": "Выбрать формат", @@ -269,6 +280,8 @@ "openInBrowser": "Нажмите, чтобы открыть в браузере", "removeAction": "Удалить", "removeItem": "Удалить элемент", + "deleteFile": "Удалить файл", + "deleteRecord": "Удалить из списка", "select": "Выбрать", "selectAll": "Выбрать все", "selectVisible": "Выбрать видимые", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index ad531d9..6c515b2 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -119,6 +119,7 @@ "downloadPending": "待處理", "downloadQueue": "下載佇列", "customDownloadFolder": "自訂下載資料夾", + "retry": "重試下載", "autoFolderPlaceholder": "自動資料夾(依中繼資料)", "autoFolderHint": "自動資料夾會由中繼資料建立。", "useAutoFolder": "使用自動資料夾", @@ -158,6 +159,16 @@ "progress": "進度", "showDetails": "顯示詳情", "hideDetails": "隱藏詳細信息", + "viewLogs": "查看日誌", + "detailsTab": "詳細資料", + "logsTab": "日誌", + "logs": { + "live": "即時日誌", + "history": "已儲存日誌", + "command": "yt-dlp 指令", + "empty": "尚無日誌。", + "scrollPaused": "捲動已暫停" + }, "selectAudioFormat": "選擇音訊格式", "selectDownloadType": "選擇下載類型", "selectFormat": "選擇格式", @@ -269,6 +280,8 @@ "openInBrowser": "點擊在瀏覽器中開啟", "removeAction": "移除", "removeItem": "移除項目", + "deleteFile": "刪除檔案", + "deleteRecord": "從列表中移除", "select": "選取", "selectAll": "全選", "selectVisible": "選取可見項目", diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 1809f00..a2fa6bd 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -119,6 +119,7 @@ "downloadPending": "待处理", "downloadQueue": "下载队列", "customDownloadFolder": "自定义下载文件夹", + "retry": "重试下载", "autoFolderPlaceholder": "自动文件夹(基于元数据)", "autoFolderHint": "自动文件夹由元数据创建。", "useAutoFolder": "使用自动文件夹", @@ -158,6 +159,16 @@ "progress": "进度", "showDetails": "显示详情", "hideDetails": "隐藏详细信息", + "viewLogs": "查看日志", + "detailsTab": "详情", + "logsTab": "日志", + "logs": { + "live": "实时日志", + "history": "已保存日志", + "command": "yt-dlp 命令", + "empty": "暂无日志。", + "scrollPaused": "滚动已暂停" + }, "selectAudioFormat": "选择音频格式", "selectDownloadType": "选择下载类型", "selectFormat": "选择格式", @@ -269,6 +280,8 @@ "openInBrowser": "点击在浏览器中打开", "removeAction": "移除", "removeItem": "移除项目", + "deleteFile": "删除文件", + "deleteRecord": "从列表中移除", "select": "选择", "selectAll": "全选", "selectVisible": "选择可见项", diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 4eb476f..d1eceb0 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -28,6 +28,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({ progress: undefined, error: item.error, ytDlpCommand: item.ytDlpCommand, + ytDlpLog: item.ytDlpLog, downloadPath: item.downloadPath, speed: undefined, duration: item.duration, @@ -190,8 +191,9 @@ export const downloadStatsAtom = atom((get) => { (acc, item) => { acc.total += 1 if ( - item.entryType === 'active' && - (item.status === 'downloading' || item.status === 'processing' || item.status === 'pending') + (item.entryType === 'active' && item.status === 'downloading') || + item.status === 'processing' || + item.status === 'pending' ) { acc.active += 1 } @@ -209,8 +211,8 @@ export const activeDownloadsCountAtom = atom((get) => { let count = 0 for (const item of downloads.values()) { if ( - item.entryType === 'active' && - (item.status === 'downloading' || item.status === 'processing') + (item.entryType === 'active' && item.status === 'downloading') || + item.status === 'processing' ) { count++ } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index cecbf01..8a63d9b 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -67,6 +67,7 @@ export interface DownloadItem { error?: string speed?: string ytDlpCommand?: string + ytDlpLog?: string // Enhanced video information duration?: number fileSize?: number @@ -117,6 +118,7 @@ export interface DownloadHistoryItem { completedAt?: number error?: string ytDlpCommand?: string + ytDlpLog?: string // Additional metadata description?: string channel?: string