feat(feedback): attach yt-dlp command (#114)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
213
src/renderer/src/components/feedback/FeedbackLinks.tsx
Normal file
213
src/renderer/src/components/feedback/FeedbackLinks.tsx
Normal 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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user