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:
@@ -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
@@ -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 ?? {} })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user