From 2d69ce81a462ac6713a113f7494a036440d6c7d7 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:50:42 +0800 Subject: [PATCH] feat(download): enrich error feedback links (#101) * feat(download): enrich feedback payloads * fix(ci): stabilize locale and ffmpeg fetch --- .github/workflows/build.yml | 7 + src/main/ipc/services/app-service.ts | 31 ++++ .../components/download/DownloadDialog.tsx | 135 +++++++++++++- .../src/components/download/DownloadItem.tsx | 170 +++++++++++++++++- src/renderer/src/locales/ar.json | 3 + src/renderer/src/locales/de.json | 3 + src/renderer/src/locales/en.json | 3 + src/renderer/src/locales/es.json | 3 + src/renderer/src/locales/fr.json | 3 + src/renderer/src/locales/id.json | 3 + src/renderer/src/locales/it.json | 3 + src/renderer/src/locales/ja.json | 3 + src/renderer/src/locales/ko.json | 3 + src/renderer/src/locales/pt.json | 3 + src/renderer/src/locales/ru.json | 3 + src/renderer/src/locales/zh-TW.json | 3 + src/renderer/src/locales/zh.json | 3 + 17 files changed, 373 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 11fc9a1..5784c04 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/src/main/ipc/services/app-service.ts b/src/main/ipc/services/app-service.ts index 6b418ac..3e5c9ed 100644 --- a/src/main/ipc/services/app-service.ts +++ b/src/main/ipc/services/app-service.ts @@ -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() diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index 1171b83..b9e4e77 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -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>(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({

{error}

+
+ + {t('download.feedback.title')} + +
+ {feedbackLinks.map((resource) => { + const Icon = resource.icon + return ( + + ) + })} +
+
)} diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 71527b7..7ec52ee 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -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 | 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) @@ -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 }`} >
{/* Content */} -
-
-
+
+
+

{download.title} @@ -929,9 +1056,36 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D {/* Error message */} {download.status === 'error' && download.error && ( -

- {download.error} -

+
+

+ {download.error} +

+
+ + {t('download.feedback.title')} + +
+ {feedbackLinks.map((resource) => { + const Icon = resource.icon + return ( + + ) + })} +
+
+
)}
diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 779a8ad..8ea117b 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -195,6 +195,9 @@ "formatNote": "ملاحظة التنسيق", "protocol": "البروتوكول", "subscription": "الاشتراك" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index 32c238a..7e243ff 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -195,6 +195,9 @@ "formatNote": "Format-Hinweis", "protocol": "Protokoll", "subscription": "Abonnement" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index c564547..3e50ea2 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -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", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index c9abf0e..3041a10 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -195,6 +195,9 @@ "formatNote": "Nota de formato", "protocol": "Protocolo", "subscription": "Suscripción" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index 13b660b..f9bb59e 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -195,6 +195,9 @@ "formatNote": "Remarque sur le format", "protocol": "Protocole", "subscription": "Abonnement" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/id.json b/src/renderer/src/locales/id.json index 2f100a8..d4b776f 100644 --- a/src/renderer/src/locales/id.json +++ b/src/renderer/src/locales/id.json @@ -195,6 +195,9 @@ "formatNote": "Catatan format", "protocol": "Protokol", "subscription": "Berlangganan" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/it.json b/src/renderer/src/locales/it.json index 18bafcd..278ab65 100644 --- a/src/renderer/src/locales/it.json +++ b/src/renderer/src/locales/it.json @@ -195,6 +195,9 @@ "formatNote": "Nota sul formato", "protocol": "Protocollo", "subscription": "Sottoscrizione" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index 7c7bba2..c089213 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -195,6 +195,9 @@ "formatNote": "メモのフォーマット", "protocol": "プロトコル", "subscription": "サブスクリプション" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/ko.json b/src/renderer/src/locales/ko.json index b482c8c..23f9e74 100644 --- a/src/renderer/src/locales/ko.json +++ b/src/renderer/src/locales/ko.json @@ -195,6 +195,9 @@ "formatNote": "메모 형식", "protocol": "규약", "subscription": "신청" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index 17678c0..af18436 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -195,6 +195,9 @@ "formatNote": "Formatar nota", "protocol": "Protocolo", "subscription": "Subscrição" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index bf9434a..7fd58e5 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -195,6 +195,9 @@ "formatNote": "Примечание формата", "protocol": "Протокол", "subscription": "Подписка" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 8da815b..167b060 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -195,6 +195,9 @@ "formatNote": "格式註釋", "protocol": "協定", "subscription": "訂閱" + }, + "feedback": { + "title": "Report this error:" } }, "error": { diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 2f5104f..20f148c 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -195,6 +195,9 @@ "formatNote": "格式注释", "protocol": "协议", "subscription": "订阅" + }, + "feedback": { + "title": "Report this error:" } }, "error": {