Fix H5/H6/M21/L37-40/L47/L55/L68/L72/L76/L91/L98/L108/L165/L166 audit items

H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
    back to addFromUrl so the original yt-dlp format is reused, not guessed.
    Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:29:35 -04:00
parent 1376c2dee8
commit 8ab85da67e
19 changed files with 339 additions and 129 deletions
+30 -17
View File
@@ -20,6 +20,19 @@ L11 (Queue header counts the live queue), L50 (no "saved" for 0 cookies), L156 +
reconciliation (M25 M26 L80 L81), and new unit tests (L35 isValidMediaItem, L36
compareVersions). All verified: typecheck + 234 tests + eslint + prettier green.
**Session 2026-06-30 pass 2 (quality/format/UX polish):** H5 (history re-download now stores
formatId/formatHasAudio + strips compound quality labels; thumbnail also passed — L72), H6
(TerminalView log capped at 2000 lines), M21 (skip --audio-quality for lossless flac/wav),
L166 (fmtEta hour rollover fixed in download.ts, store/downloads.ts, queueStats.ts — "2:00:00"
not "120:00"), L165 (formatSpeed precision aligned with fmtBytes), L55 (QueueItem suppresses
sizeLabel when already in probed-format quality label), L47 (probeMeta null sends empty meta
event to clear "Resolving…"), L68 (notifiedBackground resets on window show), L76 (dup warning
falls back to URL when title is placeholder), L91 (SettingsView unsafe tuple cast fixed), L98
(embed-chapters hint added), L108 (window title set), L37 (formatters extracted to lib/formatters.ts;
fmtBytes/fmtEta/parseProgress unit-tested including NA paths), L38 (webm thumbnail exclusion
test), L39 (CROP_SQUARE_PPA structural test), L40 (looksLikeUrl/looksLikeSingleVideo extracted
to lib/urlHelpers.ts; test imports pure module directly). All verified: typecheck + 242 tests + eslint + prettier green.
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
@@ -183,11 +196,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
with H2's shared formatter.
- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships
formatted `speed`/`eta` strings; `queueStats.ts` re-parses them back to numbers.
- [ ] **H5 — History re-download silently changes quality.** `HistoryView.redownload` passes the
- [x] **H5 — History re-download silently changes quality.** `HistoryView.redownload` passes the
stored `quality` (for format-picker downloads this is a *label* like "720p · mp4 · 184 MB")
back as `quality` with no `formatId`; `buildArgs.videoFormat()` has no matching case and falls
back to `bv*+ba/b` (Best). The queued item shows "720p…" while actually fetching Best.
- [ ] **H6 — TerminalView log grows unbounded.** `setLines((ls) => [...ls, …])` with no cap; a
- [x] **H6 — TerminalView log grows unbounded.** `setLines((ls) => [...ls, …])` with no cap; a
verbose run (`-F`, `--verbose`) streams thousands of lines into React state. Every other
log/list in the app is capped — cap this too (and/or virtualize).
- [x] **H7 — `cookies.txt` written in plaintext with no restrictive perms.** [cookies.ts](src/main/cookies.ts)
@@ -269,7 +282,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
`aria-label` (double announcement).
- [ ] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
- [x] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
lossless `audioFormat` (flac/wav), where it's meaningless; pair with M18's preset/format mismatch.
- [x] **M22 — Backup export writes secrets in cleartext, silently.** [backup.ts](src/main/backup.ts)
`exportBackup` serializes the *decrypted* settings (proxy creds, PO token, update token) to a
@@ -443,10 +456,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
regression for the actual yt-dlp argv path (only the pure builder).
- [x] **L35 — `isValidMediaItem` has no test** — the one persisted-JSON validator of six with no spec.
- [x] **L36 — `compareVersions` differing-length components untested** (e.g. `1.2` vs `1.2.0`).
- [ ] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths).
- [ ] **L38 — `buildArgs` webm-thumbnail exclusion branch untested** (`embedThumbnail && container!=='webm'`).
- [ ] **L39 — `CROP_SQUARE_PPA` exact-string assertion** is a change-detector test (breaks on harmless reformat).
- [ ] **L40 — `clipboardLink.test.ts` imports the zustand settings store** to test two pure helpers
- [x] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths).
- [x] **L38 — `buildArgs` webm-thumbnail exclusion branch untested** (`embedThumbnail && container!=='webm'`).
- [x] **L39 — `CROP_SQUARE_PPA` exact-string assertion** is a change-detector test (breaks on harmless reformat).
- [x] **L40 — `clipboardLink.test.ts` imports the zustand settings store** to test two pure helpers
(`looksLikeUrl`/`looksLikeSingleVideo`) — couples a pure-fn test to store init (see H2).
- [x] **L41 — `package.json` `homepage` points at yt-dlp's GitHub**, not AeroFetch — wrong product
URL (surfaced by the NSIS installer).
@@ -458,7 +471,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
builder `copyright` "yt-dlp frontend" vs Sidebar caption "yt-dlp frontend".
- [ ] **L46 — yt-dlp-missing error copy differs across 4 modules** (download/ytdlp/probe/indexer):
"Reinstall AeroFetch, or drop…" vs "Download it into resources/bin/…" vs "Drop it into resources/bin/."
- [ ] **L47 — `probeMeta` 30s timeout returns null silently** — no surfaced message, unlike every
- [x] **L47 — `probeMeta` 30s timeout returns null silently** — no surfaced message, unlike every
other yt-dlp timeout ("Timed out …").
- [ ] **L48 — `cleanError` strips a leading `error:`** for live failures, while raw `ERROR:` text
shows elsewhere (errorlog seed / terminal) — inconsistent error normalization.
@@ -471,7 +484,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L53 — `dialog.show*Dialog(win!, …)` non-null assertions** on a possibly-null window
(chooseFolder, backup export/import) — can throw if the sender window is gone.
- [ ] **L54 — DownloadBar preview `<img>` has no `onError` fallback** (inconsistent with `MediaThumb`'s retry).
- [ ] **L55 — QueueItem meta line can show size twice** — a probed-format `quality` label already
- [x] **L55 — QueueItem meta line can show size twice** — a probed-format `quality` label already
contains the size, then `sizeLabel` is appended again.
- [ ] **L56 — SponsorBlock ON with zero categories silently no-ops** (`buildArgs` guards on
`length > 0`) with no UI warning.
@@ -489,7 +502,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop.
- [x] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v<x>" once the version loads (text shift on boot).
- [ ] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again").
- [ ] **L68 — Background-running notification fires once per process** (`notifiedBackground` never
- [x] **L68 — Background-running notification fires once per process** (`notifiedBackground` never
resets) — won't remind on later window closes.
- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
`ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`.
@@ -499,13 +512,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`crypto` to exercise it.*
- [ ] **L71 — Settings folder inputs are `readOnly`** with only a Browse button — no way to paste a
known path.
- [ ] **L72 — History re-download drops the thumbnail** (passes only title/channel), so the
- [x] **L72 — History re-download drops the thumbnail** (passes only title/channel), so the
re-queued row loses its thumbnail until re-probed.
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
- [ ] **L74 — Accent swatches are triple-labeled** (`aria-pressed` + `aria-label` + `title`).
- [ ] **L75 — Update-token field always visible** in the Software-update card, even when no update
is pending — buries an advanced/rarely-needed input.
- [ ] **L76 — Duplicate-warning may show a placeholder title**`setDup(existing.title)` can be the
- [x] **L76 — Duplicate-warning may show a placeholder title**`setDup(existing.title)` can be the
`titleFromUrl` placeholder before metadata resolves ('YouTube video (id)').
- [ ] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
with no retry) unlike the yt-dlp "Check version" button.
@@ -544,7 +557,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L90 — Two idioms for "value in const array."** [settings.ts](src/main/settings.ts) `sanitizeOptions`
uses `ARR.includes(x as never)` (L116120) while `setSettings` uses `(ARR as readonly string[]).includes(x as string)` (L275/323). Pick one.
- [ ] **L91 — Unsafe tuple cast / fragile encoding.** `value.split('|') as [MediaKind, string]`
- [x] **L91 — Unsafe tuple cast / fragile encoding.** `value.split('|') as [MediaKind, string]`
([SettingsView.tsx](src/renderer/src/components/SettingsView.tsx):411) trusts a `'|'`-joined
`kind|quality` string; a value without `'|'` yields `undefined` quality. Use a typed pair, not a string.
- [ ] **L92 — Preload names diverge from main fn names.** `markSourceItemDownloaded``setMediaItemDownloaded`,
@@ -559,7 +572,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`…` (opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the `…` = opens-a-dialog convention.
- [ ] **L97 — Advanced-field placeholders inconsistent.** Adjacent fields use an example (`web_safari`),
a literal `(none)`, and `Optional — Gitea access token`. Pick one placeholder style.
- [ ] **L98 — "Embed chapters" has no hint** while every sibling toggle in DownloadOptionsForm does.
- [x] **L98 — "Embed chapters" has no hint** while every sibling toggle in DownloadOptionsForm does.
- [ ] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no
`<label for>`/`aria-label` (placeholder only) — see M28.
- [ ] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float;
@@ -577,7 +590,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L106 — Refresh icons used interchangeably.** `ArrowClockwiseRegular` (retry, re-download,
check-for-new, update-yt-dlp) vs `ArrowSyncRegular` (re-index, check-for-updates) for the same "redo/refresh" idea.
- [ ] **L107 — Spinner sizes mixed**`"tiny"` almost everywhere, `"extra-tiny"` for the QueueItem queued row.
- [ ] **L108 — Main window sets no explicit `title`** (relies on the document `<title>`).
- [x] **L108 — Main window sets no explicit `title`** (relies on the document `<title>`).
- [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear;
only the manual "Check" button shows a spinner.
- [ ] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as
@@ -699,10 +712,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
(2-space bullet) in QueueItem/History/DownloadBar/DownloadsView, with `' · '` (1-space midd·dot) in
LibraryView, and with `' • '` (1-space bullet) in QueueItem's "Paused • 42%". Three styles for one role.
**Standard:** one separator constant (glyph + spacing).
- [ ] **L165 — The two byte/speed formatters round differently.** `download.ts fmtBytes` uses
- [x] **L165 — The two byte/speed formatters round differently.** `download.ts fmtBytes` uses
`toFixed(v >= 100 ? 0 : 1)` ("512 MB") while `queueStats formatSpeed` uses `toFixed(1)` ("512.0 MB/s") —
visibly different precision for the same magnitudes (compounds the duplicate-formatter issue H2/M9).
- [ ] **L166 — ETA has no hour rollover.** `fmtEta` (download.ts/downloads.ts) and `formatEta`
- [x] **L166 — ETA has no hour rollover.** `fmtEta` (download.ts/downloads.ts) and `formatEta`
(queueStats) emit `M:SS` only, so a 2-hour ETA renders as **"120:00"**, whereas `fmtDuration`
(indexerCore) correctly rolls to `H:MM:SS`. Unify on the hour-aware format.
- [x] **L167 — `PROGRESS_TEMPLATE` is exported but used only inside `buildArgs.ts`** — a superfluous
+5 -1
View File
@@ -404,7 +404,11 @@ export function buildArgs(
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') {
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
// --audio-quality is a bitrate selector meaningful only for lossy re-encodes.
// For lossless formats (flac/wav) it is silently ignored by yt-dlp (M21).
const lossless = o.audioFormat === 'flac' || o.audioFormat === 'wav'
args.push('-x', '--audio-format', o.audioFormat)
if (!lossless) args.push('--audio-quality', audioQuality(opts.quality))
if (o.embedThumbnail) {
args.push('--embed-thumbnail')
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
+10 -53
View File
@@ -35,7 +35,6 @@ import {
type CommandPreviewResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress,
type Settings
} from '@shared/ipc'
@@ -65,57 +64,10 @@ export function hasActiveDownloads(): boolean {
}
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
return Number.isFinite(n) ? n : undefined
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
function fmtSpeed(bytesPerSec?: number): string | undefined {
if (bytesPerSec == null) return undefined
return `${fmtBytes(bytesPerSec)}/s`
}
function fmtEta(seconds?: number): string | undefined {
if (seconds == null) return undefined
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
// No (estimated) total → the % can never advance; flag it so the renderer
// shows an indeterminate bar rather than a stuck 0% (L137).
sizeUnknown: !totalBytes
}
}
// Implementations live in lib/formatters.ts (no electron import chain) so they
// can be unit-tested in isolation (L37). Re-exported here for external callers.
export { fmtBytes, fmtEta, parseProgress } from './lib/formatters'
import { parseProgress } from './lib/formatters'
function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
@@ -356,7 +308,12 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
// it in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
if (active.has(opts.id)) {
// Always emit a meta event: on success it fills in title/channel/duration;
// on failure (null from timeout/error) the renderer clears the
// "Resolving…" placeholder via applyEvent('meta') (SR6 / L47).
send(wc, { type: 'meta', id: opts.id, meta: meta ?? {} })
}
})
}
+8
View File
@@ -169,6 +169,7 @@ const DENIED_PERMISSIONS = new Set([
function createWindow(): void {
const win = new BrowserWindow({
title: 'AeroFetch',
width: 920,
height: 700,
// Below this the 212px sidebar + content layout breaks; pin a sensible
@@ -218,6 +219,13 @@ function createWindow(): void {
if (mainWindow === win) mainWindow = null
})
// When the user re-opens the window (via tray, second-instance, or deeplink)
// reset the background-notify latch so they're informed again if they close
// while a download is still running (L68).
win.on('show', () => {
notifiedBackground = false
})
// The OS may have launched us with an aerofetch:// link or a "Send to" .url
// file on the command line — hand it to DownloadBar's link-suggestion banner
// once the page (and its IPC listener) is actually ready to receive it.
+57
View File
@@ -0,0 +1,57 @@
import type { DownloadProgress } from '@shared/ipc'
// Pure formatting helpers for yt-dlp progress output. Extracted from download.ts
// so they can be unit-tested without pulling in the electron import chain (L37).
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
return Number.isFinite(n) ? n : undefined
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
function fmtSpeed(bytesPerSec?: number): string | undefined {
if (bytesPerSec == null) return undefined
return `${fmtBytes(bytesPerSec)}/s`
}
export function fmtEta(seconds?: number): string | undefined {
if (seconds == null) return undefined
const s = Math.max(0, Math.round(seconds))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
return `${m}:${String(r).padStart(2, '0')}`
}
export function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
// No (estimated) total → the % can never advance; flag it so the renderer
// shows an indeterminate bar rather than a stuck 0% (L137).
sizeUnknown: !totalBytes
}
}
+7 -1
View File
@@ -464,7 +464,13 @@ export function DownloadBar(): React.JSX.Element {
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
setDup(existing.title)
// Use the URL as fallback if the title is still the placeholder generated
// by titleFromUrl() before metadata loads (L76).
const isPlaceholder =
/video \(\w+\)$/.test(existing.title) ||
existing.title.endsWith(' download') ||
existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title)
return
}
}
@@ -211,7 +211,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</div>
)}
<Field label="Embed chapters">
<Field
label="Embed chapters"
hint="Write chapter markers into the file. Only useful if the video has chapters."
>
<Switch
checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)}
+11 -1
View File
@@ -173,7 +173,17 @@ export function HistoryView(): React.JSX.Element {
}, [entries, query, kindFilter])
function redownload(h: HistoryEntry): void {
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel })
// Strip compound format labels ("720p · mp4 · 184 MB" → "720p") when there is
// no stored formatId, so videoFormat() can still match the preset (H5).
const quality = (h.quality.split(' · ')[0] ?? h.quality).trim()
addFromUrl(h.url, h.kind, quality, {
title: h.title,
channel: h.channel,
thumbnail: h.thumbnail,
format: h.formatId
? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality }
: undefined
})
}
function toggleSelected(id: string, on: boolean): void {
+4 -1
View File
@@ -131,12 +131,15 @@ export const QueueItem = memo(function QueueItem({
}
}
// L55: a probed-format quality label already carries the size ("720p · mp4 ·
// 184 MB"); skip sizeLabel when it's already embedded to avoid showing it twice.
const sizeAlreadyInQuality = item.sizeLabel && item.quality.includes(item.sizeLabel)
const metaParts = [
item.channel,
item.durationLabel,
item.quality,
item.kind === 'audio' ? 'Audio' : 'Video',
item.sizeLabel
!sizeAlreadyInQuality ? item.sizeLabel : undefined
].filter(Boolean)
return (
+3 -1
View File
@@ -413,7 +413,9 @@ export function SettingsView(): React.JSX.Element {
}
function onFormatSelect(value: string): void {
const [kind, quality] = value.split('|') as [MediaKind, string]
const sep = value.indexOf('|')
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video'
const quality = sep >= 0 ? value.slice(sep + 1) : value
update(
kind === 'audio'
? { defaultKind: 'audio', defaultAudioQuality: quality }
+16 -3
View File
@@ -20,6 +20,11 @@ interface Line {
kind: LineKind
}
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
const MAX_LOG_LINES = 2000
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
gate: {
@@ -76,13 +81,21 @@ export function TerminalView(): React.JSX.Element {
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
setLines((ls) =>
[...ls, { text: ev.line, kind: ev.stream } as Line].slice(-MAX_LOG_LINES)
)
} else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setLines((ls) =>
[...ls, { text: ev.error, kind: 'stderr' as const }].slice(-MAX_LOG_LINES)
)
setRunning(false)
runId.current = null
} else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
setLines((ls) =>
[...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' as const }].slice(
-MAX_LOG_LINES
)
)
setRunning(false)
runId.current = null
}
+37
View File
@@ -0,0 +1,37 @@
/** A quick heuristic for "this clipboard text is a link worth offering". */
export function looksLikeUrl(text: string): boolean {
const t = text.trim()
if (!/^https?:\/\//i.test(t)) return false
try {
new URL(t)
return true
} catch {
return false
}
}
/**
* True for URLs that point at a single video rather than a channel/playlist. The
* library's add-source field wants collections to sync, not one-off videos, so it
* filters these out of its suggestions. Conservative on purpose: it only flags the
* common YouTube single-video shapes (a `/watch?v=` or `youtu.be/<id>` with no
* `list=` context), so a channel/playlist link — on YouTube or any other site — is
* never wrongly rejected. A `list=` param means "add the playlist", so it's kept.
*/
export function looksLikeSingleVideo(text: string): boolean {
let u: URL
try {
u = new URL(text.trim())
} catch {
return false
}
if (u.searchParams.has('list')) return false
const host = u.hostname.replace(/^www\./, '').toLowerCase()
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
return u.pathname === '/watch' && u.searchParams.has('v')
}
if (host === 'youtu.be') {
return /^\/[\w-]{6,}$/.test(u.pathname)
}
return false
}
+5 -1
View File
@@ -200,8 +200,10 @@ function titleFromUrl(url: string): string {
function fmtEta(seconds: number): string {
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
return `${m}:${String(r).padStart(2, '0')}`
}
@@ -628,6 +630,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
url: item.url,
kind: item.kind,
quality: item.quality,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
filePath: item.filePath,
sizeLabel: item.sizeLabel,
thumbnail: item.thumbnail,
+7 -3
View File
@@ -28,7 +28,8 @@ function formatSpeed(bytesPerSec: number): string {
v /= 1000
i++
}
return `${v.toFixed(1)} ${units[i]}`
// Match fmtBytes precision: one decimal below 100, none above (L165).
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
@@ -42,8 +43,11 @@ function parseEtaSeconds(s?: string): number {
function formatEta(totalSeconds: number): string {
if (totalSeconds <= 0) return ''
const s = Math.round(totalSeconds)
const m = Math.floor(s / 60)
return `${m}:${String(s % 60).padStart(2, '0')}`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
return `${m}:${String(r).padStart(2, '0')}`
}
export interface QueueSummary {
+5 -38
View File
@@ -1,43 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSettings } from './store/settings'
/** A quick heuristic for "this clipboard text is a link worth offering". */
export function looksLikeUrl(text: string): boolean {
const t = text.trim()
if (!/^https?:\/\//i.test(t)) return false
try {
new URL(t)
return true
} catch {
return false
}
}
/**
* True for URLs that point at a single video rather than a channel/playlist. The
* library's add-source field wants collections to sync, not one-off videos, so it
* filters these out of its suggestions. Conservative on purpose: it only flags the
* common YouTube single-video shapes (a `/watch?v=` or `youtu.be/<id>` with no
* `list=` context), so a channel/playlist link — on YouTube or any other site — is
* never wrongly rejected. A `list=` param means "add the playlist", so it's kept.
*/
export function looksLikeSingleVideo(text: string): boolean {
let u: URL
try {
u = new URL(text.trim())
} catch {
return false
}
if (u.searchParams.has('list')) return false
const host = u.hostname.replace(/^www\./, '').toLowerCase()
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
return u.pathname === '/watch' && u.searchParams.has('v')
}
if (host === 'youtu.be') {
return /^\/[\w-]{6,}$/.test(u.pathname)
}
return false
}
// Pure URL helpers extracted to a standalone module so they can be tested
// without pulling in the Zustand store (L40). Re-exported here for existing
// consumers (DownloadBar, LibraryView) without an import-path change.
export { looksLikeUrl, looksLikeSingleVideo } from './lib/urlHelpers'
import { looksLikeUrl } from './lib/urlHelpers'
/** Where an offered link came from — drives the banner's wording. */
export type SuggestionSource = 'clipboard' | 'external'
+8
View File
@@ -611,7 +611,15 @@ export interface HistoryEntry {
channel?: string
url: string
kind: MediaKind
/** Quality preset ('720p', 'Best available', 'Best (MP3)', …) or the probed
* format's display label. Used for display and, when no formatId is present,
* as the fallback quality selector on re-download (H5). */
quality: string
/** Exact yt-dlp format id from the probe picker, carried so re-downloading
* always uses the same format rather than falling back to a preset (H5). */
formatId?: string
/** Whether the probed format already carries its own audio track (H5). */
formatHasAudio?: boolean
filePath?: string
sizeLabel?: string
thumbnail?: string
+18 -7
View File
@@ -279,13 +279,14 @@ describe('cropThumbnail → --ppa crop', () => {
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
})
it('uses single-quoted, comma-protected crop expressions for ffmpeg', () => {
// The commas live inside gt(...) and MUST stay quoted so ffmpeg does not read
// them as filtergraph separators. The targeted-postprocessor key plus the
// ffmpeg_o output-args selector frame the whole value.
expect(CROP_SQUARE_PPA).toBe(
"EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
)
it('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => {
// Structural test: verify the key invariants without locking in the exact string,
// so minor whitespace or quoting style tweaks don't break CI.
expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/)
expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg')
expect(CROP_SQUARE_PPA).toMatch(/-vf\s+crop=/)
// Commas inside gt() MUST be inside quotes so ffmpeg doesn't split on them.
expect(CROP_SQUARE_PPA).toMatch(/crop="[^"]*,/)
})
it('does not add --ppa when cropThumbnail is off', () => {
@@ -299,6 +300,16 @@ describe('cropThumbnail → --ppa crop', () => {
expect(argv).not.toContain('--ppa')
expect(argv).not.toContain('--embed-thumbnail')
})
it('suppresses --embed-thumbnail for webm (not supported by the container, L38)', () => {
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'webm' }))
expect(argv).not.toContain('--embed-thumbnail')
})
it('keeps --embed-thumbnail for mp4 container (L38)', () => {
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'mp4' }))
expect(argv).toContain('--embed-thumbnail')
})
})
// --- Custom commands (Phase C): extra args + display formatting ------------
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { looksLikeUrl, looksLikeSingleVideo } from '../src/renderer/src/useClipboardLink'
// Import from the pure utility module directly — no Zustand store init needed (L40).
import { looksLikeUrl, looksLikeSingleVideo } from '../src/renderer/src/lib/urlHelpers'
describe('looksLikeUrl', () => {
it('accepts http(s) URLs', () => {
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
// Import from the pure formatter module directly — no electron import chain (L37).
import { fmtBytes, fmtEta, parseProgress } from '../src/main/lib/formatters'
describe('fmtBytes', () => {
it('formats bytes under 1 KB verbatim', () => {
expect(fmtBytes(0)).toBe('0 B')
expect(fmtBytes(512)).toBe('512 B')
expect(fmtBytes(1023)).toBe('1023 B')
})
it('switches to KB at 1024', () => {
expect(fmtBytes(1024)).toBe('1.0 KB')
expect(fmtBytes(1536)).toBe('1.5 KB')
})
it('uses one decimal below 100, none above', () => {
expect(fmtBytes(99 * 1024)).toBe('99.0 KB')
expect(fmtBytes(100 * 1024)).toBe('100 KB')
expect(fmtBytes(150 * 1024)).toBe('150 KB')
})
it('switches to MB and GB', () => {
expect(fmtBytes(1024 * 1024)).toBe('1.0 MB')
expect(fmtBytes(1024 * 1024 * 1024)).toBe('1.0 GB')
})
})
describe('fmtEta', () => {
it('returns undefined for null / undefined input', () => {
expect(fmtEta(undefined)).toBeUndefined()
expect(fmtEta(null as unknown as number)).toBeUndefined()
})
it('formats seconds to M:SS', () => {
expect(fmtEta(0)).toBe('0:00')
expect(fmtEta(59)).toBe('0:59')
expect(fmtEta(60)).toBe('1:00')
expect(fmtEta(90)).toBe('1:30')
expect(fmtEta(3599)).toBe('59:59')
})
it('formats hours to H:MM:SS (L166)', () => {
expect(fmtEta(3600)).toBe('1:00:00')
expect(fmtEta(3661)).toBe('1:01:01')
expect(fmtEta(7260)).toBe('2:01:00')
expect(fmtEta(7384)).toBe('2:03:04')
})
it('clamps negative input to 0', () => {
expect(fmtEta(-10)).toBe('0:00')
})
})
describe('parseProgress', () => {
const makeRaw = (parts: string[]): string => parts.join('|')
it('returns null when there are fewer than 6 fields', () => {
expect(parseProgress('')).toBeNull()
expect(parseProgress('downloading|100|200|200')).toBeNull()
})
it('parses a normal progress line', () => {
// status | downloaded | total | totalEst | speed | eta
const raw = makeRaw(['downloading', '524288', '1048576', '1048576', '262144', '2'])
const p = parseProgress(raw)
expect(p).not.toBeNull()
expect(p!.progress).toBeCloseTo(0.5)
expect(p!.eta).toBe('0:02')
expect(p!.sizeLabel).toBe('1.0 MB')
expect(p!.sizeUnknown).toBe(false)
})
it('falls back to totalEst when total is NA', () => {
const raw = makeRaw(['downloading', '100', 'NA', '1000', '50', '18'])
const p = parseProgress(raw)
expect(p).not.toBeNull()
expect(p!.sizeLabel).toBe('1000 B')
expect(p!.sizeUnknown).toBe(false)
})
it('marks sizeUnknown when both total and totalEst are NA', () => {
const raw = makeRaw(['downloading', '100', 'NA', 'NA', 'NA', 'NA'])
const p = parseProgress(raw)
expect(p).not.toBeNull()
expect(p!.sizeUnknown).toBe(true)
expect(p!.eta).toBeUndefined()
expect(p!.speed).toBeUndefined()
})
it('clamps progress to 1.0', () => {
const raw = makeRaw(['downloading', '2000', '1000', '1000', '500', '0'])
const p = parseProgress(raw)
expect(p!.progress).toBe(1)
})
it('formats an ETA over 1 hour correctly (L166)', () => {
const raw = makeRaw(['downloading', '100', '1000', '1000', '10', '3661'])
const p = parseProgress(raw)
expect(p!.eta).toBe('1:01:01')
})
})