diff --git a/src/main/ipc/services/download-service.ts b/src/main/ipc/services/download-service.ts index f9b733a..4070823 100644 --- a/src/main/ipc/services/download-service.ts +++ b/src/main/ipc/services/download-service.ts @@ -5,7 +5,8 @@ import type { PlaylistDownloadOptions, PlaylistDownloadResult, PlaylistInfo, - VideoInfo + VideoInfo, + VideoInfoCommandResult } from '../../../shared/types' import { downloadEngine } from '../../lib/download-engine' @@ -17,6 +18,14 @@ class DownloadService extends IpcService { return downloadEngine.getVideoInfo(url) } + @IpcMethod() + async getVideoInfoWithCommand( + _context: IpcContext, + url: string + ): Promise { + return downloadEngine.getVideoInfoWithCommand(url) + } + @IpcMethod() async getPlaylistInfo(_context: IpcContext, url: string): Promise { return downloadEngine.getPlaylistInfo(url) diff --git a/src/main/lib/database/schema.ts b/src/main/lib/database/schema.ts index 0e2de38..c7927e7 100644 --- a/src/main/lib/database/schema.ts +++ b/src/main/lib/database/schema.ts @@ -15,6 +15,7 @@ export const downloadHistoryTable = sqliteTable('download_history', { completedAt: integer('completed_at', { mode: 'number' }), sortKey: integer('sort_key', { mode: 'number' }).notNull(), error: text('error'), + ytDlpCommand: text('yt_dlp_command'), description: text('description'), channel: text('channel'), uploader: text('uploader'), diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 72893a9..18f03c4 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -11,7 +11,8 @@ import type { PlaylistDownloadResult, PlaylistInfo, VideoFormat, - VideoInfo + VideoInfo, + VideoInfoCommandResult } from '../../shared/types' import { buildDownloadArgs, @@ -218,6 +219,44 @@ const appendJsRuntimeArgs = (args: string[]): void => { } } +const buildVideoInfoArgs = (url: string, settings: ReturnType) => { + const args = ['-j', '--no-playlist', '--no-warnings'] + + // Add encoding support for proper handling of non-ASCII characters + args.push('--encoding', 'utf-8') + + // Note: Some sites (e.g., YouTube) may not provide filesize information + // in the initial request. This is normal behavior and filesize may be null/undefined + // for many formats. File size information might require additional HTTP HEAD requests + // which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default. + + // Add proxy if configured + if (settings.proxy) { + args.push('--proxy', settings.proxy) + } + + // Add browser cookies if configured (skip if 'none') + if (settings.browserForCookies && settings.browserForCookies !== 'none') { + args.push('--cookies-from-browser', settings.browserForCookies) + } + + const cookiesPath = settings.cookiesPath?.trim() + if (cookiesPath) { + args.push('--cookies', cookiesPath) + } + + // Add config file if configured + const configPath = resolvePathWithHome(settings.configPath) + if (configPath) { + args.push('--config-location', configPath) + } + + appendJsRuntimeArgs(args) + args.push(url) + + return args +} + class DownloadEngine extends EventEmitter { private activeDownloads: Map = new Map() private queue: DownloadQueue @@ -236,39 +275,7 @@ class DownloadEngine extends EventEmitter { const ytdlp = ytdlpManager.getInstance() const settings = settingsManager.getAll() - const args = ['-j', '--no-playlist', '--no-warnings'] - - // Add encoding support for proper handling of non-ASCII characters - args.push('--encoding', 'utf-8') - - // Note: Some sites (e.g., YouTube) may not provide filesize information - // in the initial request. This is normal behavior and filesize may be null/undefined - // for many formats. File size information might require additional HTTP HEAD requests - // which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default. - - // Add proxy if configured - if (settings.proxy) { - args.push('--proxy', settings.proxy) - } - - // Add browser cookies if configured (skip if 'none') - if (settings.browserForCookies && settings.browserForCookies !== 'none') { - args.push('--cookies-from-browser', settings.browserForCookies) - } - - const cookiesPath = settings.cookiesPath?.trim() - if (cookiesPath) { - args.push('--cookies', cookiesPath) - } - - // Add config file if configured - const configPath = resolvePathWithHome(settings.configPath) - if (configPath) { - args.push('--config-location', configPath) - } - - appendJsRuntimeArgs(args) - args.push(url) + const args = buildVideoInfoArgs(url, settings) return new Promise((resolve, reject) => { const process = ytdlp.exec(args) @@ -334,6 +341,90 @@ class DownloadEngine extends EventEmitter { }) } + async getVideoInfoWithCommand(url: string): Promise { + const ytdlp = ytdlpManager.getInstance() + const settings = settingsManager.getAll() + const args = buildVideoInfoArgs(url, settings) + const ytDlpCommand = formatYtDlpCommand(args) + + return new Promise((resolve) => { + let settled = false + const resolveOnce = (payload: VideoInfoCommandResult) => { + if (settled) { + return + } + settled = true + resolve(payload) + } + + const process = ytdlp.exec(args) + let stdout = '' + let stderr = '' + + process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + + process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + process.on('close', (code) => { + if (code === 0 && stdout) { + try { + const info = JSON.parse(stdout) + + // Calculate estimated file size for formats missing filesize information + // Using tbr (total bitrate in kbps) and duration (in seconds) + // Formula: (tbr * 1000) / 8 * duration = size in bytes + if (info.formats && Array.isArray(info.formats) && info.duration) { + const duration = info.duration + for (const format of info.formats) { + if ( + !format.filesize && + !format.filesize_approx && + format.tbr && + typeof format.tbr === 'number' && + duration > 0 + ) { + const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration) + format.filesize_approx = estimatedSize + } + } + } + + scopedLoggers.download.info('Successfully retrieved video info for:', url) + resolveOnce({ info, ytDlpCommand }) + } catch (error) { + scopedLoggers.download.error('Failed to parse video info for:', url, error) + resolveOnce({ + ytDlpCommand, + error: `Failed to parse video info: ${error instanceof Error ? error.message : error}` + }) + } + } else { + scopedLoggers.download.error( + 'Failed to fetch video info for:', + url, + 'Exit code:', + code, + 'Error:', + stderr + ) + resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' }) + } + }) + + process.on('error', (error) => { + scopedLoggers.download.error('yt-dlp process error for:', url, error) + resolveOnce({ + ytDlpCommand, + error: error instanceof Error ? error.message : 'Failed to fetch video info' + }) + }) + }) + } + async getPlaylistInfo(url: string): Promise { const ytdlp = ytdlpManager.getInstance() const settings = settingsManager.getAll() @@ -809,7 +900,9 @@ class DownloadEngine extends EventEmitter { args.push('--ffmpeg-location', ffmpegPath) args.push(urlArg) - scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args)) + const ytDlpCommand = formatYtDlpCommand(args) + this.updateDownloadInfo(id, { ytDlpCommand }) + scopedLoggers.download.info('yt-dlp command:', ytDlpCommand) const controller = new AbortController() const ytdlpProcess = ytdlp.exec(args, { @@ -1146,6 +1239,9 @@ class DownloadEngine extends EventEmitter { if (updates.error !== undefined) { historyUpdates.error = updates.error } + if (updates.ytDlpCommand !== undefined) { + historyUpdates.ytDlpCommand = updates.ytDlpCommand + } if (updates.savedFileName !== undefined) { historyUpdates.savedFileName = updates.savedFileName } @@ -1210,6 +1306,7 @@ class DownloadEngine extends EventEmitter { downloadedAt: updates.downloadedAt ?? Date.now(), completedAt: updates.completedAt, error: updates.error, + ytDlpCommand: updates.ytDlpCommand, description: updates.description, channel: updates.channel, uploader: updates.uploader, diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index 95e3631..625d1fc 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -36,6 +36,7 @@ const createDownloadHistoryTableSql = sql` completed_at INTEGER, sort_key INTEGER NOT NULL, error TEXT, + yt_dlp_command TEXT, description TEXT, channel TEXT, uploader TEXT, @@ -218,7 +219,12 @@ class HistoryManager { } const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec'] - const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name)) + const requiredColumns = ['yt_dlp_command'] + const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name)) + const missingRequired = requiredColumns.some( + (columnName) => !columns.some((column) => column.name === columnName) + ) + const needsRebuild = hasDeprecated || missingRequired if (needsRebuild) { this.rebuildDownloadHistoryTable() } @@ -387,6 +393,7 @@ class HistoryManager { completedAt: item.completedAt ?? null, sortKey: item.completedAt ?? item.downloadedAt, error: item.error ?? null, + ytDlpCommand: item.ytDlpCommand ?? null, description: item.description ?? null, channel: item.channel ?? null, uploader: item.uploader ?? null, @@ -433,6 +440,7 @@ class HistoryManager { downloadedAt: row.downloadedAt, completedAt: row.completedAt ?? undefined, error: row.error ?? undefined, + ytDlpCommand: row.ytDlpCommand ?? undefined, description: row.description ?? undefined, channel: row.channel ?? undefined, uploader: row.uploader ?? undefined, diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index d7658e5..3f950b0 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -11,16 +11,7 @@ import { buildVideoFormatPreference } from '@shared/utils/format-preferences' import { useAtom, useSetAtom } from 'jotai' -import { - FolderOpen, - Github, - List, - Loader2, - MessageCircle, - Plus, - Twitter, - Video -} from 'lucide-react' +import { FolderOpen, List, Loader2, Plus, Video } from 'lucide-react' import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -35,15 +26,12 @@ import { loadSettingsAtom, settingsAtom } from '../../store/settings' import { currentVideoInfoAtom, fetchVideoInfoAtom, + videoInfoCommandAtom, videoInfoErrorAtom, videoInfoLoadingAtom } from '../../store/video' import { PlaylistDownload } from './PlaylistDownload' -import { - type FeedbackLink, - SingleVideoDownload, - type SingleVideoState -} from './SingleVideoDownload' +import { SingleVideoDownload, type SingleVideoState } from './SingleVideoDownload' const isLikelyUrl = (value: string): boolean => { try { @@ -54,35 +42,6 @@ const isLikelyUrl = (value: string): boolean => { } } -const normalizeErrorText = (value?: string | null): string => - value ? value.replace(/\s+/g, ' ').trim() : '' - -const clampText = (value: string, maxLength: number): string => - value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value - -const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee' -const FEEDBACK_ISSUE_TITLE = 'Download error report' -const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: ' -const FEEDBACK_UNKNOWN_ERROR = 'Unknown error' -const FEEDBACK_UNKNOWN_VALUE = 'Unknown' -const FEEDBACK_SOURCE_LABEL = 'Source URL' -const FEEDBACK_ERROR_LABEL = 'Error' -const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v' - -const buildIssueLogs = ( - errorText: string, - sourceUrl: string | undefined, - urlLabel: string, - errorLabel: string -): string => { - const lines: string[] = [] - if (sourceUrl) { - lines.push(`${urlLabel}: ${sourceUrl}`) - } - lines.push(`${errorLabel}: ${errorText}`) - return lines.join('\n') -} - const isAudioOnlyFormat = (format: VideoFormat): boolean => !!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none') @@ -147,10 +106,9 @@ export function DownloadDialog({ onOpenSettings: _onOpenSettings }: DownloadDialogProps) { const { t } = useTranslation() - const [appVersion, setAppVersion] = useState('') - const [osVersion, setOsVersion] = useState('') const [open, setOpen] = useState(false) const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom) + const [videoInfoCommand] = useAtom(videoInfoCommandAtom) const [loading] = useAtom(videoInfoLoadingAtom) const [error] = useAtom(videoInfoErrorAtom) const [settings] = useAtom(settingsAtom) @@ -191,74 +149,6 @@ export function DownloadDialog({ const playlistBusy = playlistPreviewLoading || playlistDownloadLoading const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false) const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set()) - const feedbackLinks: FeedbackLink[] = useMemo(() => { - const compactError = normalizeErrorText(error) - const tweetError = compactError ? clampText(compactError, 160) : '' - const tweetText = encodeURIComponent( - tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX - ) - const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR - const issueTitle = FEEDBACK_ISSUE_TITLE - const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300) - const sourceUrl = url.trim() || undefined - const issueLogs = clampText( - buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL), - 800 - ) - const appVersionValue = appVersion - ? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}` - : FEEDBACK_UNKNOWN_VALUE - const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE - return [ - { - icon: Github, - label: t('about.resources.githubIssues'), - href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent( - issueTitle - )}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent( - issueLogs - )}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent( - osVersionValue - )}` - }, - { - icon: Twitter, - label: t('about.resources.xFeedback'), - href: `https://x.com/intent/tweet?text=${tweetText}` - }, - { - icon: MessageCircle, - label: t('about.resources.discord'), - href: 'https://discord.gg/uBqXV6QPdm' - } - ] - }, [appVersion, error, osVersion, t, url]) - - useEffect(() => { - let isActive = true - - const loadAppInfo = async () => { - try { - const [version, osRelease] = await Promise.all([ - ipcServices.app.getVersion(), - ipcServices.app.getOsVersion() - ]) - if (!isActive) { - return - } - setAppVersion(version) - setOsVersion(osRelease) - } catch (loadError) { - console.error('Failed to load app info for feedback links:', loadError) - } - } - - void loadAppInfo() - - return () => { - isActive = false - } - }, []) const computePlaylistRange = useCallback( (info: PlaylistInfo) => { @@ -496,7 +386,11 @@ export function DownloadDialog({ }) try { - const videoInfo = await ipcServices.download.getVideoInfo(trimmedUrl) + const result = await ipcServices.download.getVideoInfoWithCommand(trimmedUrl) + if (!result.info) { + throw new Error(result.error || 'Failed to fetch video info') + } + const videoInfo = result.info updateDownload({ id, @@ -1018,7 +912,8 @@ export function DownloadDialog({ error={error} videoInfo={videoInfo} state={singleVideoState} - feedbackLinks={feedbackLinks} + feedbackSourceUrl={url} + ytDlpCommand={videoInfoCommand ?? undefined} onStateChange={handleSingleVideoStateChange} /> diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 7ec52ee..1208649 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -1,3 +1,7 @@ +import { + DOWNLOAD_FEEDBACK_ISSUE_TITLE, + FeedbackLinkButtons +} from '@renderer/components/feedback/FeedbackLinks' import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { Checkbox } from '@renderer/components/ui/checkbox' @@ -17,16 +21,13 @@ import { CheckCircle2, Copy, FolderOpen, - Github, Info, Loader2, - MessageCircle, Play, Trash2, - Twitter, X } from 'lucide-react' -import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { type ReactNode, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcServices } from '../../lib/ipc' @@ -203,72 +204,8 @@ const formatDateShort = (timestamp?: number) => { }) } -const normalizeErrorText = (value?: string | null): string => - value ? value.replace(/\s+/g, ' ').trim() : '' - -const clampText = (value: string, maxLength: number): string => - value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value - -const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee' -const FEEDBACK_ISSUE_TITLE = 'Download error report' -const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: ' -const FEEDBACK_UNKNOWN_ERROR = 'Unknown error' -const FEEDBACK_UNKNOWN_VALUE = 'Unknown' -const FEEDBACK_SOURCE_LABEL = 'Source URL' -const FEEDBACK_ERROR_LABEL = 'Error' -const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v' - -const buildIssueLogs = ( - errorText: string, - sourceUrl: string | undefined, - urlLabel: string, - errorLabel: string -): string => { - const lines: string[] = [] - if (sourceUrl) { - lines.push(`${urlLabel}: ${sourceUrl}`) - } - lines.push(`${errorLabel}: ${errorText}`) - return lines.join('\n') -} - -type AppInfo = { - appVersion: string - osVersion: string -} - -let cachedAppInfo: AppInfo | null = null -let appInfoPromise: Promise | null = null - -const loadAppInfo = async (): Promise => { - if (cachedAppInfo) { - return cachedAppInfo - } - if (appInfoPromise) { - return appInfoPromise - } - - appInfoPromise = (async () => { - try { - const [version, osRelease] = await Promise.all([ - ipcServices.app.getVersion(), - ipcServices.app.getOsVersion() - ]) - cachedAppInfo = { appVersion: version, osVersion: osRelease } - } catch (error) { - console.error('Failed to load app info for feedback links:', error) - cachedAppInfo = { appVersion: '', osVersion: '' } - } - return cachedAppInfo - })() - - return appInfoPromise -} - export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) { const { t } = useTranslation() - const [appVersion, setAppVersion] = useState('') - const [osVersion, setOsVersion] = useState('') const settings = useAtomValue(settingsAtom) const removeDownload = useSetAtom(removeDownloadAtom) const removeHistory = useSetAtom(removeHistoryRecordAtom) @@ -281,66 +218,6 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D const resolvedExtension = resolveDownloadExtension(download) const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName) const selectionEnabled = isHistory && Boolean(onToggleSelect) - const feedbackLinks = useMemo(() => { - const compactError = normalizeErrorText(download.error) - const tweetError = compactError ? clampText(compactError, 160) : '' - const tweetText = encodeURIComponent( - tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX - ) - const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR - const issueTitle = FEEDBACK_ISSUE_TITLE - const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300) - const sourceUrl = download.url?.trim() || undefined - const issueLogs = clampText( - buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL), - 800 - ) - const appVersionValue = appVersion - ? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}` - : FEEDBACK_UNKNOWN_VALUE - const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE - return [ - { - icon: Github, - label: t('about.resources.githubIssues'), - href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent( - issueTitle - )}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent( - issueLogs - )}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent( - osVersionValue - )}` - }, - { - icon: Twitter, - label: t('about.resources.xFeedback'), - href: `https://x.com/intent/tweet?text=${tweetText}` - }, - { - icon: MessageCircle, - label: t('about.resources.discord'), - href: 'https://discord.gg/uBqXV6QPdm' - } - ] - }, [appVersion, download.error, download.url, osVersion, t]) - - useEffect(() => { - let isActive = true - const fetchInfo = async () => { - const info = await loadAppInfo() - if (!isActive) { - return - } - setAppVersion(info.appVersion) - setOsVersion(info.osVersion) - } - - void fetchInfo() - - return () => { - isActive = false - } - }, []) // Track if the file exists const [fileExists, setFileExists] = useState(false) @@ -1065,24 +942,18 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D {t('download.feedback.title')}
- {feedbackLinks.map((resource) => { - const Icon = resource.icon - return ( - - ) - })} + event.stopPropagation()} + />
diff --git a/src/renderer/src/components/download/SingleVideoDownload.tsx b/src/renderer/src/components/download/SingleVideoDownload.tsx index 9b3e403..7807d90 100644 --- a/src/renderer/src/components/download/SingleVideoDownload.tsx +++ b/src/renderer/src/components/download/SingleVideoDownload.tsx @@ -14,9 +14,13 @@ import { Separator } from '@renderer/components/ui/separator' import { cn } from '@renderer/lib/utils' import type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types' import { useAtom } from 'jotai' -import { AlertCircle, ExternalLink, Loader2, type LucideIcon, Settings2 } from 'lucide-react' +import { AlertCircle, ExternalLink, Loader2, Settings2 } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { + DOWNLOAD_FEEDBACK_ISSUE_TITLE, + FeedbackLinkButtons +} from '../feedback/FeedbackLinks' import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail' import { settingsAtom } from '../../store/settings' @@ -31,18 +35,13 @@ export interface SingleVideoState { selectedFps?: string } -export interface FeedbackLink { - icon: LucideIcon - label: string - href: string -} - interface SingleVideoDownloadProps { loading: boolean error: string | null videoInfo: VideoInfo | null state: SingleVideoState - feedbackLinks: FeedbackLink[] + feedbackSourceUrl?: string | null + ytDlpCommand?: string onStateChange: (state: Partial) => void } @@ -392,7 +391,8 @@ export function SingleVideoDownload({ error, videoInfo, state, - feedbackLinks, + feedbackSourceUrl, + ytDlpCommand, onStateChange }: SingleVideoDownloadProps) { const { t } = useTranslation() @@ -546,23 +546,17 @@ export function SingleVideoDownload({ {t('download.feedback.title')}
- {feedbackLinks.map((resource) => { - const Icon = resource.icon - return ( - - ) - })} +
diff --git a/src/renderer/src/components/feedback/FeedbackLinks.tsx b/src/renderer/src/components/feedback/FeedbackLinks.tsx new file mode 100644 index 0000000..69e0f44 --- /dev/null +++ b/src/renderer/src/components/feedback/FeedbackLinks.tsx @@ -0,0 +1,213 @@ +import { Button, type ButtonProps } from '@renderer/components/ui/button' +import { ipcServices } from '@renderer/lib/ipc' +import { Github, MessageCircle, Twitter } from 'lucide-react' +import { type MouseEvent, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type AppInfo = { + appVersion: string + osVersion: string +} + +const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' } +const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee' +export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[bug]: Download error report' +const FEEDBACK_UNKNOWN_ERROR = 'Unknown error' +const FEEDBACK_UNKNOWN_VALUE = 'Unknown' +const FEEDBACK_SOURCE_LABEL = 'Source URL' +const FEEDBACK_ERROR_LABEL = 'Error' +const FEEDBACK_COMMAND_LABEL = 'yt-dlp command' + +let cachedAppInfo: AppInfo | null = null +let appInfoPromise: Promise | null = null + +const normalizeErrorText = (value?: string | null): string => + value ? value.replace(/\s+/g, ' ').trim() : '' + +const clampText = (value: string, maxLength: number): string => + value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value + +const buildIssueLogs = ( + errorText: string, + sourceUrl: string | undefined, + ytDlpCommand: string | undefined, + urlLabel: string, + errorLabel: string, + commandLabel: string +): string => { + const lines: string[] = [] + if (sourceUrl) { + lines.push(`${urlLabel}: ${sourceUrl}`) + } + if (ytDlpCommand) { + lines.push(`${commandLabel}: ${ytDlpCommand}`) + } + lines.push(`${errorLabel}: ${errorText}`) + return lines.join('\n') +} + +const loadAppInfo = async (): Promise => { + if (cachedAppInfo) { + return cachedAppInfo + } + if (appInfoPromise) { + return appInfoPromise + } + + appInfoPromise = (async () => { + try { + const [version, osRelease] = await Promise.all([ + ipcServices.app.getVersion(), + ipcServices.app.getOsVersion() + ]) + cachedAppInfo = { appVersion: version, osVersion: osRelease } + } catch (error) { + console.error('Failed to load app info for feedback links:', error) + cachedAppInfo = DEFAULT_APP_INFO + } + return cachedAppInfo + })() + + return appInfoPromise +} + +export const useAppInfo = (): AppInfo => { + const [appInfo, setAppInfo] = useState(DEFAULT_APP_INFO) + + useEffect(() => { + let isActive = true + + const loadInfo = async () => { + const info = await loadAppInfo() + if (isActive) { + setAppInfo(info) + } + } + + void loadInfo() + + return () => { + isActive = false + } + }, []) + + return appInfo +} + +type FeedbackLinkButtonsProps = { + error?: string | null + sourceUrl?: string | null + issueTitle?: string + includeAppInfo?: boolean + appInfo?: AppInfo + buttonVariant?: ButtonProps['variant'] + buttonSize?: ButtonProps['size'] + buttonClassName?: string + iconClassName?: string + onLinkClick?: (event: MouseEvent) => void + ytDlpCommand?: string +} + +export const FeedbackLinkButtons = ({ + error, + sourceUrl, + issueTitle = '[bug]: ', + includeAppInfo = false, + appInfo, + buttonVariant = 'outline', + buttonSize = 'sm', + buttonClassName, + iconClassName, + onLinkClick, + ytDlpCommand +}: FeedbackLinkButtonsProps) => { + const { t } = useTranslation() + const fallbackAppInfo = useAppInfo() + const { appVersion, osVersion } = appInfo ?? fallbackAppInfo + + const links = useMemo(() => { + const compactError = normalizeErrorText(error) + const tweetError = compactError ? clampText(compactError, 160) : '' + const versionLabels = [ + appVersion ? `v${appVersion}` : null, + osVersion ? osVersion : null + ].filter(Boolean) + const tweetPrefix = versionLabels.length + ? `${FEEDBACK_TWEET_PREFIX} ${versionLabels.join(' ')}` + : FEEDBACK_TWEET_PREFIX + const tweetText = encodeURIComponent( + tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix + ) + const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR + const resolvedSourceUrl = sourceUrl?.trim() || undefined + const normalizedCommand = ytDlpCommand?.trim() || undefined + const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand) + const issueLogs = shouldIncludeLogs + ? clampText( + buildIssueLogs( + issueError, + resolvedSourceUrl, + normalizedCommand, + FEEDBACK_SOURCE_LABEL, + FEEDBACK_ERROR_LABEL, + FEEDBACK_COMMAND_LABEL + ), + 800 + ) + : null + const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE + const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE + const issueParams = new URLSearchParams({ + template: 'bug_report.yml', + title: issueTitle + }) + + if (issueLogs) { + issueParams.set('logs', issueLogs) + } + if (includeAppInfo) { + issueParams.set('app_version', appVersionValue) + issueParams.set('os_version', osVersionValue) + } + + return [ + { + icon: Github, + label: t('about.resources.githubIssues'), + href: `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}` + }, + { + icon: Twitter, + label: t('about.resources.xFeedback'), + href: `https://x.com/intent/tweet?text=${tweetText}` + }, + { + icon: MessageCircle, + label: t('about.resources.discord'), + href: 'https://discord.gg/uBqXV6QPdm' + } + ] + }, [appVersion, error, includeAppInfo, issueTitle, osVersion, sourceUrl, t, ytDlpCommand]) + + return ( + <> + {links.map((resource) => { + const Icon = resource.icon + return ( + + ) + })} + + ) +} diff --git a/src/renderer/src/pages/About.tsx b/src/renderer/src/pages/About.tsx index b3d8d2a..7b714b2 100644 --- a/src/renderer/src/pages/About.tsx +++ b/src/renderer/src/pages/About.tsx @@ -1,3 +1,4 @@ +import { FeedbackLinkButtons, useAppInfo } from '@renderer/components/feedback/FeedbackLinks' import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card' @@ -11,7 +12,6 @@ import { FileText, Github, Link as LinkIcon, - MessageCircle, MessageSquare, RefreshCw, Twitter @@ -44,33 +44,13 @@ export function About() { const [updateReady] = useAtom(updateReadyAtom) const [updateAvailableState] = useAtom(updateAvailableAtom) const setUpdateAvailable = useSetAtom(updateAvailableAtom) - const [appVersion, setAppVersion] = useState('—') + const { appVersion, osVersion } = useAppInfo() + const appVersionLabel = appVersion || '—' const [latestVersionState, setLatestVersionState] = useState(null) const [updateDownloadProgress, setUpdateDownloadProgress] = useState(null) const saveSetting = useSetAtom(saveSettingAtom) const shareTargetUrl = 'https://vidbee.org' - useEffect(() => { - let isActive = true - - const fetchAppVersion = async () => { - try { - const version = await ipcServices.app.getVersion() - if (isActive) { - setAppVersion(version) - } - } catch (error) { - console.error('Failed to get app version:', error) - } - } - - void fetchAppVersion() - - return () => { - isActive = false - } - }, []) - useEffect(() => { if (!updateAvailableState.available) { return @@ -163,7 +143,7 @@ export function About() { toast.success(t('about.notifications.noUpdatesAvailable')) setLatestVersionState({ status: 'uptodate', - version: result.version ?? appVersion + version: result.version ?? appVersionLabel }) setUpdateAvailable({ available: false, @@ -210,7 +190,7 @@ export function About() { toast.success(t('about.notifications.noUpdatesAvailable')) setLatestVersionState({ status: 'uptodate', - version: result.version ?? appVersion + version: result.version ?? appVersionLabel }) setUpdateAvailable({ available: false, @@ -279,39 +259,6 @@ export function About() { const shouldShowCheckUpdates = !updateAvailableState.available && latestVersionState?.status !== 'available' - const handleXFeedback = useCallback(() => { - const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee' - const tweetText = encodeURIComponent(`@nexmoex${versionText}`) - openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`) - }, [appVersion, openShareUrl]) - - const feedbackResources = useMemo( - () => [ - { - icon: Github, - label: t('about.resources.githubIssues'), - description: t('about.resources.githubIssuesDescription'), - actionLabel: t('about.actions.feedback'), - href: 'https://github.com/nexmoe/VidBee/issues/new/choose' - }, - { - icon: Twitter, - label: t('about.resources.xFeedback'), - description: t('about.resources.xFeedbackDescription'), - actionLabel: t('about.actions.feedback'), - onClick: handleXFeedback - }, - { - icon: MessageCircle, - label: t('about.resources.discord'), - description: t('about.resources.discordDescription'), - actionLabel: t('about.actions.visit'), - href: 'https://discord.gg/uBqXV6QPdm' - } - ], - [t, handleXFeedback] - ) - const aboutResources = useMemo( () => [ { @@ -345,7 +292,7 @@ export function About() {

{t('about.appName')}

- {t('about.versionLabel', { version: appVersion })} + {t('about.versionLabel', { version: appVersionLabel })} {latestVersionState ? (
@@ -496,28 +443,12 @@ export function About() {
- {feedbackResources.map((resource) => { - const Icon = resource.icon - return resource.href ? ( - - ) : ( - - ) - })} +
{/* Other resources */} diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 1d89fe6..a8fdeca 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -24,6 +24,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({ status: item.status, progress: undefined, error: item.error, + ytDlpCommand: item.ytDlpCommand, downloadPath: item.downloadPath, speed: undefined, duration: item.duration, diff --git a/src/renderer/src/store/video.ts b/src/renderer/src/store/video.ts index dc36679..fe6233a 100644 --- a/src/renderer/src/store/video.ts +++ b/src/renderer/src/store/video.ts @@ -11,15 +11,24 @@ export const videoInfoLoadingAtom = atom(false) // Error state for video info export const videoInfoErrorAtom = atom(null) +// Last yt-dlp command used for video info +export const videoInfoCommandAtom = atom(null) + // Fetch video info export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => { set(videoInfoLoadingAtom, true) set(videoInfoErrorAtom, null) + set(videoInfoCommandAtom, null) set(currentVideoInfoAtom, null) try { - const info = await ipcServices.download.getVideoInfo(url) - set(currentVideoInfoAtom, info) + const result = await ipcServices.download.getVideoInfoWithCommand(url) + set(videoInfoCommandAtom, result.ytDlpCommand) + if (result.info) { + set(currentVideoInfoAtom, result.info) + return + } + set(videoInfoErrorAtom, result.error || 'Failed to fetch video info') } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info' set(videoInfoErrorAtom, errorMessage) @@ -32,4 +41,5 @@ export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => { export const clearVideoInfoAtom = atom(null, (_get, set) => { set(currentVideoInfoAtom, null) set(videoInfoErrorAtom, null) + set(videoInfoCommandAtom, null) }) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 5e595cf..c54f3fa 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -34,6 +34,12 @@ export interface VideoInfo { uploader?: string } +export interface VideoInfoCommandResult { + info?: VideoInfo + ytDlpCommand: string + error?: string +} + export interface DownloadProgress { percent: number currentSpeed?: string @@ -60,6 +66,7 @@ export interface DownloadItem { progress?: DownloadProgress error?: string speed?: string + ytDlpCommand?: string // Enhanced video information duration?: number fileSize?: number @@ -109,6 +116,7 @@ export interface DownloadHistoryItem { downloadedAt: number completedAt?: number error?: string + ytDlpCommand?: string // Additional metadata description?: string channel?: string