feat(download): enrich error feedback links (#101)

* feat(download): enrich feedback payloads

* fix(ci): stabilize locale and ffmpeg fetch
This commit is contained in:
Nexmoe
2026-01-14 21:50:42 +08:00
committed by GitHub
parent 4a9ee1406b
commit 2d69ce81a4
17 changed files with 373 additions and 9 deletions

View File

@@ -98,6 +98,13 @@ jobs:
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
if [[ ! -f "$arm_bin" ]]; then
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
fi
if [[ ! -f "$x86_bin" ]]; then
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
fi
if [[ ! -f "$arm_bin" ]]; then
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
exit 1

View File

@@ -16,6 +16,37 @@ class AppService extends IpcService {
return os.platform()
}
@IpcMethod()
getOsVersion(_context: IpcContext): string {
const platform = os.platform()
const platformLabel =
platform === 'darwin'
? 'macOS'
: platform === 'win32'
? 'Windows'
: platform === 'linux'
? 'Linux'
: platform
const systemVersion =
typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'
? (process as { getSystemVersion: () => string }).getSystemVersion()
: typeof os.version === 'function'
? os.version()
: os.release()
if (platform === 'win32') {
const buildToken = systemVersion.split('.').at(-1) ?? ''
const buildNumber = Number.parseInt(buildToken, 10)
const windowsName =
Number.isFinite(buildNumber) && buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
return Number.isFinite(buildNumber)
? `${windowsName} (build ${buildNumber})`
: `${platformLabel} ${systemVersion}`.trim()
}
return `${platformLabel} ${systemVersion}`.trim()
}
@IpcMethod()
quit(_context: IpcContext): void {
app.quit()

View File

@@ -20,7 +20,17 @@ import {
buildVideoFormatPreference
} from '@shared/utils/format-preferences'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, FolderOpen, List, Loader2, Plus, Video } from 'lucide-react'
import {
AlertCircle,
FolderOpen,
Github,
List,
Loader2,
MessageCircle,
Plus,
Twitter,
Video
} from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -49,6 +59,35 @@ 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')
@@ -112,6 +151,8 @@ 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 [loading] = useAtom(videoInfoLoadingAtom)
@@ -151,6 +192,74 @@ export function DownloadDialog({
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set())
const feedbackLinks = 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) => {
@@ -891,6 +1000,30 @@ export function DownloadDialog({
<p className="text-xs text-muted-foreground wrap-break-word">{error}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
{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]"
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer">
<Icon className="h-3 w-3" />
{resource.label}
</a>
</Button>
)
})}
</div>
</div>
</div>
)}

View File

@@ -17,13 +17,16 @@ import {
CheckCircle2,
Copy,
FolderOpen,
Github,
Info,
Loader2,
MessageCircle,
Play,
Trash2,
Twitter,
X
} from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../../lib/ipc'
@@ -200,8 +203,72 @@ 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)
@@ -214,6 +281,66 @@ 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)
@@ -656,7 +783,7 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
}`}
>
<div
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
className={`flex w-full flex-col gap-2 sm:flex-row sm:gap-3 ${
selectionEnabled ? 'cursor-pointer' : ''
}`}
{...(selectionEnabled
@@ -701,9 +828,9 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
</div>
{/* Content */}
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
<div className="flex-1 min-w-0 max-w-full overflow-hidden pointer-events-none">
<div className="flex items-center justify-center h-14 w-full flex-col gap-1.5 sm:flex-row sm:justify-between sm:gap-2">
<div className="flex-1 items-center min-w-0 max-w-full space-y-1.5 overflow-hidden">
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
{download.title}
@@ -929,9 +1056,36 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
{/* Error message */}
{download.status === 'error' && download.error && (
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
<div className="flex flex-col gap-2">
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground pointer-events-auto">
<span className="text-xs font-medium text-muted-foreground">
{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>
)
})}
</div>
</div>
</div>
)}
</div>
</div>

View File

@@ -195,6 +195,9 @@
"formatNote": "ملاحظة التنسيق",
"protocol": "البروتوكول",
"subscription": "الاشتراك"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Format-Hinweis",
"protocol": "Protokoll",
"subscription": "Abonnement"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -130,6 +130,9 @@
"error": "Error",
"fetch": "Fetch",
"fetchingVideoInfo": "Fetching video info...",
"feedback": {
"title": "Report this error:"
},
"history": "History",
"imageLoadError": "Image failed to load",
"imagePlaceholder": "No image available",

View File

@@ -195,6 +195,9 @@
"formatNote": "Nota de formato",
"protocol": "Protocolo",
"subscription": "Suscripción"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Remarque sur le format",
"protocol": "Protocole",
"subscription": "Abonnement"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Catatan format",
"protocol": "Protokol",
"subscription": "Berlangganan"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Nota sul formato",
"protocol": "Protocollo",
"subscription": "Sottoscrizione"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "メモのフォーマット",
"protocol": "プロトコル",
"subscription": "サブスクリプション"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "메모 형식",
"protocol": "규약",
"subscription": "신청"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Formatar nota",
"protocol": "Protocolo",
"subscription": "Subscrição"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "Примечание формата",
"protocol": "Протокол",
"subscription": "Подписка"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "格式註釋",
"protocol": "協定",
"subscription": "訂閱"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {

View File

@@ -195,6 +195,9 @@
"formatNote": "格式注释",
"protocol": "协议",
"subscription": "订阅"
},
"feedback": {
"title": "Report this error:"
}
},
"error": {