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') &&
typeof e.error === 'string' &&
typeof e.occurredAt === 'number' &&
isOptionalString(e.title)
isOptionalString(e.title) &&
isOptionalString(e.detail)
)
}
+9 -3
View File
@@ -33,7 +33,7 @@ import {
FILEPATH_MARKER,
DEST_MARKER
} from './core/buildArgs'
import { describeDownloadError } from './log'
import { describeDownloadError, stderrDetail } from './log'
import { addErrorLog } from './errorlog'
import {
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)
}
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
// would persist the title + URL in the user-facing diagnostics log.
if (opts.incognito) return
@@ -127,6 +132,7 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
url: opts.url,
kind: opts.kind,
error,
detail,
occurredAt: Date.now()
})
}
@@ -532,7 +538,7 @@ function wireChildProcess(params: {
} else {
const msg = describeDownloadError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, getTitle(), msg)
logFailure(opts, getTitle(), msg, stderrDetail(stderrTail))
notify(
wc,
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
}
/**
* 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',
error: '[youtube] err0r: Private video. Sign in if you have access.',
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 { 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 { useErrorTextStyles } from '../../components/ui/errorText'
import { EmptyState } from '../../components/ui/EmptyState'
@@ -13,13 +19,25 @@ export function DiagnosticsCard(): React.JSX.Element {
const clearErrorLog = useErrorLog((s) => s.clear)
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 {
// 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).
const report = errorEntries
.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')
navigator.clipboard.writeText(report).catch(() => {})
@@ -78,17 +96,33 @@ export function DiagnosticsCard(): React.JSX.Element {
<EmptyState compact message="No errors yet." />
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
{errorEntries.slice(0, 20).map((e) => {
const isExpanded = expanded.has(e.id)
return (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<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>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
)
})}
{errorEntries.length > 20 && (
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
)}
@@ -113,5 +113,21 @@ export const useSettingsStyles = makeStyles({
overflow: 'hidden',
textOverflow: 'ellipsis',
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
kind: MediaKind
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 */
occurredAt: number
}
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { cleanError, describeDownloadError } from '../src/main/log'
import { cleanError, describeDownloadError, stderrDetail } from '../src/main/log'
describe('cleanError', () => {
it('returns the last ERROR line, stripped of its prefix', () => {
@@ -76,3 +76,38 @@ describe('describeDownloadError', () => {
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)
})
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', () => {
const { error: _error, ...noError } = validError
expect(isValidErrorLogEntry(noError)).toBe(false)