feat: playlist (#2)

* feat: implement playlist download feature with UI components, enhance download service for playlist handling, and update translations for playlist-related terms

* refactor: replace console logs with log.info for auto-updater events and enhance zh-TW translations with new keys and updates
This commit is contained in:
Nexmoe
2025-10-29 20:32:27 +08:00
committed by GitHub
parent a1307bdd0b
commit 512b9d1175
17 changed files with 708 additions and 119 deletions

View File

@@ -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)
}
}

View File

@@ -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<string[]> {
): Promise<PlaylistDownloadResult> {
return downloadEngine.startPlaylistDownload(options)
}
}

View File

@@ -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<string[]> {
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
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 = {

View File

@@ -214,6 +214,28 @@ export function DownloadItem({ download }: DownloadItemProps) {
</div>
)}
</div>
{download.playlistId && (
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="secondary"
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
>
{t('playlist.badgeLabel')}
</Badge>
<span className="truncate">
{download.playlistTitle || t('playlist.untitled')}
{download.playlistIndex !== undefined &&
download.playlistSize !== undefined && (
<span className="ml-1 text-muted-foreground/80">
{t('playlist.positionLabel', {
index: download.playlistIndex,
total: download.playlistSize
})}
</span>
)}
</span>
</div>
)}
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>

View File

@@ -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 (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
{errorCount > 0 && (
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
</div>
</div>
<div className="space-y-2">
{records.map((record) => (
<div
key={`${groupId}:${record.entryType}:${record.id}`}
className="border-l border-border/50 pl-3"
>
<DownloadItem download={record} />
</div>
))}
</div>
</div>
)
}

View File

@@ -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() {
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => (
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
))}
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
{groupedView.order.map((item) => {
if (item.type === 'single') {
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
/>
)
})}
</div>
)}
</CardContent>

View File

@@ -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 (
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1 space-y-1">
<CardTitle
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
title={playlist.title}
>
{playlist.title || t('playlist.untitled')}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
{firstIndex !== null && lastIndex !== null ? (
<span className="truncate">
{t('playlist.selectedRange', {
start: firstIndex,
end: lastIndex
})}
</span>
) : (
<span className="truncate">{t('playlist.noRangeSelected')}</span>
)}
<span className="truncate">
{t('playlist.showingCount', { count: selectedCount })}
</span>
</div>
</CardDescription>
</div>
{onClear && (
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
{t('playlist.clearPreview')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/20">
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
{entries.length === 0 ? (
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
{t('playlist.noEntriesInRange')}
</li>
) : (
entries.map((entry) => (
<li
key={`${entry.index}-${entry.id}`}
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
>
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
#{entry.index}
</span>
<span
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
title={entry.title}
>
{entry.title}
</span>
</li>
))
)}
</ol>
</ScrollArea>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -48,7 +48,7 @@ export function About() {
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
const shareTargetUrl = 'https://vidbee.org'
useEffect(() => {
let isActive = true

View File

@@ -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<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(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%' }}
>
<Tabs defaultValue="single" className="w-full">
{/* <TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
@@ -451,7 +544,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList> */}
</TabsList>
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6">
@@ -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}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistLoading}
disabled={playlistBusy}
>
{t('download.paste')}
</Button>
@@ -600,7 +697,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistLoading}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
@@ -621,7 +718,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistLoading}
disabled={playlistBusy}
/>
<Input
type="number"
@@ -629,29 +726,68 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistLoading}
disabled={playlistBusy}
/>
</div>
</div>
</div>
<Button
onClick={handleDownloadPlaylist}
className="w-full"
size="lg"
disabled={playlistLoading || !playlistUrl.trim()}
>
{playlistLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}
variant="outline"
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
)}
</Button>
</div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</CardContent>
</Card>
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/>
)}
</TabsContent>
</Tabs>

View File

@@ -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
})

View File

@@ -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'