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:
+30
-17
@@ -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
|
reconciliation (M25 M26 L80 L81), and new unit tests (L35 isValidMediaItem, L36
|
||||||
compareVersions). All verified: typecheck + 234 tests + eslint + prettier green.
|
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
|
**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
|
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,
|
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.
|
with H2's shared formatter.
|
||||||
- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships
|
- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships
|
||||||
formatted `speed`/`eta` strings; `queueStats.ts` re-parses them back to numbers.
|
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")
|
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 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.
|
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
|
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).
|
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)
|
- [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
|
- [ ] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
|
||||||
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
|
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
|
||||||
`aria-label` (double announcement).
|
`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.
|
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)
|
- [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
|
`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).
|
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] **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`).
|
- [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).
|
- [x] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths).
|
||||||
- [ ] **L38 — `buildArgs` webm-thumbnail exclusion branch untested** (`embedThumbnail && container!=='webm'`).
|
- [x] **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).
|
- [x] **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] **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).
|
(`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
|
- [x] **L41 — `package.json` `homepage` points at yt-dlp's GitHub**, not AeroFetch — wrong product
|
||||||
URL (surfaced by the NSIS installer).
|
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".
|
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):
|
- [ ] **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/."
|
"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 …").
|
other yt-dlp timeout ("Timed out …").
|
||||||
- [ ] **L48 — `cleanError` strips a leading `error:`** for live failures, while raw `ERROR:` text
|
- [ ] **L48 — `cleanError` strips a leading `error:`** for live failures, while raw `ERROR:` text
|
||||||
shows elsewhere (errorlog seed / terminal) — inconsistent error normalization.
|
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
|
- [ ] **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.
|
(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).
|
- [ ] **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.
|
contains the size, then `sizeLabel` is appended again.
|
||||||
- [ ] **L56 — SponsorBlock ON with zero categories silently no-ops** (`buildArgs` guards on
|
- [ ] **L56 — SponsorBlock ON with zero categories silently no-ops** (`buildArgs` guards on
|
||||||
`length > 0`) with no UI warning.
|
`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.
|
- [ ] **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).
|
- [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").
|
- [ ] **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.
|
resets) — won't remind on later window closes.
|
||||||
- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
|
- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
|
||||||
`ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`.
|
`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.*
|
`crypto` to exercise it.*
|
||||||
- [ ] **L71 — Settings folder inputs are `readOnly`** with only a Browse button — no way to paste a
|
- [ ] **L71 — Settings folder inputs are `readOnly`** with only a Browse button — no way to paste a
|
||||||
known path.
|
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.
|
re-queued row loses its thumbnail until re-probed.
|
||||||
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
|
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
|
||||||
- [ ] **L74 — Accent swatches are triple-labeled** (`aria-pressed` + `aria-label` + `title`).
|
- [ ] **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
|
- [ ] **L75 — Update-token field always visible** in the Software-update card, even when no update
|
||||||
is pending — buries an advanced/rarely-needed input.
|
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)').
|
`titleFromUrl` placeholder before metadata resolves ('YouTube video (id)').
|
||||||
- [ ] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
|
- [ ] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
|
||||||
with no retry) unlike the yt-dlp "Check version" button.
|
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`
|
- [ ] **L90 — Two idioms for "value in const array."** [settings.ts](src/main/settings.ts) `sanitizeOptions`
|
||||||
uses `ARR.includes(x as never)` (L116–120) while `setSettings` uses `(ARR as readonly string[]).includes(x as string)` (L275/323). Pick one.
|
uses `ARR.includes(x as never)` (L116–120) 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
|
([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.
|
`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`,
|
- [ ] **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.
|
`…` (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`),
|
- [ ] **L97 — Advanced-field placeholders inconsistent.** Adjacent fields use an example (`web_safari`),
|
||||||
a literal `(none)`, and `Optional — Gitea access token`. Pick one placeholder style.
|
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
|
- [ ] **L99 — URL input has an `id` but no label.** `input={{ id: 'aerofetch-url' }}` with no
|
||||||
`<label for>`/`aria-label` (placeholder only) — see M28.
|
`<label for>`/`aria-label` (placeholder only) — see M28.
|
||||||
- [ ] **L100 — Inconsistent elevation.** DownloadBar (`shadow4`) and CommandPalette (`shadow28`) float;
|
- [ ] **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,
|
- [ ] **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.
|
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.
|
- [ ] **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;
|
- [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear;
|
||||||
only the manual "Check" button shows a spinner.
|
only the manual "Check" button shows a spinner.
|
||||||
- [ ] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as
|
- [ ] **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
|
(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.
|
LibraryView, and with `' • '` (1-space bullet) in QueueItem's "Paused • 42%". Three styles for one role.
|
||||||
**Standard:** one separator constant (glyph + spacing).
|
**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") —
|
`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).
|
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`
|
(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.
|
(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
|
- [x] **L167 — `PROGRESS_TEMPLATE` is exported but used only inside `buildArgs.ts`** — a superfluous
|
||||||
|
|||||||
@@ -404,7 +404,11 @@ export function buildArgs(
|
|||||||
args.push(...postProcessArgs(opts, o))
|
args.push(...postProcessArgs(opts, o))
|
||||||
|
|
||||||
if (opts.kind === 'audio') {
|
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) {
|
if (o.embedThumbnail) {
|
||||||
args.push('--embed-thumbnail')
|
args.push('--embed-thumbnail')
|
||||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||||
|
|||||||
+10
-53
@@ -35,7 +35,6 @@ import {
|
|||||||
type CommandPreviewResult,
|
type CommandPreviewResult,
|
||||||
type DownloadEvent,
|
type DownloadEvent,
|
||||||
type DownloadMeta,
|
type DownloadMeta,
|
||||||
type DownloadProgress,
|
|
||||||
type Settings
|
type Settings
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
@@ -65,57 +64,10 @@ export function hasActiveDownloads(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||||
|
// Implementations live in lib/formatters.ts (no electron import chain) so they
|
||||||
function num(s?: string): number | undefined {
|
// can be unit-tested in isolation (L37). Re-exported here for external callers.
|
||||||
if (!s || s === 'NA') return undefined
|
export { fmtBytes, fmtEta, parseProgress } from './lib/formatters'
|
||||||
const n = Number(s)
|
import { parseProgress } from './lib/formatters'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(wc: WebContents, ev: DownloadEvent): void {
|
function send(wc: WebContents, ev: DownloadEvent): void {
|
||||||
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
|
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.
|
// it in parallel so the card fills in quickly.
|
||||||
probeMeta(ytdlp, opts.url).then((meta) => {
|
probeMeta(ytdlp, opts.url).then((meta) => {
|
||||||
if (meta?.title) resolvedTitle = meta.title
|
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 {
|
function createWindow(): void {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
|
title: 'AeroFetch',
|
||||||
width: 920,
|
width: 920,
|
||||||
height: 700,
|
height: 700,
|
||||||
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
||||||
@@ -218,6 +219,13 @@ function createWindow(): void {
|
|||||||
if (mainWindow === win) mainWindow = null
|
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
|
// 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
|
// 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.
|
// 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)
|
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||||
)
|
)
|
||||||
if (existing) {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Embed chapters">
|
<Field
|
||||||
|
label="Embed chapters"
|
||||||
|
hint="Write chapter markers into the file. Only useful if the video has chapters."
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedChapters}
|
checked={value.embedChapters}
|
||||||
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
||||||
|
|||||||
@@ -173,7 +173,17 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
}, [entries, query, kindFilter])
|
}, [entries, query, kindFilter])
|
||||||
|
|
||||||
function redownload(h: HistoryEntry): void {
|
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 {
|
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 = [
|
const metaParts = [
|
||||||
item.channel,
|
item.channel,
|
||||||
item.durationLabel,
|
item.durationLabel,
|
||||||
item.quality,
|
item.quality,
|
||||||
item.kind === 'audio' ? 'Audio' : 'Video',
|
item.kind === 'audio' ? 'Audio' : 'Video',
|
||||||
item.sizeLabel
|
!sizeAlreadyInQuality ? item.sizeLabel : undefined
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -413,7 +413,9 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFormatSelect(value: string): void {
|
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(
|
update(
|
||||||
kind === 'audio'
|
kind === 'audio'
|
||||||
? { defaultKind: 'audio', defaultAudioQuality: quality }
|
? { defaultKind: 'audio', defaultAudioQuality: quality }
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ interface Line {
|
|||||||
kind: LineKind
|
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({
|
const useStyles = makeStyles({
|
||||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||||
gate: {
|
gate: {
|
||||||
@@ -76,13 +81,21 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
window.api.onTerminalOutput((ev) => {
|
window.api.onTerminalOutput((ev) => {
|
||||||
if (ev.id !== runId.current) return
|
if (ev.id !== runId.current) return
|
||||||
if (ev.type === 'output') {
|
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') {
|
} 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)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
} else {
|
} 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)
|
setRunning(false)
|
||||||
runId.current = null
|
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 {
|
function fmtEta(seconds: number): string {
|
||||||
const s = Math.max(0, Math.round(seconds))
|
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
|
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')}`
|
return `${m}:${String(r).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +630,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
quality: item.quality,
|
quality: item.quality,
|
||||||
|
formatId: item.formatId,
|
||||||
|
formatHasAudio: item.formatHasAudio,
|
||||||
filePath: item.filePath,
|
filePath: item.filePath,
|
||||||
sizeLabel: item.sizeLabel,
|
sizeLabel: item.sizeLabel,
|
||||||
thumbnail: item.thumbnail,
|
thumbnail: item.thumbnail,
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function formatSpeed(bytesPerSec: number): string {
|
|||||||
v /= 1000
|
v /= 1000
|
||||||
i++
|
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.
|
// 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 {
|
function formatEta(totalSeconds: number): string {
|
||||||
if (totalSeconds <= 0) return ''
|
if (totalSeconds <= 0) return ''
|
||||||
const s = Math.round(totalSeconds)
|
const s = Math.round(totalSeconds)
|
||||||
const m = Math.floor(s / 60)
|
const h = Math.floor(s / 3600)
|
||||||
return `${m}:${String(s % 60).padStart(2, '0')}`
|
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 {
|
export interface QueueSummary {
|
||||||
|
|||||||
@@ -1,43 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
|
// Pure URL helpers extracted to a standalone module so they can be tested
|
||||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
// without pulling in the Zustand store (L40). Re-exported here for existing
|
||||||
export function looksLikeUrl(text: string): boolean {
|
// consumers (DownloadBar, LibraryView) without an import-path change.
|
||||||
const t = text.trim()
|
export { looksLikeUrl, looksLikeSingleVideo } from './lib/urlHelpers'
|
||||||
if (!/^https?:\/\//i.test(t)) return false
|
import { looksLikeUrl } from './lib/urlHelpers'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where an offered link came from — drives the banner's wording. */
|
/** Where an offered link came from — drives the banner's wording. */
|
||||||
export type SuggestionSource = 'clipboard' | 'external'
|
export type SuggestionSource = 'clipboard' | 'external'
|
||||||
|
|||||||
@@ -611,7 +611,15 @@ export interface HistoryEntry {
|
|||||||
channel?: string
|
channel?: string
|
||||||
url: string
|
url: string
|
||||||
kind: MediaKind
|
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
|
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
|
filePath?: string
|
||||||
sizeLabel?: string
|
sizeLabel?: string
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
|
|||||||
+18
-7
@@ -279,13 +279,14 @@ describe('cropThumbnail → --ppa crop', () => {
|
|||||||
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
|
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses single-quoted, comma-protected crop expressions for ffmpeg', () => {
|
it('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => {
|
||||||
// The commas live inside gt(...) and MUST stay quoted so ffmpeg does not read
|
// Structural test: verify the key invariants without locking in the exact string,
|
||||||
// them as filtergraph separators. The targeted-postprocessor key plus the
|
// so minor whitespace or quoting style tweaks don't break CI.
|
||||||
// ffmpeg_o output-args selector frame the whole value.
|
expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/)
|
||||||
expect(CROP_SQUARE_PPA).toBe(
|
expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg')
|
||||||
"EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
|
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', () => {
|
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('--ppa')
|
||||||
expect(argv).not.toContain('--embed-thumbnail')
|
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 ------------
|
// --- Custom commands (Phase C): extra args + display formatting ------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('looksLikeUrl', () => {
|
||||||
it('accepts http(s) URLs', () => {
|
it('accepts http(s) URLs', () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user