feat(diagnostics): persist full stderr detail behind a "Show details" toggle

describeDownloadError condenses a postprocessing failure to one line for
the queue/notification, discarding the rest of yt-dlp's stderr. The
persisted error log now also keeps the fuller non-noise transcript
(stderrDetail) as an optional `detail` field, and the Diagnostics card
gets a collapsible "Show details" expander per row so a failure like
"Postprocessing: Conversion failed!" can be inspected -- and copied
into a bug report -- without re-running the download from a terminal.
This commit is contained in:
2026-07-05 14:56:09 -04:00
parent 6cb9c5ca47
commit 27cae28e12
9 changed files with 155 additions and 17 deletions
+2 -1
View File
@@ -74,7 +74,8 @@ export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
(e.kind === 'video' || e.kind === 'audio') && (e.kind === 'video' || e.kind === 'audio') &&
typeof e.error === 'string' && typeof e.error === 'string' &&
typeof e.occurredAt === 'number' && typeof e.occurredAt === 'number' &&
isOptionalString(e.title) isOptionalString(e.title) &&
isOptionalString(e.detail)
) )
} }
+9 -3
View File
@@ -33,7 +33,7 @@ import {
FILEPATH_MARKER, FILEPATH_MARKER,
DEST_MARKER DEST_MARKER
} from './core/buildArgs' } from './core/buildArgs'
import { describeDownloadError } from './log' import { describeDownloadError, stderrDetail } from './log'
import { addErrorLog } from './errorlog' import { addErrorLog } from './errorlog'
import { import {
STALL_TIMEOUT_MS, STALL_TIMEOUT_MS,
@@ -117,7 +117,12 @@ function notify(wc: WebContents, title: string, body: string, incognito = false)
if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true) if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true)
} }
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void { function logFailure(
opts: StartDownloadOptions,
title: string | undefined,
error: string,
detail?: string
): void {
// L136: incognito downloads are never recorded — not even a failure entry, which // L136: incognito downloads are never recorded — not even a failure entry, which
// would persist the title + URL in the user-facing diagnostics log. // would persist the title + URL in the user-facing diagnostics log.
if (opts.incognito) return if (opts.incognito) return
@@ -127,6 +132,7 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
url: opts.url, url: opts.url,
kind: opts.kind, kind: opts.kind,
error, error,
detail,
occurredAt: Date.now() occurredAt: Date.now()
}) })
} }
@@ -532,7 +538,7 @@ function wireChildProcess(params: {
} else { } else {
const msg = describeDownloadError(stderrTail) || `yt-dlp exited with code ${code}` const msg = describeDownloadError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg }) send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, getTitle(), msg) logFailure(opts, getTitle(), msg, stderrDetail(stderrTail))
notify( notify(
wc, wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'), opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
+17
View File
@@ -53,3 +53,20 @@ export function describeDownloadError(stderr: string): string {
} }
return context.length > 0 ? `${headline}${context.join(' · ')}` : headline return context.length > 0 ? `${headline}${context.join(' · ')}` : headline
} }
/**
* Full non-noise stderr, for the persisted error log's "Show details" expander
* (Diagnostics card) — everything describeDownloadError condenses to one line,
* kept in full (progress ticks and delete-bookkeeping still stripped) so a bug
* report carries the real yt-dlp/ffmpeg transcript instead of a paraphrase.
* Returns undefined when there's nothing beyond a single line — the headline
* the caller already shows — so a plain, self-explanatory error doesn't grow a
* details toggle with nothing new in it.
*/
export function stderrDetail(stderr: string): string | undefined {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !CONTEXT_NOISE.test(l))
return lines.length > 1 ? lines.join('\n') : undefined
}
+17
View File
@@ -13,6 +13,23 @@ const seed: ErrorLogEntry[] = PREVIEW
kind: 'video', kind: 'video',
error: '[youtube] err0r: Private video. Sign in if you have access.', error: '[youtube] err0r: Private video. Sign in if you have access.',
occurredAt: Date.now() - 1000 * 60 * 12 occurredAt: Date.now() - 1000 * 60 * 12
},
{
id: 'e2',
title: 'youre about to get hacked!! (7 reasons why)',
url: 'https://youtube.com/watch?v=q2err0r',
kind: 'video',
error:
'Postprocessing: Conversion failed! — [ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
detail: [
'[info] q2err0r: Downloading 1 format(s): 137+251',
'[SponsorBlock] Found 1 segments in the SponsorBlock database',
'[Merger] Merging formats into "video.mp4"',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'[ModifyChapters] Removing chapters from video.mp4',
'ERROR: Postprocessing: Conversion failed!'
].join('\n'),
occurredAt: Date.now() - 1000 * 60 * 5
} }
] ]
: [] : []
@@ -1,6 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components' import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons' import {
BugRegular,
ChevronDownRegular,
ChevronRightRegular,
CopyRegular,
DeleteRegular
} from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog' import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../../components/ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { EmptyState } from '../../components/ui/EmptyState' import { EmptyState } from '../../components/ui/EmptyState'
@@ -13,13 +19,25 @@ export function DiagnosticsCard(): React.JSX.Element {
const clearErrorLog = useErrorLog((s) => s.clear) const clearErrorLog = useErrorLog((s) => s.clear)
const [confirmClearLog, setConfirmClearLog] = useState(false) const [confirmClearLog, setConfirmClearLog] = useState(false)
// Which rows have their "Show details" transcript expanded; keyed by entry id.
const [expanded, setExpanded] = useState<Set<string>>(new Set())
function toggleExpanded(id: string): void {
setExpanded((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function copyErrorReport(): void { function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always // The button is disabled when there are no entries, so `report` is always
// non-empty here -- no need for an unreachable empty-report fallback (L159). // non-empty here -- no need for an unreachable empty-report fallback (L159).
const report = errorEntries const report = errorEntries
.map((e) => .map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n') [new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error, e.detail]
.filter(Boolean)
.join('\n')
) )
.join('\n\n---\n\n') .join('\n\n---\n\n')
navigator.clipboard.writeText(report).catch(() => {}) navigator.clipboard.writeText(report).catch(() => {})
@@ -78,17 +96,33 @@ export function DiagnosticsCard(): React.JSX.Element {
<EmptyState compact message="No errors yet." /> <EmptyState compact message="No errors yet." />
) : ( ) : (
<div className={styles.errorList}> <div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => ( {errorEntries.slice(0, 20).map((e) => {
<div key={e.id} className={styles.errorRow}> const isExpanded = expanded.has(e.id)
<div className={styles.errorRowHeader}> return (
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text> <div key={e.id} className={styles.errorRow}>
<Caption1 className={styles.hint}> <div className={styles.errorRowHeader}>
{new Date(e.occurredAt).toLocaleString()} <Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
</Caption1> <Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
{e.detail && (
<>
<Button
appearance="transparent"
size="small"
icon={isExpanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
onClick={() => toggleExpanded(e.id)}
>
{isExpanded ? 'Hide details' : 'Show details'}
</Button>
{isExpanded && <pre className={styles.errorDetail}>{e.detail}</pre>}
</>
)}
</div> </div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1> )
</div> })}
))}
{errorEntries.length > 20 && ( {errorEntries.length > 20 && (
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1> <Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
)} )}
@@ -113,5 +113,21 @@ export const useSettingsStyles = makeStyles({
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
},
// The Diagnostics card's "Show details" expander -- the fuller non-noise
// stderr transcript behind a failure's one-line headline. Scrollable and
// capped so a long SponsorBlock/ffmpeg transcript can't blow out the card.
errorDetail: {
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: tokens.colorNeutralForeground3,
backgroundColor: tokens.colorNeutralBackground1,
marginTop: '6px',
padding: '8px',
maxHeight: '200px',
overflowY: 'auto',
...shorthands.borderRadius(tokens.borderRadiusMedium)
} }
}) })
+7
View File
@@ -845,6 +845,13 @@ export interface ErrorLogEntry {
url: string url: string
kind: MediaKind kind: MediaKind
error: string error: string
/**
* Fuller non-noise stderr context behind the Diagnostics card's "Show
* details" toggle — undefined when `error` already says everything (most
* failures), present for the generic "Postprocessing: Conversion failed!"
* wrapper where the useful detail is the tagged step lines that preceded it.
*/
detail?: string
/** epoch milliseconds */ /** epoch milliseconds */
occurredAt: number occurredAt: number
} }
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { cleanError, describeDownloadError } from '../src/main/log' import { cleanError, describeDownloadError, stderrDetail } from '../src/main/log'
describe('cleanError', () => { describe('cleanError', () => {
it('returns the last ERROR line, stripped of its prefix', () => { it('returns the last ERROR line, stripped of its prefix', () => {
@@ -76,3 +76,38 @@ describe('describeDownloadError', () => {
expect(describeDownloadError('')).toBe('') expect(describeDownloadError('')).toBe('')
}) })
}) })
describe('stderrDetail', () => {
it('keeps the full non-noise transcript for the Diagnostics "Show details" expander', () => {
const stderr = [
'[info] qkUTB65OfAc: Downloading 1 format(s): 137+251',
'[SponsorBlock] Found 1 segments in the SponsorBlock database',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
const detail = stderrDetail(stderr)
expect(detail).toContain('[SponsorBlock] Found 1 segments')
expect(detail).toContain('[ModifyChapters] Re-encoding')
expect(detail).toContain('ERROR: Postprocessing: Conversion failed!')
})
it('strips [download] progress ticks and "Deleting original file" bookkeeping', () => {
const stderr = [
'[download] 42.0% of 23.21MiB at 15.11MiB/s ETA 00:00',
'Deleting original file video.f251.webm (pass -k to keep)',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
const detail = stderrDetail(stderr)
expect(detail).not.toMatch(/\[download\]/)
expect(detail).not.toContain('Deleting original file')
})
it('returns undefined when there is nothing beyond a single headline', () => {
expect(stderrDetail('ERROR: Postprocessing: Conversion failed!')).toBeUndefined()
})
it('returns undefined for blank stderr', () => {
expect(stderrDetail('')).toBeUndefined()
})
})
+5
View File
@@ -175,6 +175,11 @@ describe('isValidErrorLogEntry', () => {
expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true) expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true)
}) })
it('accepts an optional detail string and rejects a non-string one', () => {
expect(isValidErrorLogEntry({ ...validError, detail: 'the full transcript' })).toBe(true)
expect(isValidErrorLogEntry({ ...validError, detail: 42 })).toBe(false)
})
it('rejects a missing error message', () => { it('rejects a missing error message', () => {
const { error: _error, ...noError } = validError const { error: _error, ...noError } = validError
expect(isValidErrorLogEntry(noError)).toBe(false) expect(isValidErrorLogEntry(noError)).toBe(false)