feat(feedback): attach yt-dlp command (#114)

This commit is contained in:
Nexmoe
2026-01-15 21:07:37 +08:00
committed by GitHub
parent 9d377dd464
commit c1d1a1b912
12 changed files with 446 additions and 408 deletions

View File

@@ -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<VideoInfoCommandResult> {
return downloadEngine.getVideoInfoWithCommand(url)
}
@IpcMethod()
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
return downloadEngine.getPlaylistInfo(url)

View File

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

View File

@@ -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<typeof 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)
return args
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = 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<VideoInfoCommandResult> {
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<PlaylistInfo> {
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,

View File

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

View File

@@ -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<Set<string>>(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}
/>
</TabsContent>

View File

@@ -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<AppInfo> | null = null
const loadAppInfo = async (): Promise<AppInfo> => {
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')}
</span>
<div className="flex flex-wrap gap-2">
{feedbackLinks.map((resource) => {
const Icon = resource.icon
return (
<Button
key={resource.label}
variant="outline"
size="sm"
className="h-6 gap-1 px-1.5 text-[10px]"
onClick={(event) => event.stopPropagation()}
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer">
<Icon className="h-3 w-3" />
{resource.label}
</a>
</Button>
)
})}
<FeedbackLinkButtons
error={download.error}
sourceUrl={download.url}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={download.ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-6 gap-1 px-1.5 text-[10px]"
iconClassName="h-3 w-3"
onLinkClick={(event) => event.stopPropagation()}
/>
</div>
</div>
</div>

View File

@@ -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<SingleVideoState>) => 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')}
</span>
<div className="flex flex-wrap gap-1.5">
{feedbackLinks.map((resource) => {
const Icon = resource.icon
return (
<Button
key={resource.label}
variant="outline"
size="sm"
className="h-5 gap-1 px-1.5 text-[10px]"
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer">
<Icon className="h-2.5 w-2.5" />
{resource.label}
</a>
</Button>
)
})}
<FeedbackLinkButtons
error={error}
sourceUrl={feedbackSourceUrl}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-5 gap-1 px-1.5 text-[10px]"
iconClassName="h-2.5 w-2.5"
/>
</div>
</div>
</div>

View File

@@ -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<AppInfo> | 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<AppInfo> => {
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<AppInfo>(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<HTMLAnchorElement>) => 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 (
<Button
key={resource.label}
variant={buttonVariant}
size={buttonSize}
className={buttonClassName}
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer" onClick={onLinkClick}>
<Icon className={iconClassName} />
{resource.label}
</a>
</Button>
)
})}
</>
)
}

View File

@@ -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<string>('—')
const { appVersion, osVersion } = useAppInfo()
const appVersionLabel = appVersion || '—'
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(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<AboutResource[]>(
() => [
{
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<AboutResource[]>(
() => [
{
@@ -345,7 +292,7 @@ export function About() {
<div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
{t('about.versionLabel', { version: appVersionLabel })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
@@ -496,28 +443,12 @@ export function About() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{feedbackResources.map((resource) => {
const Icon = resource.icon
return resource.href ? (
<Button key={resource.label} variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
<Icon className="h-4 w-4" />
{resource.label}
</a>
</Button>
) : (
<Button
key={resource.label}
variant="outline"
size="sm"
onClick={resource.onClick}
className="gap-2"
>
<Icon className="h-4 w-4" />
{resource.label}
</Button>
)
})}
<FeedbackLinkButtons
appInfo={{ appVersion, osVersion }}
issueTitle="[bug]: "
buttonClassName="gap-2"
iconClassName="h-4 w-4"
/>
</div>
</div>
{/* Other resources */}

View File

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

View File

@@ -11,15 +11,24 @@ export const videoInfoLoadingAtom = atom<boolean>(false)
// Error state for video info
export const videoInfoErrorAtom = atom<string | null>(null)
// Last yt-dlp command used for video info
export const videoInfoCommandAtom = atom<string | null>(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)
})

View File

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