feat(download): add log capture and file actions (#157)
* feat(download): add log capture and file actions * fix(ci): update ffmpeg download source * fix(ci): consolidate ffmpeg download source to yt-dlp repository Update FFmpeg download URLs to use yt-dlp/FFmpeg-Builds as the primary source across all platforms and remove BtbN fallback repository reference.
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -106,6 +106,35 @@ class FileSystemService extends IpcService {
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
try {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{thirdColumnLabel}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{thirdColumnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{metaLabel && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground/70 leading-snug break-words">
|
||||
{metaLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "تحديد المرئي",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "表示中を選択",
|
||||
|
||||
@@ -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": "표시된 항목 선택",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выбрать видимые",
|
||||
|
||||
@@ -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": "選取可見項目",
|
||||
|
||||
@@ -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": "选择可见项",
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user