diff --git a/entitlements.mac.plist b/entitlements.mac.plist deleted file mode 100644 index 38c887b..0000000 --- a/entitlements.mac.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - - diff --git a/icon.icns b/icon.icns deleted file mode 100644 index 69efa86..0000000 Binary files a/icon.icns and /dev/null differ diff --git a/icon.ico b/icon.ico deleted file mode 100644 index 1a28eb3..0000000 Binary files a/icon.ico and /dev/null differ diff --git a/icon.png b/icon.png deleted file mode 100644 index 6d03b62..0000000 Binary files a/icon.png and /dev/null differ diff --git a/src/main/index.ts b/src/main/index.ts index f718143..c2de522 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -96,12 +96,12 @@ function setupDownloadEvents(): void { function initAutoUpdater(): void { if (process.env.NODE_ENV !== 'production') { - console.log('Skipping auto-updater initialization in development mode') + log.info('Skipping auto-updater initialization in development mode') return } try { - console.log('Initializing auto-updater...') + log.info('Initializing auto-updater...') log.transports.file.level = 'info' autoUpdater.logger = log @@ -110,31 +110,26 @@ function initAutoUpdater(): void { autoUpdater.on('update-available', (info) => { log.info('Update available:', info.version) - console.log('Update available:', info.version) mainWindow?.webContents.send('update:available', info) }) autoUpdater.on('update-not-available', (info) => { log.info('Update not available:', info.version) - console.log('Update not available:', info.version) mainWindow?.webContents.send('update:not-available', info) }) autoUpdater.on('error', (err) => { log.error('Update error:', err) - console.error('Update error:', err) mainWindow?.webContents.send('update:error', err.message) }) autoUpdater.on('download-progress', (progressObj) => { log.info('Download progress:', progressObj.percent) - console.log('Download progress:', progressObj.percent) mainWindow?.webContents.send('update:download-progress', progressObj) }) autoUpdater.on('update-downloaded', (info) => { log.info('Update downloaded:', info.version) - console.log('Update downloaded:', info.version) mainWindow?.webContents.send('update:downloaded', info) if (mainWindow) { @@ -148,15 +143,12 @@ function initAutoUpdater(): void { if (settingsManager.get('autoUpdate')) { log.info('Auto-update is enabled, checking for updates...') - console.log('Auto-update is enabled, checking for updates...') void autoUpdater.checkForUpdatesAndNotify() } log.info('Auto-updater initialized successfully') - console.log('Auto-updater initialized successfully') } catch (error) { log.error('Failed to initialize auto-updater:', error) - console.error('Failed to initialize auto-updater:', error) } } diff --git a/src/main/ipc/services/download-service.ts b/src/main/ipc/services/download-service.ts index f62d81a..f9b733a 100644 --- a/src/main/ipc/services/download-service.ts +++ b/src/main/ipc/services/download-service.ts @@ -3,6 +3,7 @@ import type { DownloadItem, DownloadOptions, PlaylistDownloadOptions, + PlaylistDownloadResult, PlaylistInfo, VideoInfo } from '../../../shared/types' @@ -45,7 +46,7 @@ class DownloadService extends IpcService { async startPlaylistDownload( _context: IpcContext, options: PlaylistDownloadOptions - ): Promise { + ): Promise { return downloadEngine.startPlaylistDownload(options) } } diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index d1dd911..8c8b5fa 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -7,6 +7,7 @@ import type { DownloadOptions, DownloadProgress, PlaylistDownloadOptions, + PlaylistDownloadResult, PlaylistInfo, VideoFormat, VideoInfo @@ -120,7 +121,7 @@ class DownloadEngine extends EventEmitter { const ytdlp = ytdlpManager.getInstance() const settings = settingsManager.getAll() - const args = ['-j', '--flat-playlist', '--no-warnings'] + const args = ['-J', '--flat-playlist', '--no-warnings'] // Add encoding support for proper handling of non-ASCII characters args.push('--encoding', 'utf-8') @@ -140,8 +141,52 @@ class DownloadEngine extends EventEmitter { args.push('--cookies', cookiesPath) } + // Add config file if configured + if (settings.configPath) { + args.push('--config-location', `"${settings.configPath}"`) + } + args.push(url) + type RawPlaylistEntry = { + id?: string + title?: string + url?: string + webpage_url?: string + original_url?: string + ie_key?: string + } + + const resolveEntryUrl = (entry: RawPlaylistEntry): string => { + if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) { + return entry.url + } + if (entry.webpage_url && typeof entry.webpage_url === 'string') { + return entry.webpage_url + } + if (entry.original_url && typeof entry.original_url === 'string') { + return entry.original_url + } + if (entry.url && typeof entry.url === 'string') { + if (entry.ie_key && typeof entry.ie_key === 'string') { + const extractor = entry.ie_key.toLowerCase() + if (extractor.includes('youtube')) { + return `https://www.youtube.com/watch?v=${entry.url}` + } + if (extractor.includes('youtubemusic')) { + return `https://music.youtube.com/watch?v=${entry.url}` + } + } + if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) { + return entry.url + } + } + if (entry.id && typeof entry.id === 'string') { + return entry.id + } + return '' + } + return new Promise((resolve, reject) => { const process = ytdlp.exec(args) let stdout = '' @@ -158,51 +203,107 @@ class DownloadEngine extends EventEmitter { process.on('close', (code) => { if (code === 0 && stdout) { try { - const lines = stdout.trim().split('\n') - const entries = lines.map((line) => JSON.parse(line)) - const playlistEntry = entries[0] + const parsed = JSON.parse(stdout) as { + id?: string + title?: string + entries?: RawPlaylistEntry[] + } + const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : [] + const entries = rawEntries + .map((entry, index) => { + const resolvedUrl = resolveEntryUrl(entry) + return { + id: entry.id || `${index}`, + title: entry.title || `Entry ${index + 1}`, + url: resolvedUrl, + index: index + 1 + } + }) + .filter((entry) => entry.url) + scopedLoggers.download.info( + 'Successfully retrieved playlist info for:', + url, + 'entries:', + entries.length + ) resolve({ - id: playlistEntry.id || '', - title: playlistEntry.title || 'Playlist', - entries: entries.map((entry) => ({ - id: entry.id || '', - title: entry.title || 'Unknown', - url: entry.url || entry.webpage_url || '' - })), + id: parsed.id || url, + title: parsed.title || 'Playlist', + entries, entryCount: entries.length }) } catch (error) { + scopedLoggers.download.error('Failed to parse playlist info for:', url, error) reject(new Error(`Failed to parse playlist info: ${error}`)) } } else { + scopedLoggers.download.error( + 'Failed to fetch playlist info for:', + url, + 'Exit code:', + code, + 'Error:', + stderr + ) reject(new Error(stderr || 'Failed to fetch playlist info')) } }) process.on('error', (error) => { + scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error) reject(error) }) }) } - async startPlaylistDownload(options: PlaylistDownloadOptions): Promise { + async startPlaylistDownload(options: PlaylistDownloadOptions): Promise { const playlistInfo = await this.getPlaylistInfo(options.url) - const downloadIds: string[] = [] + const downloadEntries: PlaylistDownloadResult['entries'] = [] + const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}` // Calculate the range of entries to download - const startIndex = (options.startIndex || 1) - 1 // Convert to 0-based index - const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1 - const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1) + const totalEntries = playlistInfo.entries.length + if (totalEntries === 0) { + scopedLoggers.download.warn('Playlist has no entries:', options.url) + return { + groupId, + playlistId: playlistInfo.id, + playlistTitle: playlistInfo.title, + type: options.type, + totalCount: 0, + startIndex: 0, + endIndex: 0, + entries: [] + } + } + + const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0) + const requestedEnd = options.endIndex + ? Math.min(options.endIndex - 1, totalEntries - 1) + : totalEntries - 1 + const rangeStart = Math.min(requestedStart, requestedEnd) + const rangeEnd = Math.max(requestedStart, requestedEnd) + const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1) + const settings = settingsManager.getAll() + + const selectedEntries = rawEntries.filter((entry) => { + if (!entry.url) { + scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry) + return false + } + return true + }) + + const selectionSize = selectedEntries.length scopedLoggers.download.info( - `Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"` + `Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"` ) // Create download items for each video in the playlist - for (const entry of entriesToDownload) { - const downloadId = `playlist_${Date.now()}_${Math.random().toString(36).substring(7)}` - downloadIds.push(downloadId) + for (const entry of selectedEntries) { + const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}` const downloadOptions: DownloadOptions = { url: entry.url, @@ -212,6 +313,13 @@ class DownloadEngine extends EventEmitter { } const createdAt = Date.now() + downloadEntries.push({ + downloadId, + entryId: entry.id, + title: entry.title, + url: entry.url, + index: entry.index + }) // Add to queue this.queue.add(downloadId, downloadOptions, { @@ -221,17 +329,35 @@ class DownloadEngine extends EventEmitter { type: options.type, status: 'pending', progress: { percent: 0 }, - createdAt + createdAt, + playlistId: groupId, + playlistTitle: playlistInfo.title, + playlistIndex: entry.index, + playlistSize: selectionSize }) this.upsertHistoryEntry(downloadId, downloadOptions, { title: entry.title, status: 'pending', - downloadedAt: createdAt + downloadedAt: createdAt, + downloadPath: settings.downloadPath, + playlistId: groupId, + playlistTitle: playlistInfo.title, + playlistIndex: entry.index, + playlistSize: selectionSize }) } - return downloadIds + return { + groupId, + playlistId: playlistInfo.id, + playlistTitle: playlistInfo.title, + type: options.type, + totalCount: selectionSize, + startIndex: selectedEntries[0]?.index ?? rangeStart + 1, + endIndex: selectedEntries[selectedEntries.length - 1]?.index ?? rangeEnd + 1, + entries: downloadEntries + } } startDownload(id: string, options: DownloadOptions): void { @@ -607,6 +733,18 @@ class DownloadEngine extends EventEmitter { if (updates.tags !== undefined) { historyUpdates.tags = updates.tags } + if (updates.playlistId !== undefined) { + historyUpdates.playlistId = updates.playlistId + } + if (updates.playlistTitle !== undefined) { + historyUpdates.playlistTitle = updates.playlistTitle + } + if (updates.playlistIndex !== undefined) { + historyUpdates.playlistIndex = updates.playlistIndex + } + if (updates.playlistSize !== undefined) { + historyUpdates.playlistSize = updates.playlistSize + } if (updates.status !== undefined) { historyUpdates.status = updates.status } @@ -651,7 +789,11 @@ class DownloadEngine extends EventEmitter { channel: completedDownload?.item.channel, uploader: completedDownload?.item.uploader, viewCount: completedDownload?.item.viewCount, - tags: completedDownload?.item.tags + tags: completedDownload?.item.tags, + playlistId: completedDownload?.item.playlistId, + playlistTitle: completedDownload?.item.playlistTitle, + playlistIndex: completedDownload?.item.playlistIndex, + playlistSize: completedDownload?.item.playlistSize }) } @@ -683,7 +825,11 @@ class DownloadEngine extends EventEmitter { viewCount: updates.viewCount, tags: updates.tags, // Download-specific format info - selectedFormat: updates.selectedFormat + selectedFormat: updates.selectedFormat, + playlistId: updates.playlistId, + playlistTitle: updates.playlistTitle, + playlistIndex: updates.playlistIndex, + playlistSize: updates.playlistSize } const merged: DownloadHistoryItem = { diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 818a9f5..399810c 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -214,6 +214,28 @@ export function DownloadItem({ download }: DownloadItemProps) { )} + {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)} diff --git a/src/renderer/src/components/download/PlaylistDownloadGroup.tsx b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx new file mode 100644 index 0000000..c0016ea --- /dev/null +++ b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import type { DownloadRecord } from '../../store/downloads' +import { DownloadItem } from './DownloadItem' + +interface PlaylistDownloadGroupProps { + groupId: string + title: string + records: DownloadRecord[] + totalCount: number +} + +export function PlaylistDownloadGroup({ + groupId, + title, + records, + totalCount +}: PlaylistDownloadGroupProps) { + const { t } = useTranslation() + + const completedCount = records.filter((record) => record.status === 'completed').length + const errorCount = records.filter((record) => record.status === 'error').length + const activeCount = records.filter((record) => + ['downloading', 'processing', 'pending'].includes(record.status) + ).length + + const displayTitle = title || t('playlist.untitled') + + return ( + + + + {displayTitle} + + {t('playlist.groupSummary', { completed: completedCount, total: totalCount })} + + + + {activeCount > 0 && {t('playlist.groupActive', { count: activeCount })}} + {errorCount > 0 && ( + + {t('playlist.groupErrors', { count: errorCount })} + + )} + + + + + {records.map((record) => ( + + + + ))} + + + ) +} diff --git a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx index 22165ce..28274af 100644 --- a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx +++ b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx @@ -6,8 +6,10 @@ import { History as HistoryIcon } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useHistorySync } from '../../hooks/use-history-sync' +import type { DownloadRecord } from '../../store/downloads' import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads' import { DownloadItem } from './DownloadItem' +import { PlaylistDownloadGroup } from './PlaylistDownloadGroup' type StatusFilter = 'all' | 'active' | 'completed' | 'error' @@ -47,6 +49,56 @@ export function UnifiedDownloadHistory() { { key: 'error', label: t('download.error'), count: downloadStats.error } ] + const groupedView = useMemo(() => { + const groups = new Map< + string, + { id: string; title: string; totalCount: number; records: DownloadRecord[] } + >() + const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> = + [] + + for (const record of filteredRecords) { + if (record.playlistId) { + let group = groups.get(record.playlistId) + if (!group) { + group = { + id: record.playlistId, + title: record.playlistTitle || record.title, + totalCount: record.playlistSize || 0, + records: [] + } + groups.set(record.playlistId, group) + order.push({ type: 'group', id: record.playlistId }) + } + group.records.push(record) + if (!group.title && record.playlistTitle) { + group.title = record.playlistTitle + } + if (!group.totalCount && record.playlistSize) { + group.totalCount = record.playlistSize + } + } else { + order.push({ type: 'single', record }) + } + } + + for (const group of groups.values()) { + group.records.sort((a, b) => { + const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER + const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER + if (aIndex !== bIndex) { + return aIndex - bIndex + } + return b.createdAt - a.createdAt + }) + if (!group.totalCount) { + group.totalCount = group.records.length + } + } + + return { order, groups } + }, [filteredRecords]) + const hasCompletedActive = allRecords.some( (item) => item.entryType === 'active' && item.status === 'completed' ) @@ -110,10 +162,32 @@ export function UnifiedDownloadHistory() { {t('download.noItems')} ) : ( - - {filteredRecords.map((record) => ( - - ))} + + {groupedView.order.map((item) => { + if (item.type === 'single') { + return ( + + ) + } + + const group = groupedView.groups.get(item.id) + if (!group) { + return null + } + + return ( + + ) + })} )} diff --git a/src/renderer/src/components/playlist/PlaylistPreviewCard.tsx b/src/renderer/src/components/playlist/PlaylistPreviewCard.tsx new file mode 100644 index 0000000..f8a2bae --- /dev/null +++ b/src/renderer/src/components/playlist/PlaylistPreviewCard.tsx @@ -0,0 +1,94 @@ +import { Button } from '@renderer/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@renderer/components/ui/card' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import type { PlaylistEntry, PlaylistInfo } from '@shared/types' +import { useTranslation } from 'react-i18next' + +interface PlaylistPreviewCardProps { + playlist: PlaylistInfo + entries: PlaylistEntry[] + onClear?: () => void +} + +export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) { + const { t } = useTranslation() + + const totalCount = playlist.entryCount + const selectedCount = entries.length + const firstIndex = entries[0]?.index ?? null + const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null + + return ( + + + + + {playlist.title || t('playlist.untitled')} + + + + {t('playlist.totalVideos', { count: totalCount })} + {firstIndex !== null && lastIndex !== null ? ( + + {t('playlist.selectedRange', { + start: firstIndex, + end: lastIndex + })} + + ) : ( + {t('playlist.noRangeSelected')} + )} + + {t('playlist.showingCount', { count: selectedCount })} + + + + + {onClear && ( + + {t('playlist.clearPreview')} + + )} + + + + + + {entries.length === 0 ? ( + + {t('playlist.noEntriesInRange')} + + ) : ( + entries.map((entry) => ( + + + #{entry.index} + + + {entry.title} + + + )) + )} + + + + + + ) +} diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 26e4a76..4e0d891 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -226,6 +226,8 @@ "videoCopied": "Video copied to clipboard" }, "playlist": { + "badgeLabel": "Playlist", + "clearPreview": "Clear preview", "comingSoon": "Playlist download feature coming soon!", "completed": "Playlist downloaded", "description": "Download all videos from a YouTube playlist or channel", @@ -240,12 +242,27 @@ "filenameFormat": "Filename format for playlists", "folderFormat": "Folder name format for playlists", "foundVideos": "Found {{count}} videos in playlist", + "groupActive": "{{count}} active", + "groupErrors": "{{count}} failed", + "groupSummary": "{{completed}} / {{total}} completed", "linkLabel": "Playlist URL", + "noEntries": "No videos were found in this playlist", + "noEntriesInRange": "No videos in the selected range", + "noRangeSelected": "No end set - full playlist selected", "playlistUrlDescription": "Download all videos from a playlist in bulk", + "positionLabel": "Item {{index}} of {{total}}", + "previewButton": "Preview playlist", + "previewFailed": "Failed to preview playlist", + "previewSummary": "Preview playlist items before downloading.", + "previewRequired": "Preview the playlist before downloading.", "range": "Range (Optional)", "resetToDefault": "Reset to default", + "selectedRange": "Range: {{start}}-{{end}}", + "showingCount": "Showing {{count}} videos", "startIndex": "Start (1)", - "title": "Download Playlist" + "title": "Download Playlist", + "totalVideos": "Total videos: {{count}}", + "untitled": "Untitled playlist" }, "settings": { "aboutTab": "About", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index f7a94a2..be0c6b9 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -22,6 +22,12 @@ "followAuthorTitle": "關注開發者", "here": "此處", "homepage": "首頁", + "latestVersionBadge": "最新版本:v{{version}}", + "latestVersionStatus": { + "available": "有新版本可用", + "error": "無法取得最新版本", + "uptodate": "您已是最新版本" + }, "notifications": { "checkingUpdates": "正在搜尋更新...", "downloadError": "下載更新失敗", @@ -62,13 +68,7 @@ "sourceCode": "原始碼已開放", "title": "關於", "version": "版本", - "versionLabel": "v{{version}}", - "latestVersionBadge": "最新版本:v{{version}}", - "latestVersionStatus": { - "available": "有新版本可用", - "uptodate": "您已是最新版本", - "error": "無法取得最新版本" - } + "versionLabel": "v{{version}}" }, "advancedOptions": { "closeWhenDone": "下載完成後關閉應用程式", @@ -226,6 +226,7 @@ "videoCopied": "影片已複製到剪貼簿" }, "playlist": { + "clearPreview": "清晰預覽", "comingSoon": "播放清單下載功能即將推出!", "completed": "播放清單已下載", "description": "下載 YouTube 播放清單或頻道中的全部影片", @@ -240,12 +241,27 @@ "filenameFormat": "播放清單檔案名稱格式", "folderFormat": "播放清單資料夾命名格式", "foundVideos": "在播放清單中找到 {{count}} 個影片", + "groupActive": "{{count}} 個活躍", + "groupErrors": "{{count}} 失敗", + "groupSummary": "{{已完成}} / {{總計}}已完成", "linkLabel": "播放清單連結", + "noEntries": "在此播放列表中找不到視頻", + "noEntriesInRange": "所選範圍內沒有視頻", + "noRangeSelected": "沒有結束設置 - 已選擇完整播放列表", "playlistUrlDescription": "批量下載播放清單中的所有影片", + "positionLabel": "第 {{index}} 項,共 {{total}} 項", + "previewButton": "預覽播放列表", + "previewFailed": "預覽播放列表失敗", + "previewRequired": "下載前預覽播放列表。", + "previewSummary": "下載前預覽播放列表項目。", "range": "範圍(可選)", "resetToDefault": "恢復預設", + "selectedRange": "範圍:{{開始}}-{{結束}}", + "showingCount": "顯示 {{count}} 個視頻", "startIndex": "開始(1)", - "title": "下載播放清單" + "title": "下載播放清單", + "totalVideos": "視頻總數:{{count}}", + "untitled": "無標題播放列表" }, "settings": { "aboutTab": "關於", @@ -261,8 +277,15 @@ "firefox": "Firefox", "safari": "Safari" }, + "clearCookiesFile": "清除", "configFile": "使用設定檔", "configFileDescription": "yt-dlp 的自訂設定檔", + "cookiesFile": "餅乾文件", + "cookiesFileDescription": "要加載以進行身份驗證的 Netscape 格式的 cookie 文件", + "cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。", + "cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答", + "cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ)並在需要時在此處選擇它。", + "cookiesHelpTitle": "使用cookie", "dark": "深色", "description": "設定下載偏好和應用程式設定", "directorySelectError": "選擇目錄失敗", @@ -289,6 +312,7 @@ "normal": "標準", "worst": "最差" }, + "openLinkError": "無法打開鏈接", "proxy": "代理伺服器", "proxyDescription": "網路請求的代理伺服器", "proxyPlaceholder": "http://proxy:port", diff --git a/src/renderer/src/pages/About.tsx b/src/renderer/src/pages/About.tsx index 2c632cb..ae93303 100644 --- a/src/renderer/src/pages/About.tsx +++ b/src/renderer/src/pages/About.tsx @@ -48,7 +48,7 @@ export function About() { const [appVersion, setAppVersion] = useState('—') const [latestVersionState, setLatestVersionState] = useState(null) const saveSetting = useSetAtom(saveSettingAtom) - const shareTargetUrl = 'https://github.com/nexmoe/VidBee' + const shareTargetUrl = 'https://vidbee.org' useEffect(() => { let isActive = true diff --git a/src/renderer/src/pages/Home.tsx b/src/renderer/src/pages/Home.tsx index 4461e1d..882e4d5 100644 --- a/src/renderer/src/pages/Home.tsx +++ b/src/renderer/src/pages/Home.tsx @@ -15,15 +15,16 @@ import { SelectTrigger, SelectValue } from '@renderer/components/ui/select' -import { Tabs, TabsContent } from '@renderer/components/ui/tabs' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { popularSites } from '@renderer/data/popularSites' -import type { AppSettings, OneClickQualityPreset } from '@shared/types' +import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types' import { useAtom, useSetAtom } from 'jotai' -import { AlertCircle, Download, Loader2, Search } from 'lucide-react' -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory' +import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard' import { VideoInfoCard } from '../components/video/VideoInfoCard' import { ipcEvents, ipcServices } from '../lib/ipc' import { @@ -148,10 +149,41 @@ export function Home({ onOpenSupportedSites }: HomeProps) { const playlistUrlId = useId() const downloadTypeId = useId() const [playlistUrl, setPlaylistUrl] = useState('') - const [playlistLoading, setPlaylistLoading] = useState(false) const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video') const [startIndex, setStartIndex] = useState('1') const [endIndex, setEndIndex] = useState('') + const [playlistInfo, setPlaylistInfo] = useState(null) + const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false) + const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false) + const [playlistPreviewError, setPlaylistPreviewError] = useState(null) + const playlistBusy = playlistPreviewLoading || playlistDownloadLoading + + const computePlaylistRange = useCallback( + (info: PlaylistInfo) => { + const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1) + const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined + const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart + const endValue = + rawEnd !== undefined + ? info.entryCount > 0 + ? Math.min(rawEnd, info.entryCount) + : rawEnd + : undefined + return { start, end: endValue } + }, + [startIndex, endIndex] + ) + + const selectedPlaylistEntries = useMemo(() => { + if (!playlistInfo) { + return [] + } + const range = computePlaylistRange(playlistInfo) + const previewEnd = range.end ?? playlistInfo.entryCount + return playlistInfo.entries.filter( + (entry) => entry.index >= range.start && entry.index <= previewEnd + ) + }, [playlistInfo, computePlaylistRange]) const syncHistoryItem = useCallback( async (id: string) => { @@ -366,70 +398,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) { // Playlist handlers const handlePastePlaylistUrl = useCallback(async () => { + if (playlistBusy) return try { const text = await navigator.clipboard.readText() if (!text.trim()) { toast.error(t('errors.clipboardEmpty')) return } - setPlaylistUrl(text.trim()) + const trimmed = text.trim() + setPlaylistUrl(trimmed) + setPlaylistInfo(null) + setPlaylistPreviewError(null) } catch (error) { console.error('Failed to paste URL:', error) toast.error(t('errors.pasteFromClipboard')) } - }, [t]) + }, [playlistBusy, t]) - const handleDownloadPlaylist = useCallback(async () => { + const handleClearPlaylistPreview = useCallback(() => { + setPlaylistInfo(null) + setPlaylistPreviewError(null) + }, []) + + const handlePreviewPlaylist = useCallback(async () => { if (!playlistUrl.trim()) { toast.error(t('errors.emptyUrl')) return } - - setPlaylistLoading(true) + setPlaylistPreviewError(null) + setPlaylistPreviewLoading(true) try { - // Get playlist info first to show user what will be downloaded - const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl) + const trimmedUrl = playlistUrl.trim() + const info = await ipcServices.download.getPlaylistInfo(trimmedUrl) + setPlaylistInfo(info) + if (info.entryCount === 0) { + toast.error(t('playlist.noEntries')) + return + } + toast.success(t('playlist.foundVideos', { count: info.entryCount })) + } catch (error) { + console.error('Failed to fetch playlist info:', error) + const message = + error instanceof Error && error.message ? error.message : t('playlist.previewFailed') + setPlaylistPreviewError(message) + setPlaylistInfo(null) + toast.error(t('playlist.previewFailed')) + } finally { + setPlaylistPreviewLoading(false) + } + }, [playlistUrl, t]) - toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount })) + const handleDownloadPlaylist = useCallback(async () => { + const trimmedUrl = playlistUrl.trim() + if (!trimmedUrl) { + toast.error(t('errors.emptyUrl')) + return + } + + if (!playlistInfo) { + toast.error(t('playlist.previewRequired')) + return + } + + setPlaylistPreviewError(null) + setPlaylistDownloadLoading(true) + try { + const info = playlistInfo + setPlaylistInfo(info) + + if (info.entryCount === 0) { + toast.error(t('playlist.noEntries')) + return + } + + const range = computePlaylistRange(info) + const previewEnd = range.end ?? info.entryCount + + if (previewEnd < range.start || previewEnd === 0) { + toast.error(t('playlist.noEntriesInRange')) + return + } - // Build format preference based on settings const format = downloadType === 'video' ? buildVideoFormatPreference(settings) : buildAudioFormatPreference(settings) - // Start playlist download - const downloadIds = await ipcServices.download.startPlaylistDownload({ - url: playlistUrl.trim(), + const result = await ipcServices.download.startPlaylistDownload({ + url: trimmedUrl, type: downloadType, format, - startIndex: parseInt(startIndex, 10) || 1, - endIndex: endIndex ? parseInt(endIndex, 10) : undefined + startIndex: range.start, + endIndex: range.end }) - // Add all downloads to the renderer state - for (const id of downloadIds) { + if (result.totalCount === 0) { + toast.error(t('playlist.noEntriesInRange')) + return + } + + const baseCreatedAt = Date.now() + result.entries.forEach((entry, index) => { const downloadItem = { - id, - url: playlistUrl.trim(), - title: t('download.fetchingVideoInfo'), + id: entry.downloadId, + url: entry.url, + title: entry.title || t('download.fetchingVideoInfo'), type: downloadType, status: 'pending' as const, progress: { percent: 0 }, - createdAt: Date.now() + createdAt: baseCreatedAt + index, + playlistId: result.groupId, + playlistTitle: result.playlistTitle, + playlistIndex: entry.index, + playlistSize: result.totalCount } addDownload(downloadItem) - } + }) - toast.success(t('playlist.downloadStarted', { count: downloadIds.length })) - setPlaylistUrl('') // Clear the URL after starting download + toast.success(t('playlist.downloadStarted', { count: result.totalCount })) } catch (error) { console.error('Failed to start playlist download:', error) toast.error(t('playlist.downloadFailed')) } finally { - setPlaylistLoading(false) + setPlaylistDownloadLoading(false) } - }, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t]) + }, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t]) // Auto-focus input on mount useEffect(() => { @@ -442,7 +535,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) { style={{ maxWidth: '100%' }} > - {/* + {t('download.singleVideo')} @@ -451,7 +544,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) { {t('playlist.title')} - */} + {/* Single Video Download Tab */} @@ -580,14 +673,18 @@ export function Home({ onOpenSupportedSites }: HomeProps) { id={playlistUrlId} placeholder="https://www.youtube.com/playlist?list=..." value={playlistUrl} - onChange={(e) => setPlaylistUrl(e.target.value)} + onChange={(e) => { + setPlaylistUrl(e.target.value) + setPlaylistInfo(null) + setPlaylistPreviewError(null) + }} className="flex-1" - disabled={playlistLoading} + disabled={playlistBusy} /> {t('download.paste')} @@ -600,7 +697,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) { setDownloadType(v as 'video' | 'audio')} - disabled={playlistLoading} + disabled={playlistBusy} > @@ -621,7 +718,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) { value={startIndex} onChange={(e) => setStartIndex(e.target.value)} min="1" - disabled={playlistLoading} + disabled={playlistBusy} /> setEndIndex(e.target.value)} min="1" - disabled={playlistLoading} + disabled={playlistBusy} /> - - {playlistLoading ? ( - <> - - {t('download.loading')} - > - ) : ( - t('playlist.downloadPlaylist') + + + {playlistPreviewLoading ? ( + <> + + {t('download.loading')} + > + ) : ( + <> + + {t('playlist.previewButton')} + > + )} + + {playlistInfo && ( + + {playlistDownloadLoading ? ( + <> + + {t('download.loading')} + > + ) : ( + t('playlist.downloadPlaylist') + )} + )} - + + + {playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && ( + {t('playlist.previewRequired')} + )} + + {playlistPreviewError && ( + + {playlistPreviewError} + + )} + {playlistInfo && ( + + )} diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index d49e49f..0d8e732 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -39,6 +39,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({ viewCount: item.viewCount, tags: item.tags, selectedFormat: item.selectedFormat, + playlistId: item.playlistId, + playlistTitle: item.playlistTitle, + playlistIndex: item.playlistIndex, + playlistSize: item.playlistSize, entryType: 'history', downloadedAt: item.downloadedAt }) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index ca527d3..66c9a2a 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -76,6 +76,11 @@ export interface DownloadItem { tags?: string[] // Download-specific format info selectedFormat?: VideoFormat + // Playlist context (optional) + playlistId?: string + playlistTitle?: string + playlistIndex?: number + playlistSize?: number } export interface DownloadHistoryItem { @@ -103,6 +108,11 @@ export interface DownloadHistoryItem { tags?: string[] // Download-specific format info selectedFormat?: VideoFormat + // Playlist context (optional) + playlistId?: string + playlistTitle?: string + playlistIndex?: number + playlistSize?: number } export interface DownloadOptions { @@ -117,14 +127,17 @@ export interface DownloadOptions { downloadSubs?: boolean } +export interface PlaylistEntry { + id: string + title: string + url: string + index: number +} + export interface PlaylistInfo { id: string title: string - entries: Array<{ - id: string - title: string - url: string - }> + entries: PlaylistEntry[] entryCount: number } @@ -138,6 +151,25 @@ export interface PlaylistDownloadOptions { folderFormat?: string } +export interface PlaylistDownloadEntry { + downloadId: string + entryId: string + title: string + url: string + index: number +} + +export interface PlaylistDownloadResult { + groupId: string + playlistId: string + playlistTitle: string + type: 'video' | 'audio' + totalCount: number + startIndex: number + endIndex: number + entries: PlaylistDownloadEntry[] +} + // Settings types export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
{displayTitle}
+ {t('playlist.groupSummary', { completed: completedCount, total: totalCount })} +
{t('download.noItems')}
{t('playlist.previewRequired')}