16 Commits

Author SHA1 Message Date
debont80 e8f0b83a7e chore: license the project under MIT
Replace the placeholder "UNLICENSED" with the MIT License so the now-public
repo is genuinely free and open source. Adds a LICENSE file and documents the
app-vs-bundled-binary licensing split in the README (yt-dlp/FFmpeg/aria2 are
invoked as separate executables, so they remain under their own licenses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:09:52 -04:00
debont80 a7edde8a32 Merge pull request 'docs: add README with feature overview and screenshots' (#14) from docs/readme-screenshots into main 2026-07-10 13:18:22 -04:00
debont80 4204483792 docs: add README with feature overview and screenshots
Give the now-public repo a proper front page: pitch, grouped feature
list, light/dark screenshots of Downloads/Library/History/Settings,
download + build-from-source instructions, and a note on the bundled
yt-dlp/FFmpeg/aria2 licenses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:15:59 -04:00
debont80 94ef814fa7 chore: bump version to 0.7.2 2026-07-10 10:43:14 -04:00
debont80 78a168ca5c Merge pull request 'chore(config): point the updater at the now-public AeroFetch repo' (#13) from chore/consolidate-update-source into main
Reviewed-on: #13
2026-07-10 10:41:28 -04:00
debont80 3b9dd6788d chore(config): point the updater at the now-public AeroFetch repo
The source repo is public now, so the separate releases-only repo
(AeroFetch-releases) is no longer needed to keep the updater tokenless.
Retarget UPDATE_REPO from AeroFetch-releases back to AeroFetch and refresh the
config doc comment. The dedicated read-only baked-token service account
(aerofetch-updater) has been deleted and its token revoked, so nothing reads a
token any more; the .gitignore comment for the retired .update-token file is
updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:39:12 -04:00
debont80 ccee9b7d3b chore: bump version to 0.7.1 2026-07-09 15:50:14 -04:00
debont80 af9c285864 Merge pull request 'fix(sync): run watched sync when a scheduled --sync hits the running instance' (#12) from fix/scheduled-sync-running-instance into main
Reviewed-on: #12
2026-07-09 15:48:31 -04:00
debont80 87e16ca28e fix(sync): run watched sync when a scheduled --sync hits the running instance
The Windows daily-sync task launches `AeroFetch.exe --sync`, but the single-
instance lock routes that relaunch to the `second-instance` handler whenever
AeroFetch is already open. The handler only focused the window and looked for a
link -- it never checked `isSyncLaunch(argv)` -- so the sync never ran. The daily
auto-download of new watched-source uploads therefore did nothing for minimize-
to-tray / always-on users (and the relaunch stole window focus each day).

Add a `runWatchedSync` main->renderer push: on a `--sync` relaunch the handler
now nudges the renderer to run syncWatched() instead of focusing, so the daily
sync fires whether AeroFetch was closed or already running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:45:35 -04:00
debont80 f3e10b100d chore: bump version to 0.7.0 2026-07-08 06:01:17 -04:00
debont80 6db27b946f Merge pull request 'feat(setup): fetch ffmpeg on first run instead of bundling it' (#11) from feature/ffmpeg-first-run-download into main
Reviewed-on: #11
2026-07-08 05:42:11 -04:00
debont80 eb53de2ea5 feat(setup): fetch ffmpeg on first run instead of bundling it
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe
(the bulk of its size). On first run they are downloaded from a pinned
upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and
unpacked with the OS tar.exe into the managed userData/bin dir -- the
same place as the self-updating yt-dlp, so they survive app updates and
portable re-extraction. A hard onboarding gate blocks the app until they
are present, with a "locate existing ffmpeg" folder-picker fallback for
offline machines and a Repair action in Settings > About.

The updater's streaming/checksum/redirect/idle-timeout download loop is
extracted to lib/verifiedDownload.streamVerifiedFile and shared by both
the app-installer download and the ffmpeg fetch; the updater's public
behaviour and error strings are unchanged (its boundary tests still pass).
No new npm dependency: extraction uses the System32 bsdtar resolved by
absolute path (audit F3).

Note: this commit also carries pre-existing, in-progress aria2c/network
and updater-token work that was already uncommitted in the working tree
and is entangled with the above in shared files (updater.ts, ipc.ts,
preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network
files and aria2c.exe). It was not cleanly separable by path, so it is
included here rather than split out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:02:13 -04:00
debont80 cb25262a2d feat(library): add per-row "Download new" for watched sources
Queuing a watched channel's not-yet-downloaded videos previously meant
expanding the source and using its "Download N pending" button. Watched
source rows now carry a one-click "Download new" that loads the source's
items (even while collapsed) and queues everything not already
downloaded or in the queue, using the source's resolved kind/profile.

Extracts the shared, tested selectUndownloaded selector (also now backs
the expanded panel's pending list) and adds ensureSourceItems to load a
collapsed source's items without expanding it.
2026-07-05 17:49:39 -04:00
debont80 27cae28e12 feat(diagnostics): persist full stderr detail behind a "Show details" toggle
describeDownloadError condenses a postprocessing failure to one line for
the queue/notification, discarding the rest of yt-dlp's stderr. The
persisted error log now also keeps the fuller non-noise transcript
(stderrDetail) as an optional `detail` field, and the Diagnostics card
gets a collapsible "Show details" expander per row so a failure like
"Postprocessing: Conversion failed!" can be inspected -- and copied
into a bug report -- without re-running the download from a terminal.
2026-07-05 14:56:09 -04:00
debont80 6cb9c5ca47 fix(downloads): re-resolve options on retry instead of replaying stale ones
A queued item freezes its post-processing options at enqueue time, so
retrying a failed download replayed whatever was in effect when it was
first added -- a settings change made afterward (e.g. turning
SponsorBlock-remove off, which forces a slow, failure-prone software
re-encode) never took effect on retry.

retry/retryAll now re-resolve a source-tied item's options from the
current source profile / global settings (the same precedence enqueue
uses) before requeuing it. Manual pastes with no source keep their
explicit per-download options untouched, and this is deliberately
distinct from the history re-download path, which keeps frozen options
on purpose to reproduce an identical file.
2026-07-05 14:39:45 -04:00
debont80 5de4b40c1d fix(download): name the failing step in postprocessing errors
yt-dlp collapses an ffmpeg postprocessing failure to a bare
"Postprocessing: Conversion failed!" with no indication of which step
died, and without --verbose the underlying ffmpeg reason is never
emitted at all. describeDownloadError() appends the last non-noise
trailing lines (e.g. the [ModifyChapters] SponsorBlock re-encode) so
the error log and completion notification point at the actual cause
instead of a dead end.
2026-07-05 14:39:29 -04:00
56 changed files with 2135 additions and 465 deletions
+2
View File
@@ -10,6 +10,8 @@ dist
# Secrets — never commit
.gitea-token
# Retired: the baked update token was removed from the build and its service
# account revoked; kept ignored so a stray local .update-token can never be committed.
.update-token
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Devon Bontrager
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
<div align="center">
# AeroFetch
**A clean, native-feeling video &amp; audio downloader for Windows — a friendly front-end for [yt-dlp](https://github.com/yt-dlp/yt-dlp).**
Paste a link, pick a quality, and go. No command line, no Python setup, no chasing down FFmpeg — everything is bundled.
![AeroFetch downloads screen](docs/screenshots/downloads-dark.png)
</div>
---
## What it is
AeroFetch wraps the power of yt-dlp in a modern Windows desktop app. It handles single videos, whole playlists, and watched channels; it can grab audio-only, embed thumbnails and metadata, trim clips, skip sponsor segments, and keep a library of channels in sync — all from a point-and-click interface built with Fluent UI.
It ships as a **regular installer** *and* a **single portable `.exe`** (no admin rights required), with `yt-dlp`, `FFmpeg`, and `aria2c` bundled in — so it runs on a fresh machine with nothing else installed.
## Features
- **One-paste downloads** — drop in a video or playlist URL and press Enter. Clipboard detection can offer the link automatically.
- **Format &amp; quality control** — pick a resolution/codec (H.264 / VP9 / AV1), or extract audio to MP3, M4A, Opus, FLAC, WAV, or AAC.
- **Playlists &amp; channels** — select which videos to grab, or add a channel to your **Library** and let AeroFetch watch it for new uploads.
- **Post-processing** — embed thumbnails, metadata, chapters, and subtitles; **SponsorBlock** to skip or mark sponsor segments; trim to a start/end timestamp.
- **Queue that behaves** — concurrent downloads with a configurable cap, pause/resume, cancel, retry-all, and live speed/ETA.
- **Library &amp; sync** — organized per-source folders, scheduled daily sync, retention rules, and quality-upgrade selectors.
- **History** — searchable, filterable record of everything you've downloaded, with quick open-file / show-in-folder / re-download.
- **Fast &amp; convenient** — `aria2c` acceleration, a `Ctrl+K` command palette, light / dark / auto themes, and native Windows notifications.
- **Portable-friendly** — runs from a USB stick or Downloads folder without installing or needing admin rights.
## Screenshots
| Downloads (light) | Downloads (dark) |
| :---: | :---: |
| ![Downloads, light theme](docs/screenshots/downloads-light.png) | ![Downloads, dark theme](docs/screenshots/downloads-dark.png) |
| Library — watched channels &amp; playlists | History — searchable &amp; filterable |
| :---: | :---: |
| ![Library](docs/screenshots/library-dark.png) | ![History](docs/screenshots/history-dark.png) |
<div align="center">
**Settings — grouped &amp; searchable**
![Settings](docs/screenshots/settings-light.png)
</div>
## Download &amp; install
Grab the latest build from the [**Releases**](https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases) page:
- **`AeroFetch Setup <version>.exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates.
- **`AeroFetch-<version>-portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs.
Each release also publishes a `.sha256` next to every executable so you can verify the download.
> Because the executables aren't code-signed, Windows SmartScreen may warn on first launch — choose **More info → Run anyway**. No administrator rights are required either way.
## Build from source
Requires **Node.js 24.x**.
```bash
git clone https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git
cd AeroFetch
npm install
npm run dev # launch the app in development
npm run ui # preview just the renderer in a browser (http://localhost:5173)
npm test # run the test suite
npm run build:win # produce the installer + portable .exe in dist/
```
The bundled `yt-dlp.exe`, `ffmpeg.exe`, `ffprobe.exe`, and `aria2c.exe` live in `resources/bin/` and are packaged automatically.
## Tech stack
Built with **Electron** + **electron-vite** + **electron-builder**, a **React 19** / **TypeScript** renderer, **Fluent UI**, **Zustand** for state, and **Zod** for boundary validation. yt-dlp runs only in the main process; the renderer talks to it over a typed IPC bridge with context isolation enabled.
## A note on responsible use
AeroFetch is a tool for downloading media you have the right to download — your own uploads, content offered for download, or material whose license permits it. Respect the terms of service of the sites you use and the rights of content creators.
## License
AeroFetch's own source code is released under the [**MIT License**](LICENSE) — free to use, modify, and redistribute.
It also bundles third-party binaries that remain under their own licenses: **yt-dlp** (Unlicense), **FFmpeg** (LGPL/GPL), and **aria2** (GPL). AeroFetch invokes these as separate executables rather than linking against them, so they are distributed as an aggregate; their licenses apply to the respective files in `resources/bin/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+8 -2
View File
@@ -28,13 +28,19 @@ files:
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
# yt-dlp.exe (+ optional aria2c.exe) ship outside the asar archive so they can be spawned
# directly, landing in <install>/resources/bin (reachable via process.resourcesPath).
# ffmpeg.exe/ffprobe.exe are deliberately EXCLUDED — they're the bulk of the installer, so
# they're fetched on first run into userData/bin instead (src/main/ffmpegSetup.ts). The
# excludes also keep a developer's local resources/bin copies (used only for the dev seed)
# out of the shipped build.
extraResources:
- from: resources/bin
to: bin
filter:
- '**/*'
- '!ffmpeg.exe'
- '!ffprobe.exe'
# The app icon, copied so the runtime (system tray) can load it at
# <resources>/icon.ico — build/ itself isn't bundled into the app.
- from: build/icon.ico
-18
View File
@@ -1,28 +1,10 @@
import { resolve } from 'path'
import { existsSync, readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
// Read-only Gitea token compiled into the main bundle so the auto-updater can
// read the PRIVATE release repo without user setup (see config.ts BAKED_UPDATE_TOKEN).
// Source order: AEROFETCH_UPDATE_TOKEN env var (CI/secret), else a gitignored
// .update-token file (local builds). Absent → empty string → no token baked in.
// Keep this a dedicated read-only service-account token; the shipped bundle is public.
function bakedUpdateToken(): string {
const fromEnv = process.env.AEROFETCH_UPDATE_TOKEN?.trim()
if (fromEnv) return fromEnv
const file = resolve('.update-token')
return existsSync(file) ? readFileSync(file, 'utf8').trim() : ''
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
// Textually replaces the __AEROFETCH_UPDATE_TOKEN__ identifier in main-process
// code with the token literal at build time — value never lands in source/git.
define: {
__AEROFETCH_UPDATE_TOKEN__: JSON.stringify(bakedUpdateToken())
},
resolve: {
alias: {
'@shared': resolve('src/shared')
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aerofetch",
"version": "0.6.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerofetch",
"version": "0.6.1",
"version": "0.7.2",
"license": "UNLICENSED",
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "aerofetch",
"version": "0.6.1",
"version": "0.7.2",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
"license": "UNLICENSED",
"license": "MIT",
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
"repository": {
"type": "git",
+25 -20
View File
@@ -1,19 +1,21 @@
# Bundled binaries
AeroFetch spawns these from the **main process** and bundles them via
electron-builder `extraResources` (they ship outside the asar archive). The
`.exe` binaries are committed to the repo so a fresh clone builds and runs
without a manual download step.
AeroFetch spawns these from the **main process**. `yt-dlp.exe` (and optional
`aria2c.exe`) ship in the installer via electron-builder `extraResources` (outside the
asar archive) and are committed here so a fresh clone builds and runs.
Drop the three required Windows executables here before running `npm run dev` or
building; `aria2c.exe` is optional:
`ffmpeg.exe` + `ffprobe.exe` are **NOT shipped in the installer** — they're the bulk of
its size, so they're downloaded on first run into `userData/bin` (see
`src/main/ffmpegSetup.ts`, pinned in `src/main/config.ts`). The copies here are used only
as a **dev seed**`ensureManagedFfmpeg` copies them into `userData/bin` so `npm run dev`
needs no download — and are excluded from the shipped build.
```
resources/bin/
├── yt-dlp.exe
├── ffmpeg.exe
├── ffprobe.exe
└── aria2c.exe (optional)
├── yt-dlp.exe (bundled + committed)
├── ffmpeg.exe (dev seed only — excluded from the installer)
├── ffprobe.exe (dev seed only — excluded from the installer)
└── aria2c.exe (optional, bundled)
```
## yt-dlp.exe
@@ -22,17 +24,20 @@ resources/bin/
- Grab `yt-dlp.exe`.
- Unlicense / public domain.
## ffmpeg.exe + ffprobe.exe
## ffmpeg.exe + ffprobe.exe (dev seed only)
- Use an **LGPL** build to keep licensing simple, e.g.
https://github.com/BtbN/FFmpeg-Builds/releases (pick a `*-lgpl-shared` or
`*-lgpl` win64 build) or https://www.gyan.dev/ffmpeg/builds/.
- Copy **both** `ffmpeg.exe` and `ffprobe.exe` here — they ship together in the
same archive's `bin/` folder, and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent
post-processing fails with "ffprobe not found" (`--sponsorblock-remove`,
`--force-keyframes-at-cuts`, and `--split-chapters` all error out).
- Ship the LGPL license text alongside the installer before release.
- The app downloads these on first run from the pinned upstream archive in
`src/main/config.ts` (`FFMPEG_ARCHIVE_URL` + `FFMPEG_ARCHIVE_SHA256`). To bump the
version, edit those constants — recompute the SHA-256 from the new zip — in their own
commit (the "pinned decisions" discipline).
- For local dev, drop **both** `ffmpeg.exe` and `ffprobe.exe` here — they come together in
the same archive's `bin/` folder and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent post-processing
fails with "ffprobe not found" (`--sponsorblock-remove`, `--force-keyframes-at-cuts`,
and `--split-chapters` all error out). Any recent win64 build works, e.g.
https://www.gyan.dev/ffmpeg/builds/ or https://github.com/BtbN/FFmpeg-Builds/releases.
- The installer no longer redistributes ffmpeg, so there's no ffmpeg license text to ship
with the build — users fetch the binary from upstream themselves.
## aria2c.exe (optional)
Binary file not shown.
+41 -13
View File
@@ -42,15 +42,20 @@ export function getAppIconImage(): NativeImage {
}
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
* portable re-extraction — which only ever replace the bundled seed — can never
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
* AeroFetch keeps its OWN writable copies of the tool binaries under userData/bin,
* separate from anything in the read-only bundle (resources/bin). This managed dir is
* what actually gets spawned, and it survives an app reinstall or portable re-extraction
* (which only ever touch the bundle) — so a self-updated yt-dlp is never rolled back to
* a stale bundled version, and the first-run-downloaded ffmpeg/ffprobe (which the
* installer no longer ships at all) persist across updates.
*
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
* - yt-dlp.exe — seeded from the bundled copy, then self-updates (`--update-to`).
* See ensureManagedYtdlp in ytdlp.ts.
* - ffmpeg.exe / ffprobe.exe — fetched from upstream on first run (ffmpegSetup.ts);
* in dev, ensureManagedFfmpeg seeds them from resources/bin so `npm run dev` needs
* no download. yt-dlp finds them here via `--ffmpeg-location <getFfmpegDir()>`.
*
* aria2c stays in getBinDir(): it's optional, bundled, and passed by absolute path.
*/
function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
@@ -66,17 +71,40 @@ export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
/**
* The managed ffmpeg.exe under userData/bin. The installer no longer bundles ffmpeg
* (it's the bulk of the download); ffmpegSetup.ts fetches it on first run, and in dev
* ensureManagedFfmpeg seeds it from getBundledFfmpegPath(). yt-dlp locates it (and
* ffprobe) via `--ffmpeg-location getFfmpegDir()`.
*/
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
return join(getManagedBinDir(), 'ffmpeg.exe')
}
/**
* yt-dlp finds ffprobe via --ffmpeg-location (the bin dir), so the app never
* spawns it directly but it must be present, or duration-aware post-processing
* (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters) fails. This
* accessor exists so startDownload can assert its presence up front.
* The managed ffprobe.exe under userData/bin. yt-dlp finds it via --ffmpeg-location
* (so the app never spawns it directly), but it must be present, or duration-aware
* post-processing (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters)
* fails. startDownload / the setup gate assert its presence up front.
*/
export function getFfprobePath(): string {
return join(getManagedBinDir(), 'ffprobe.exe')
}
/** Directory holding the managed ffmpeg/ffprobe, handed to yt-dlp as --ffmpeg-location. */
export function getFfmpegDir(): string {
return getManagedBinDir()
}
/**
* Dev-only read seeds for ffmpeg/ffprobe in resources/bin, copied into the managed dir
* by ensureManagedFfmpeg when present. Absent in a shipped installer (excluded from the
* bundle in electron-builder.yml), where first-run download provides them instead.
*/
export function getBundledFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
export function getBundledFfprobePath(): string {
return join(getBinDir(), 'ffprobe.exe')
}
+35 -24
View File
@@ -4,33 +4,44 @@
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values.
*
* The update repo is PRIVATE, so the release API + downloads require a token.
* A build-time read-only token (BAKED_UPDATE_TOKEN below) lets a shipped client
* reach it without the user configuring anything; a user-set updateToken in
* Settings still takes precedence. On a build with no token baked in, the update
* check simply reports that it couldn't reach the server.
* The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous
* reads work and the updater sends no auth at all; there is no secret in the bundle
* to leak. (A prior design split releases into a separate releases-only repo so the
* source could stay private; that's retired now that the source repo is public
* itself — one fewer moving part.) A private fork retargets its updater by editing
* these constants and rebuilding.
*/
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
/**
* Read-only Gitea token baked in at build time so the auto-updater can read the
* PRIVATE release repo without each user pasting a token into Settings. Injected
* by electron.vite.config's `define` from the AEROFETCH_UPDATE_TOKEN env var (or a
* gitignored .update-token file) — so the real value lives only in the built
* bundle, never in source or git.
*
* SECURITY: a shipped client is decompilable, so treat this as PUBLIC. It MUST be
* a token from a dedicated service account with read-only access to ONLY this repo
* — never a personal/push-capable token. The user's own updateToken overrides it
* (see authHeader in updater.ts), so a private fork can point elsewhere.
*
* `__AEROFETCH_UPDATE_TOKEN__` is a `define`-replaced literal in built code; under
* plain vitest (no define) the identifier is undeclared, so the `typeof` guard
* keeps this from throwing and falls back to empty (no token).
*/
declare const __AEROFETCH_UPDATE_TOKEN__: string
export const BAKED_UPDATE_TOKEN: string =
typeof __AEROFETCH_UPDATE_TOKEN__ === 'string' ? __AEROFETCH_UPDATE_TOKEN__ : ''
// --- ffmpeg dependency source ------------------------------------------------
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
// they're fetched once on first run into the managed userData/bin dir (ffmpegSetup.ts).
//
// Unlike the app updater — which pulls from our own host-pinned release server — this
// downloads from UPSTREAM (gyan.dev's GitHub releases). Integrity therefore rests on a
// PINNED exact-version URL + SHA-256 here, re-verified against the streamed bytes: a
// swapped or tampered upstream asset fails the check and nothing is installed. Bumping
// ffmpeg is a deliberate edit to these three constants in its own commit (recompute the
// hash from the new zip) — the same "pinned decisions" discipline the app version follows.
//
// Licensing note: gyan's "essentials" is a GPL build. That's fine here because AeroFetch
// never redistributes it — it invokes ffmpeg.exe as a separate process (as yt-dlp does)
// and the user fetches the binary from upstream themselves. Swapping to an LGPL build
// (e.g. a BtbN win64 build) is purely a change to FFMPEG_ARCHIVE_URL/SHA-256 below,
// provided the zip still carries ffmpeg.exe + ffprobe.exe under a `*/bin/` path.
export const FFMPEG_VERSION = '8.1.2'
export const FFMPEG_ARCHIVE_URL = `https://github.com/GyanD/codexffmpeg/releases/download/${FFMPEG_VERSION}/ffmpeg-${FFMPEG_VERSION}-essentials_build.zip`
export const FFMPEG_ARCHIVE_SHA256 =
'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec'
// The setup UI quotes ~105 MB for this archive; keep that copy in sync when bumping above.
// Hosts the archive download may touch, HTTPS-only, re-checked on EVERY redirect hop
// (github.com 302s a release asset to release-assets.githubusercontent.com; the older
// objects.githubusercontent.com is kept for resilience). Anything off this list fails safe.
export const FFMPEG_TRUSTED_HOSTS = [
'github.com',
'release-assets.githubusercontent.com',
'objects.githubusercontent.com'
] as const
+8
View File
@@ -13,6 +13,14 @@
export const VERSION_TIMEOUT_MS = 15_000
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
/**
* How long the first-run ffmpeg archive download may STALL (no bytes received)
* before it's abandoned (ffmpegSetup.ts). Same generous idle budget the app-update
* download uses; total time is otherwise unbounded (the archive is ~105 MB).
*/
export const FFMPEG_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
/** Unpacking the two ffmpeg exes with System32 tar.exe — bounded so a wedged extract can't hang setup. */
export const FFMPEG_EXTRACT_TIMEOUT_MS = 120_000
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
export const META_PROBE_TIMEOUT_MS = 30_000
/** Full format/metadata probe (`-J`) for the download bar. */
+3 -2
View File
@@ -4,8 +4,9 @@
* This module is deliberately free of any electron / node-runtime dependency
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
* so the argv it produces can be unit-tested without spinning up Electron.
* `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than
* resolving it itself; the caller in download.ts passes `getBinDir()`.
* `buildArgs` takes the `binDir` as a parameter (used only for `--ffmpeg-location`)
* rather than resolving it itself; download.ts passes `getFfmpegDir()` — the managed
* userData/bin where the first-run download installs ffmpeg/ffprobe.
*/
import { join } from 'path'
+2 -1
View File
@@ -74,7 +74,8 @@ export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
(e.kind === 'video' || e.kind === 'audio') &&
typeof e.error === 'string' &&
typeof e.occurredAt === 'number' &&
isOptionalString(e.title)
isOptionalString(e.title) &&
isOptionalString(e.detail)
)
}
+17 -7
View File
@@ -5,7 +5,7 @@ import { join, parse } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
getYtdlpPath,
getBinDir,
getFfmpegDir,
getAria2cPath,
getFfmpegPath,
getFfprobePath,
@@ -33,7 +33,7 @@ import {
FILEPATH_MARKER,
DEST_MARKER
} from './core/buildArgs'
import { cleanError } from './log'
import { describeDownloadError, stderrDetail } from './log'
import { addErrorLog } from './errorlog'
import {
STALL_TIMEOUT_MS,
@@ -117,7 +117,12 @@ function notify(wc: WebContents, title: string, body: string, incognito = false)
if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true)
}
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
function logFailure(
opts: StartDownloadOptions,
title: string | undefined,
error: string,
detail?: string
): void {
// L136: incognito downloads are never recorded — not even a failure entry, which
// would persist the title + URL in the user-facing diagnostics log.
if (opts.incognito) return
@@ -127,6 +132,7 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
url: opts.url,
kind: opts.kind,
error,
detail,
occurredAt: Date.now()
})
}
@@ -237,7 +243,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
youtubePoToken: settings.youtubePoToken
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs })
// binDir feeds only `--ffmpeg-location`, so it's the managed ffmpeg dir (userData/bin),
// where the first-run download / dev seed places ffmpeg.exe + ffprobe.exe.
return buildArgs({ opts, outputTemplate, options, binDir: getFfmpegDir(), access, extraArgs })
}
/** Build the exact command line for the current form state, without running it. */
@@ -279,9 +287,11 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
if (!existsSync(getFfmpegPath())) missingBins.push('ffmpeg.exe')
if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
if (missingBins.length > 0) {
// The first-run setup gate normally guarantees these are present; this backstops a
// mid-session loss (AV quarantine, a cleared userData/bin) with a clear repair path.
return {
ok: false,
error: `${missingBins.join(' and ')} not found in ${getBinDir()}. Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
error: `${missingBins.join(' and ')} not found. Open Settings → About and choose "Repair ffmpeg" to reinstall the media tools.`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
@@ -530,9 +540,9 @@ function wireChildProcess(params: {
opts.incognito
)
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
const msg = describeDownloadError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, getTitle(), msg)
logFailure(opts, getTitle(), msg, stderrDetail(stderrTail))
notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
+1 -1
View File
@@ -21,7 +21,7 @@ async function readToolVersion(path: string): Promise<string | null> {
return m?.[1] ?? null
}
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
/** Versions of the managed ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
+249
View File
@@ -0,0 +1,249 @@
import { app, dialog, type BrowserWindow, type OpenDialogOptions, type WebContents } from 'electron'
import { existsSync, mkdirSync, copyFileSync, renameSync, rmSync } from 'fs'
import { execFile } from 'child_process'
import { join, dirname, basename } from 'path'
import {
getFfmpegPath,
getFfprobePath,
getFfmpegDir,
getBundledFfmpegPath,
getBundledFfprobePath,
getSystem32Path
} from './binaries'
import { getFfmpegVersions } from './ffmpeg'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { FFMPEG_ARCHIVE_URL, FFMPEG_ARCHIVE_SHA256, FFMPEG_TRUSTED_HOSTS } from './config'
import { VERSION_TIMEOUT_MS, FFMPEG_EXTRACT_TIMEOUT_MS } from './constants'
import {
IpcChannels,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult
} from '@shared/ipc'
import { logger } from './logger'
/**
* First-run acquisition of ffmpeg + ffprobe. The installer no longer bundles them
* (they're the bulk of its size); they live in the managed userData/bin dir (like the
* self-updating yt-dlp) and are fetched once on first run from a PINNED upstream archive,
* verified against a pinned SHA-256, then unpacked with the OS's tar.exe. See config.ts
* for the source + trust rationale, and binaries.ts for why the managed dir is used.
*/
/**
* Trust gate for the ffmpeg archive: HTTPS + an allowlisted upstream host, re-checked on
* every redirect hop by streamVerifiedFile (github.com 302s to a githubusercontent host).
* Mirrors updater.ts's isTrustedDownloadUrl but for the upstream host set, not our own.
*/
export function isTrustedFfmpegUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && (FFMPEG_TRUSTED_HOSTS as readonly string[]).includes(u.host)
} catch {
return false
}
}
/** Both binaries present in the managed dir — the readiness the download gate keys on. */
function bothPresent(): boolean {
return existsSync(getFfmpegPath()) && existsSync(getFfprobePath())
}
/** Copy a dev seed into the managed dir when the managed copy is missing. */
function seedOne(seed: string, managed: string): void {
if (existsSync(managed) || !existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent → first-run download */
}
}
/**
* Ensure the managed ffmpeg/ffprobe exist, seeding them from the read-only bundle when
* present (dev checkouts keep them in resources/bin, so `npm run dev` needs no download).
* A shipped installer carries no seed, so this no-ops and first-run download provides them.
* Best-effort and idempotent — cheap to call at startup and before the presence gate.
* Mirrors ensureManagedYtdlp in ytdlp.ts.
*/
export function ensureManagedFfmpeg(): void {
seedOne(getBundledFfmpegPath(), getFfmpegPath())
seedOne(getBundledFfprobePath(), getFfprobePath())
}
/** Readiness + versions for the setup gate and the About card. */
export async function getFfmpegSetupStatus(): Promise<FfmpegSetupStatus> {
// Seed from the dev bundle first so a seeded checkout reports ready without a download.
ensureManagedFfmpeg()
const versions = await getFfmpegVersions()
return { ready: bothPresent(), ffmpeg: versions.ffmpeg, ffprobe: versions.ffprobe }
}
// The in-flight download's canceller (armed only during the network phase; extraction is
// not cancelable) and a single-flight guard shared by download + locate.
let activeCancel: (() => void) | null = null
let extracting = false
/** Abort the in-flight ffmpeg archive download, if any (the setup card's Cancel). */
export function cancelFfmpegDownload(): void {
activeCancel?.()
}
/**
* Download the pinned ffmpeg archive, verify its SHA-256, unpack ffmpeg.exe + ffprobe.exe
* into the managed dir, and clean up. Progress (download %, then an indeterminate
* "extracting" phase) is pushed to the renderer. Single-flight; the network phase is
* cancelable via cancelFfmpegDownload().
*/
export async function downloadFfmpeg(wc: WebContents): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
if (!isTrustedFfmpegUrl(FFMPEG_ARCHIVE_URL)) {
return { ok: false, error: 'Refused to download ffmpeg from an untrusted location.' }
}
const tempZip = join(app.getPath('temp'), 'aerofetch-ffmpeg.zip')
const extractDir = join(app.getPath('temp'), `aerofetch-ffmpeg-${Date.now()}`)
const send = (p: FfmpegSetupProgress): void => {
if (!wc.isDestroyed()) wc.send(IpcChannels.ffmpegSetupProgress, p)
}
try {
const dl = await streamVerifiedFile({
url: FFMPEG_ARCHIVE_URL,
filePath: tempZip,
expectedSha: FFMPEG_ARCHIVE_SHA256,
isTrusted: isTrustedFfmpegUrl,
onProgress: (p) =>
send({ phase: 'downloading', received: p.received, total: p.total, fraction: p.fraction }),
onCancelReady: (cancel) => {
activeCancel = cancel
},
onSettled: (cancel) => {
if (activeCancel === cancel) activeCancel = null
},
messages: {
checksumMismatch:
'The ffmpeg download failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'ffmpeg download canceled.'
}
})
if (!dl.ok) return { ok: false, error: dl.error }
extracting = true
send({ phase: 'extracting' })
await extractArchive(tempZip, extractDir)
installFromDir(extractDir)
if (!bothPresent()) {
return { ok: false, error: 'The ffmpeg archive did not contain the expected binaries.' }
}
return { ok: true }
} catch (e) {
logger.error('[ffmpeg-setup] download failed', e instanceof Error ? e.message : String(e))
return { ok: false, error: e instanceof Error ? e.message : String(e) }
} finally {
extracting = false
activeCancel = null
rmSync(tempZip, { force: true })
rmSync(extractDir, { recursive: true, force: true })
}
}
/**
* Unpack just ffmpeg.exe + ffprobe.exe (flattened) from the archive using the OS's bundled
* bsdtar. Resolve tar.exe by absolute System32 path, never the bare name, so a planted
* tar.exe on PATH / in the CWD can't run in its place (audit F3). The two glob patterns
* (matching the `bin/ffmpeg.exe` + `bin/ffprobe.exe` members) are applied by tar itself —
* execFile does no shell globbing — and --strip-components=2 drops the leading
* `<build>/bin/` path so the exes land flat in destDir.
*/
function extractArchive(zip: string, destDir: string): Promise<void> {
mkdirSync(destDir, { recursive: true })
return new Promise((resolve, reject) => {
execFile(
getSystem32Path('tar.exe'),
['-xf', zip, '-C', destDir, '--strip-components=2', '*/bin/ffmpeg.exe', '*/bin/ffprobe.exe'],
{ timeout: FFMPEG_EXTRACT_TIMEOUT_MS, windowsHide: true },
(err) => (err ? reject(err) : resolve())
)
})
}
/** Move the two extracted exes into the managed dir. */
function installFromDir(dir: string): void {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(join(dir, 'ffmpeg.exe'), getFfmpegPath())
installOne(join(dir, 'ffprobe.exe'), getFfprobePath())
}
/**
* Place one binary at its managed path via a temp copy + rename, so a concurrent read
* never sees a half-written exe (rename is atomic within a volume). Falls back to an
* in-place overwrite only if the rename is blocked by a same-name destination.
*/
function installOne(src: string, dest: string): void {
if (!existsSync(src)) throw new Error(`the ffmpeg archive is missing ${basename(dest)}`)
const tmp = `${dest}.part`
copyFileSync(src, tmp)
try {
renameSync(tmp, dest)
} catch {
copyFileSync(tmp, dest)
rmSync(tmp, { force: true })
}
}
/** Run `<tool> -version` to confirm a manually-located binary is actually runnable. */
function validateTool(path: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
path,
['-version'],
{ timeout: VERSION_TIMEOUT_MS, windowsHide: true },
(err, stdout) => resolve(!err && /version/i.test(String(stdout)))
)
})
}
/**
* Offline / air-gapped fallback: let the user point at a folder that already holds
* ffmpeg.exe + ffprobe.exe (or its `bin/` subfolder), validate both actually run, and
* copy them into the managed dir. This is what keeps the hard first-run gate escapable
* without a network connection.
*/
export async function locateFfmpegManually(win?: BrowserWindow): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
const opts: OpenDialogOptions = {
properties: ['openDirectory'],
title: 'Select the folder containing ffmpeg.exe and ffprobe.exe'
}
const res = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts)
if (res.canceled || !res.filePaths[0]) return { ok: false, error: 'No folder selected.' }
const chosen = res.filePaths[0]
for (const cand of [chosen, join(chosen, 'bin')]) {
const ffmpeg = join(cand, 'ffmpeg.exe')
const ffprobe = join(cand, 'ffprobe.exe')
if (!existsSync(ffmpeg) || !existsSync(ffprobe)) continue
const runnable = (await validateTool(ffmpeg)) && (await validateTool(ffprobe))
if (!runnable) {
return {
ok: false,
error: 'Those binaries could not be run — they may be blocked or corrupt.'
}
}
try {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(ffmpeg, getFfmpegPath())
installOne(ffprobe, getFfprobePath())
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
return { ok: true }
}
return { ok: false, error: "That folder doesn't contain ffmpeg.exe and ffprobe.exe." }
}
+16
View File
@@ -10,6 +10,7 @@ import {
getSystemThemeInfo
} from './ipc'
import { runStartupYtdlpAutoUpdate } from './ytdlp'
import { ensureManagedFfmpeg } from './ffmpegSetup'
import { hasActiveDownloads } from './download'
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
import { ensureMediaDirs } from './paths'
@@ -259,6 +260,15 @@ if (isPrimaryInstance) {
// link it carried and hand it to the window we already have.
app.on('second-instance', (_e, argv) => {
if (!mainWindow) return
// A scheduled `--sync` relaunch (Task Scheduler) hit the already-running instance:
// run the watched-source sync in this window instead of no-opping. Without this the
// daily auto-download never fires while AeroFetch is open — e.g. minimize-to-tray
// users. Deliberately does NOT focus the window, so the daily sync stays unobtrusive
// (mirrors the fresh-launch `--sync` showInactive path in createWindow).
if (isSyncLaunch(argv)) {
mainWindow.webContents.send(IpcChannels.runWatchedSync)
return
}
focusWindow(mainWindow)
const incomingUrl = extractIncomingUrl(argv)
if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl)
@@ -311,6 +321,12 @@ if (isPrimaryInstance) {
}
])
// Seed the managed ffmpeg/ffprobe from the dev bundle when present, so a dev checkout
// (and any transitional build that still ships them) is ready without a download. In a
// shipped installer there's no seed, so this no-ops and the renderer's setup gate
// triggers the first-run download instead. Cheap + best-effort.
ensureManagedFfmpeg()
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
+22
View File
@@ -28,6 +28,13 @@ import {
import { PAGE_BACKGROUND } from '@shared/theme'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import {
getFfmpegSetupStatus,
downloadFfmpeg,
cancelFfmpegDownload,
locateFfmpegManually
} from './ffmpegSetup'
import { getAria2cPath } from './binaries'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
@@ -114,6 +121,21 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
// First-run ffmpeg/ffprobe acquisition (they're no longer bundled). Status gates the
// setup screen; download streams the pinned archive to e.sender; locate is the offline
// fallback (folder picker parented to the window, like chooseFolder).
ipcMain.handle(IpcChannels.ffmpegSetupStatus, () => getFfmpegSetupStatus())
ipcMain.handle(IpcChannels.ffmpegSetupDownload, (e) => downloadFfmpeg(e.sender))
ipcMain.handle(IpcChannels.ffmpegSetupCancel, () => cancelFfmpegDownload())
ipcMain.handle(IpcChannels.ffmpegSetupLocate, (e) =>
locateFfmpegManually(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
// Whether the optional aria2c external downloader shipped with this build, so the
// Settings toggle can disable itself instead of silently doing nothing (the flag
// is a no-op without the binary — see download.ts's existsSync gate).
ipcMain.handle(IpcChannels.aria2cAvailable, () => existsSync(getAria2cPath()))
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
+183
View File
@@ -0,0 +1,183 @@
import { net } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { unlink } from 'fs/promises'
import { createHash } from 'crypto'
/** Result of streaming a verified download to disk. */
export interface VerifiedDownloadResult {
ok: boolean
/** absolute path to the written file, when ok */
filePath?: string
error?: string
}
/** Live byte progress of a streaming download (main → renderer). */
export interface VerifiedDownloadProgress {
/** bytes received so far */
received: number
/** total bytes, when the server reports Content-Length */
total?: number
/** 0..1 fraction, when total is known */
fraction?: number
}
/** Context-specific message overrides; the defaults are generic and suit any caller. */
export interface VerifiedDownloadMessages {
/** shown when the streamed bytes don't match expectedSha */
checksumMismatch?: string
/** shown when the caller-armed cancel fires */
canceled?: string
}
export interface StreamVerifiedFileOptions {
url: string
filePath: string
/** published SHA-256 (lowercase hex) to verify against, or null to skip verification */
expectedSha: string | null
/** per-hop trust gate: a URL is fetched or followed only if this returns true for it */
isTrusted: (url: string) => boolean
onProgress: (p: VerifiedDownloadProgress) => void
onCancelReady: (cancel: () => void) => void
onSettled: (cancel: () => void) => void
/** stall budget: how long with no bytes received before abandoning (default 60s) */
idleTimeoutMs?: number
messages?: VerifiedDownloadMessages
}
/** Default stall (no-bytes) budget before a download is abandoned. */
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
/**
* Stream one HTTPS resource into a file with full download discipline: per-hop redirect
* re-validation against a caller-supplied trust gate, streaming SHA-256 verification,
* idle-timeout, content-length truncation detection, write-stream backpressure, and a
* single teardown point that aborts the request and removes the partial file on every
* failure path.
*
* Extracted from the app updater (CL4) so the same audited loop backs both the installer
* download and the first-run ffmpeg fetch; the differences (which hosts are trusted, the
* checksum source, a couple of user-facing strings) are parameters, not forks. net.request
* (not fetch) is used so the host can be re-validated on EVERY redirect hop — undici's
* fetch hides the Location header under redirect:'manual', so it can't gate hops.
*
* The caller owns the cancel slot: `onCancelReady` hands it this download's cancel
* function once armed, and `onSettled` hands the same function back on completion so the
* caller can release the slot only if this download still owns it.
*/
export function streamVerifiedFile(
opts: StreamVerifiedFileOptions
): Promise<VerifiedDownloadResult> {
const { url, filePath, expectedSha, isTrusted, onProgress, onCancelReady, onSettled } = opts
const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
const canceledMsg = opts.messages?.canceled ?? 'Download canceled.'
const checksumMsg =
opts.messages?.checksumMismatch ??
'The download failed its checksum check — it may be corrupt or tampered with.'
return new Promise<VerifiedDownloadResult>((resolve) => {
let settled = false
let fileStream: WriteStream | null = null
let idle: NodeJS.Timeout | null = null
// Single teardown point. On failure it also aborts the request, so a write error
// (disk full, etc.) can't leave Electron pulling bytes into a dead stream. abort()
// is idempotent, so the call sites below don't repeat it.
const finish = (result: VerifiedDownloadResult): void => {
if (settled) return
settled = true
onSettled(requestCancel)
if (idle) clearTimeout(idle)
if (!result.ok) {
request.abort()
// Close the handle before unlinking (Windows won't delete an open file) and
// never leave a partial/aborted file lying around.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// Expose this download's abort so the caller's Cancel routes through the same
// teardown — request.abort() + partial cleanup.
const requestCancel = (): void => finish({ ok: false, error: canceledMsg })
onCancelReady(requestCancel)
// 'manual' means a hop only proceeds if we call followRedirect().
const request = net.request({ url, redirect: 'manual' })
const armIdle = (): void => {
if (idle) clearTimeout(idle)
idle = setTimeout(() => {
finish({ ok: false, error: 'Download stalled — please try again.' })
}, idleTimeoutMs)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrusted(redirectUrl)) {
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
return
}
const lenHeader = response.headers['content-length']
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
const stream = createWriteStream(filePath)
fileStream = stream
// Hash the bytes as they stream by, so verification needs no second pass.
const hash = createHash('sha256')
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume() exist at
// runtime but aren't in its type, so feature-detect them to apply backpressure —
// without it a large download can buffer in memory.
const flow = response as unknown as { pause?(): void; resume?(): void }
let received = 0
armIdle()
response.on('data', (chunk: Buffer) => {
armIdle()
received += chunk.length
hash.update(chunk)
if (!stream.write(chunk) && flow.pause && flow.resume) {
flow.pause()
stream.once('drain', () => flow.resume?.())
}
onProgress({ received, total, fraction: total ? received / total : undefined })
})
response.on('end', () => {
// Body fully received — "stalled" is no longer meaningful past this point.
if (idle) clearTimeout(idle)
stream.end(() => {
// A truncated download (connection dropped mid-stream) must never be used.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({ ok: false, error: checksumMsg })
return
}
finish({ ok: true, filePath })
})
})
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
// promise can't hang.
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
request.end()
})
}
+58
View File
@@ -12,3 +12,61 @@ export function cleanError(stderr: string): string {
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
// Lines that carry no diagnostic value as post-processing context: per-tick
// download progress and the "Deleting original file" cleanup bookkeeping yt-dlp
// prints between merge steps.
const CONTEXT_NOISE = /^\[download\]|^Deleting original file/i
// A single context line, capped so a long output path can't bloat the message.
const CONTEXT_LINE_MAX = 200
/**
* Richer error for the DOWNLOAD failure path (probe/indexer/updater keep the
* concise cleanError line).
*
* yt-dlp collapses an ffmpeg post-processing failure to a bare
* "ERROR: Postprocessing: Conversion failed!" that names neither the step that
* died nor the reason — and without `--verbose` the underlying ffmpeg detail is
* never emitted at all. What yt-dlp DOES print is a tagged progress line per
* post-processor ([Merger], [ModifyChapters], [ExtractAudio], …; its phase tags
* like [download]/[info] are lowercase). So for that generic wrapper we append
* the last couple of non-noise trailing lines, which name the failing step (e.g.
* the SponsorBlock re-encode) and surface any ffmpeg diagnostic when one is
* present. Self-explanatory errors (a private video, a 403) are returned
* unchanged.
*/
export function describeDownloadError(stderr: string): string {
const headline = cleanError(stderr)
if (!headline || !/conversion failed|postprocessing/i.test(headline)) return headline
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const context: string[] = []
for (const line of [...lines].reverse()) {
if (context.length >= 2) break
if (CONTEXT_NOISE.test(line) || /^error/i.test(line)) continue
// Skip anything already conveyed by the headline (avoids echoing it back).
if (headline.includes(line) || line.includes(headline)) continue
const capped = line.length > CONTEXT_LINE_MAX ? `${line.slice(0, CONTEXT_LINE_MAX - 1)}` : line
if (!context.includes(capped)) context.unshift(capped)
}
return context.length > 0 ? `${headline}${context.join(' · ')}` : headline
}
/**
* Full non-noise stderr, for the persisted error log's "Show details" expander
* (Diagnostics card) — everything describeDownloadError condenses to one line,
* kept in full (progress ticks and delete-bookkeeping still stripped) so a bug
* report carries the real yt-dlp/ffmpeg transcript instead of a paraphrase.
* Returns undefined when there's nothing beyond a single line — the headline
* the caller already shows — so a plain, self-explanatory error doesn't grow a
* details toggle with nothing new in it.
*/
export function stderrDetail(stderr: string): string | undefined {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !CONTEXT_NOISE.test(l))
return lines.length > 1 ? lines.join('\n') : undefined
}
+5 -5
View File
@@ -62,12 +62,12 @@ function getStore(): Store<Settings> {
let cachedSettings: Settings | null = null
// --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
// so a leaked settings.json doesn't expose them. They're decrypted before
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
// proxy / youtubePoToken can carry secrets (a proxy password, API tokens).
// They're stored encrypted via Electron safeStorage (DPAPI on Windows) so a
// leaked settings.json doesn't expose them. They're decrypted before reaching
// the renderer; backup export strips them entirely (see backup.ts, M22).
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
export const SECRET_KEYS = ['proxy', 'youtubePoToken'] as const
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage
+1 -3
View File
@@ -102,9 +102,7 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings
youtubePlayerClient: z.string().transform((v) => v.trim()),
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
proxy: z.string(),
youtubePoToken: z.string(),
// An access token has no spaces; trim before encrypting (like proxy creds).
updateToken: z.string().transform((v) => v.trim())
youtubePoToken: z.string()
}
/**
+18 -182
View File
@@ -1,34 +1,9 @@
import { app, net, shell, type WebContents } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { stat, unlink } from 'fs/promises'
import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { createHash } from 'crypto'
import { getSettings } from './settings'
import { UPDATE_HOST, RELEASE_API, BAKED_UPDATE_TOKEN } from './config'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress
} from '@shared/ipc'
/**
* Authorization header for the update host. The user's updateToken from Settings
* wins if set (lets a private fork target its own repo); otherwise it falls back
* to BAKED_UPDATE_TOKEN — the read-only token compiled in at build time so a
* stock client can read the private release repo with no setup. Empty (no header)
* only when neither is present.
*
* The token must never reach an origin other than the host-pinned UPDATE_HOST.
* Every request that carries it guards against that on its own terms: the REST
* check refuses to follow redirects (`redirect: 'error'`), and the download paths
* use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So
* a redirect can't bounce the header to another host on any path.
*/
function authHeader(): Record<string, string> {
const tok = getSettings().updateToken?.trim() || BAKED_UPDATE_TOKEN
return tok ? { Authorization: `token ${tok}` } : {}
}
import { UPDATE_HOST, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in
@@ -141,10 +116,10 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json', ...authHeader() },
headers: { Accept: 'application/json' },
// The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps an authenticated check from ever forwarding the token to
// another origin; a stray redirect fails safe into the catch below.
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to
// a sign-in page) fails safe into the catch below rather than being followed.
redirect: 'error',
signal: controller.signal
})
@@ -191,9 +166,6 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
}
}
/** How long the download may stall (no bytes received) before we give up. */
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
// The in-flight installer download's canceller, set while a download runs and
// cleared on settle. During the pre-download checksum phase it just flags the
// cancel; during the download phase it routes through `finish()` (abort the
@@ -238,7 +210,6 @@ function fetchTrustedText(
resolve(r)
}
const request = net.request({ url, redirect: 'manual' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
request.on('redirect', (_s, _m, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
@@ -269,149 +240,6 @@ function fetchTrustedText(
})
}
/**
* Stream one HTTPS resource into a file with the updater's full download
* discipline (CL4's extracted helper; the once-wrapped event-emitter API CC5
* calls for): per-hop redirect re-validation against the trusted host, streaming
* SHA-256, idle-timeout, content-length truncation detection, write-stream
* backpressure, and single-point teardown that aborts the request and removes
* the partial file on every failure path.
*
* The caller owns the module-level cancel slot: `onCancelReady` hands it this
* download's cancel function once armed, and `onSettled` hands the same function
* back on completion so the caller can release the slot only if this download
* still owns it (the B6 ownership rule).
*/
function streamInstallerToFile(opts: {
url: string
filePath: string
/** published SHA-256 to verify against, or null to skip verification */
expectedSha: string | null
onProgress: (p: AppUpdateProgress) => void
onCancelReady: (cancel: () => void) => void
onSettled: (cancel: () => void) => void
}): Promise<AppUpdateDownload> {
const { url, filePath, expectedSha, onProgress, onCancelReady, onSettled } = opts
return new Promise<AppUpdateDownload>((resolve) => {
let settled = false
let fileStream: WriteStream | null = null
let idle: NodeJS.Timeout | null = null
// Single teardown point. On failure it also aborts the request, so a write
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
// stream. abort() is idempotent, so the call sites below don't repeat it.
const finish = (result: AppUpdateDownload): void => {
if (settled) return
settled = true
onSettled(requestCancel)
if (idle) clearTimeout(idle)
if (!result.ok) {
request.abort()
// Close the handle before unlinking (Windows won't delete an open file)
// and never leave a partial/aborted installer lying around in temp.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// Expose this download's abort so cancelAppUpdate() (the update card's Cancel)
// can route through the same teardown — request.abort() + partial cleanup. (B6)
const requestCancel = (): void => finish({ ok: false, error: 'Update download canceled.' })
onCancelReady(requestCancel)
// net.request (not fetch) so we can re-validate the host on EVERY redirect
// hop: Gitea 302s a release download to its attachment store, and undici's
// fetch hides the Location header under redirect:'manual', so it can't gate
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
const request = net.request({ url, redirect: 'manual' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
const armIdle = (): void => {
if (idle) clearTimeout(idle)
idle = setTimeout(() => {
finish({ ok: false, error: 'Download stalled — please try again.' })
}, DOWNLOAD_IDLE_TIMEOUT_MS)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
return
}
const lenHeader = response.headers['content-length']
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
const stream = createWriteStream(filePath)
fileStream = stream
// Hash the bytes as they stream by, so verification needs no second pass.
const hash = createHash('sha256')
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
// exist at runtime but aren't in its type, so feature-detect them to apply
// backpressure — without it a ~300 MB installer can buffer in memory.
const flow = response as unknown as { pause?(): void; resume?(): void }
let received = 0
armIdle()
response.on('data', (chunk: Buffer) => {
armIdle()
received += chunk.length
hash.update(chunk)
if (!stream.write(chunk) && flow.pause && flow.resume) {
flow.pause()
stream.once('drain', () => flow.resume?.())
}
onProgress({
received,
total,
fraction: total ? received / total : undefined
})
})
response.on('end', () => {
// Body fully received — "stalled" is no longer meaningful past this point.
if (idle) clearTimeout(idle)
stream.end(() => {
// A truncated download (connection dropped mid-stream) must never be run.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the release's published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({
ok: false,
error:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
})
return
}
finish({ ok: true, filePath })
})
})
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
// promise can't hang.
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
request.end()
})
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
@@ -465,15 +293,17 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
}
// The streaming itself — redirects, hashing, timeout, truncation, teardown —
// lives in streamInstallerToFile (CL4). This function stays a linear
// pre-flight: trust-check → checksum resolution → stream → done. The cancel
// lives in streamVerifiedFile (lib/verifiedDownload, CL4). This function stays a
// linear pre-flight: trust-check → checksum resolution → stream → done. The cancel
// slot's B6 ownership dance stays here, next to the module state it guards:
// ownership moves from the checksum-phase earlyCancel above to the stream's
// cancel, and is released only if this download still owns the slot.
return streamInstallerToFile({
return streamVerifiedFile({
url,
filePath,
expectedSha,
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
isTrusted: isTrustedDownloadUrl,
onProgress: (progress) => {
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
},
@@ -482,6 +312,12 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
},
onSettled: (cancel) => {
if (cancelActiveDownload === cancel) cancelActiveDownload = null
},
// Preserve the update card's exact wording (the generic defaults are for other callers).
messages: {
checksumMismatch:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'Update download canceled.'
}
})
}
+36 -1
View File
@@ -9,6 +9,9 @@ import {
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
@@ -64,10 +67,34 @@ const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
/** Versions of the managed ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
/** Readiness (both binaries present) + versions of the managed ffmpeg/ffprobe. */
getFfmpegSetupStatus: (): Promise<FfmpegSetupStatus> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupStatus),
/** Download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup). */
downloadFfmpeg: (): Promise<FfmpegSetupResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupDownload),
/** Abort the in-flight ffmpeg archive download (the setup card's Cancel). */
cancelFfmpegDownload: (): Promise<void> => ipcRenderer.invoke(IpcChannels.ffmpegSetupCancel),
/** Offline fallback: pick a folder holding ffmpeg.exe + ffprobe.exe and install from it. */
locateFfmpeg: (): Promise<FfmpegSetupResult> => ipcRenderer.invoke(IpcChannels.ffmpegSetupLocate),
/** Subscribe to ffmpeg setup progress (download %, then extract). Returns an unsubscribe fn. */
onFfmpegSetupProgress: (cb: (p: FfmpegSetupProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: FfmpegSetupProgress): void => cb(p)
ipcRenderer.on(IpcChannels.ffmpegSetupProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.ffmpegSetupProgress, listener)
},
/** Whether the optional aria2c external downloader is present in this build. */
aria2cAvailable: (): Promise<boolean> => ipcRenderer.invoke(IpcChannels.aria2cAvailable),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
@@ -252,6 +279,14 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
},
/** Subscribe to the "run watched-source sync now" nudge a scheduled `--sync` relaunch
* sends when it hits the already-running instance. Returns an unsubscribe function. */
onRunWatchedSync: (cb: () => void): (() => void) => {
const listener = (): void => cb()
ipcRenderer.on(IpcChannels.runWatchedSync, listener)
return () => ipcRenderer.removeListener(IpcChannels.runWatchedSync, listener)
},
// --- Built-in yt-dlp terminal (Phase N) ---
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
+12 -1
View File
@@ -8,6 +8,7 @@ import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useSetup } from './store/setup'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
@@ -130,6 +131,11 @@ function App(): React.JSX.Element {
useDownloads.getState().hydrate()
}, [])
// Subscribe to ffmpeg setup progress and run the initial readiness check, which drives
// the first-run setup gate below. In an App effect (not at store module load) so it runs
// after window.api is assigned in every context (real bridge and browser-preview mock).
useEffect(() => useSetup.getState().init(), [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
@@ -222,7 +228,12 @@ function App(): React.JSX.Element {
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
// Onboarding doubles as the one-time media-tools setup: show it for a fresh user, OR
// whenever ffmpeg/ffprobe are confirmed missing (an existing user upgrading from a
// bundled build). `=== false` (not falsy) so the null "still checking" state never
// flashes the setup screen at a user who actually has ffmpeg.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const showOnboarding = loaded && (!hasCompletedOnboarding || ffmpegReady === false)
return (
<FluentProvider
+50 -3
View File
@@ -2,7 +2,8 @@ import {
DEFAULT_SETTINGS,
type Settings,
type CommandTemplate,
type MediaProfile
type MediaProfile,
type FfmpegSetupProgress
} from '@shared/ipc'
/**
@@ -50,6 +51,11 @@ let mockProfiles: MediaProfile[] = [
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
]
// First-run ffmpeg setup preview state: ready unless `?setup` is in the preview URL (which
// forces the onboarding setup step), plus the progress callback the fake download drives.
let mockFfmpegReady = typeof location === 'undefined' || !/[?&]setup(\b|=)/.test(location.search)
let mockFfmpegProgressCb: ((p: FfmpegSetupProgress) => void) | null = null
export const mockApi: Api = {
getAppVersion: async () => MOCK_CURRENT_VERSION,
checkForAppUpdate: async () => {
@@ -75,9 +81,46 @@ export const mockApi: Api = {
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
ffmpeg: '8.1.2-essentials_build (UI preview mock)',
ffprobe: '8.1.2-essentials_build (UI preview mock)'
}),
// First-run ffmpeg setup: ready by default so the preview isn't blocked. Append `?setup`
// to the preview URL to force the not-ready state and demo the onboarding setup step —
// the fake download/locate below then flips it ready.
getFfmpegSetupStatus: async () =>
mockFfmpegReady
? {
ready: true,
ffmpeg: '8.1.2-essentials_build (mock)',
ffprobe: '8.1.2-essentials_build (mock)'
}
: { ready: false, ffmpeg: null, ffprobe: null },
downloadFfmpeg: async () => {
for (let i = 1; i <= 10; i++) {
await new Promise((r) => setTimeout(r, 150))
mockFfmpegProgressCb?.({
phase: 'downloading',
received: i * 11_000_000,
total: 110_000_000,
fraction: i / 10
})
}
mockFfmpegProgressCb?.({ phase: 'extracting' })
await new Promise((r) => setTimeout(r, 500))
mockFfmpegReady = true
return { ok: true }
},
cancelFfmpegDownload: async () => {},
locateFfmpeg: async () => {
mockFfmpegReady = true
return { ok: true }
},
onFfmpegSetupProgress: (cb) => {
mockFfmpegProgressCb = cb
return () => {
if (mockFfmpegProgressCb === cb) mockFfmpegProgressCb = null
}
},
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -167,6 +210,9 @@ export const mockApi: Api = {
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
// Preview assumes the bundled aria2c is present, so the Network toggle renders
// in its normal (enabled) state.
aria2cAvailable: async () => true,
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
@@ -225,6 +271,7 @@ export const mockApi: Api = {
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {},
onRunWatchedSync: () => () => {},
// Media-manager sources — a seeded channel so the (Phase H) Library view has
// demo data in this browser-only preview.
listSources: async () => [
+38 -24
View File
@@ -5,7 +5,10 @@ import { logError } from '../reportError'
import { revealFile } from '../reveal'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
import { useProfiles } from './profiles'
import { emit, on } from './coordinator'
import { pickRetryOptions } from './retryOptions'
import { RESOLVING, buildItem } from './downloadItem'
import { fromPersisted, persistableItems } from './queuePersist'
import { seed } from './downloadSeed'
@@ -27,6 +30,39 @@ export type { MediaKind, ChosenFormat, DownloadItem, DownloadStatus, AddOptions,
// we've loaded it back.
let queueHydrated = false
// Reset a failed/canceled item back to 'queued' for a fresh spawn, re-resolving
// its post-processing option group from the CURRENT source profile / global
// settings first (pickRetryOptions). Without this, a retry replays the options
// frozen when the item was queued, so a settings change made in between — e.g.
// turning SponsorBlock-remove off — never takes effect. Reading the sources /
// profiles stores here is a one-way lookup (they never import downloads, so no
// cycle); manual (non-source) items resolve to null and keep their explicit
// per-download options untouched.
function requeueForRetry(item: DownloadItem): DownloadItem {
const settings = useSettings.getState()
const fresh = pickRetryOptions(
item.mediaItemId,
useSources.getState().sources,
useProfiles.getState().profiles,
{
kind: settings.defaultKind,
videoQuality: settings.defaultVideoQuality,
audioQuality: settings.defaultAudioQuality,
options: settings.downloadOptions,
outputTemplate: settings.collectionOutputTemplate
}
)
return {
...item,
...(fresh ?? {}),
status: 'queued',
progress: 0,
error: undefined,
speedBytesPerSec: undefined,
etaSeconds: undefined
}
}
// Side-effects to run exactly once when a download completes: record it to
// history (unless private) and mark its source MediaItem downloaded. Shared by
// the real `applyEvent('done')` path and the preview ticker so the two can't
@@ -213,36 +249,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
retry: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speedBytesPerSec: undefined,
etaSeconds: undefined
}
: i
)
items: s.items.map((i) => (i.id === id ? requeueForRetry(i) : i))
}))
pump()
},
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speedBytesPerSec: undefined,
etaSeconds: undefined
}
: i
)
items: s.items.map((i) => (i.status === 'error' ? requeueForRetry(i) : i))
}))
pump()
},
+17
View File
@@ -13,6 +13,23 @@ const seed: ErrorLogEntry[] = PREVIEW
kind: 'video',
error: '[youtube] err0r: Private video. Sign in if you have access.',
occurredAt: Date.now() - 1000 * 60 * 12
},
{
id: 'e2',
title: 'youre about to get hacked!! (7 reasons why)',
url: 'https://youtube.com/watch?v=q2err0r',
kind: 'video',
error:
'Postprocessing: Conversion failed! — [ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
detail: [
'[info] q2err0r: Downloading 1 format(s): 137+251',
'[SponsorBlock] Found 1 segments in the SponsorBlock database',
'[Merger] Merging formats into "video.mp4"',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'[ModifyChapters] Removing chapters from video.mp4',
'ERROR: Postprocessing: Conversion failed!'
].join('\n'),
occurredAt: Date.now() - 1000 * 60 * 5
}
]
: []
+16
View File
@@ -0,0 +1,16 @@
import type { MediaItem } from '@shared/ipc'
/**
* Which of a source's items are worth queuing for a one-click "Download new":
* everything not already downloaded AND not already represented in the download
* queue. `queuedUrls` is the set of URLs that currently have any queue entry, so
* an item that's downloading, queued, completed, failed, or canceled is left
* alone matching the expanded panel's "Download N pending" semantics (a failed
* item is retried via its own Retry, not bulk-swept here).
*
* Pure so the choice is unit-tested without the store; the impure enqueue lives
* in the caller (LibraryView).
*/
export function selectUndownloaded(items: MediaItem[], queuedUrls: Set<string>): MediaItem[] {
return items.filter((it) => !it.downloaded && !queuedUrls.has(it.url))
}
+51
View File
@@ -0,0 +1,51 @@
import type { Source, MediaProfile } from '@shared/ipc'
import { resolveProfile, type ProfileDefaults } from '@shared/profileResolve'
import type { DownloadItem } from './downloadTypes'
/** The per-download group a retry re-resolves from current config. */
export type RetryOptionGroup = Pick<
DownloadItem,
'options' | 'extraArgs' | 'collectionOutputTemplate'
>
/**
* Re-resolve a failed download's post-processing option group from the CURRENT
* source profile / global settings, using the same precedence enqueue does
* (resolveProfile). This is what lets a retry pick up a settings change made
* after the item was queued e.g. turning SponsorBlock-remove off instead of
* replaying the options frozen at add time.
*
* Only downloads tied to a Library source are re-resolved: their options always
* derived from the source/global config in the first place. `mediaItemId` is
* `<sourceId>:<videoId>`, so the source id is its prefix. Returned as a partial so
* the caller can spread it over the item.
*
* - no mediaItemId (a manual paste) null: it carried the user's explicit
* per-download choices, so retry must not overwrite them.
* - source since deleted null: nothing to resolve against; keep
* whatever options the item already had.
* - otherwise the profile's group, or the globals when
* the source points at no (or a dangling) profile.
*
* Distinct from the history re-download path, which deliberately KEEPS an item's
* frozen options to reproduce an identical file (L87); a retry's goal is instead
* to get a failed download to succeed under the user's current configuration.
*/
export function pickRetryOptions(
mediaItemId: string | undefined,
sources: Source[],
profiles: MediaProfile[],
defaults: ProfileDefaults
): RetryOptionGroup | null {
const sourceId = mediaItemId?.split(':')[0]
if (!sourceId) return null
const src = sources.find((s) => s.id === sourceId)
if (!src) return null
const profile = src.profileId ? (profiles.find((p) => p.id === src.profileId) ?? null) : null
const resolved = resolveProfile(profile, defaults)
return {
options: resolved.options,
extraArgs: resolved.extraArgs || undefined,
collectionOutputTemplate: resolved.outputTemplate || undefined
}
}
+112
View File
@@ -0,0 +1,112 @@
import { create } from 'zustand'
import { logError } from '../reportError'
/**
* First-run setup state: whether the managed ffmpeg/ffprobe are present, and the
* download / locate orchestration for acquiring them. The App gate blocks the app on
* `ffmpegReady === false`, and Onboarding + the About card drive the actions here.
*
* Unlike the settings store, this has no preview-only fallback: `window.api` is the real
* bridge or the mockApi (which simulates a fake download and honours the `?setup` preview
* flag), so the same code path works in both. Init is driven from an App effect not at
* module load so it runs after `window.api` is assigned in every context.
*/
interface SetupState {
/** null until the first status check resolves; then true once both binaries are present */
ffmpegReady: boolean | null
ffmpegVersion: string | null
ffprobeVersion: string | null
/** phase of an in-flight acquisition (drives the progress UI) */
phase: 'idle' | 'downloading' | 'extracting'
/** 0..1 download fraction when known, else undefined (indeterminate bar) */
fraction: number | undefined
/** a download or locate is running */
busy: boolean
error: string | null
/** subscribe to progress + run the first status check; returns an unsubscribe fn */
init: () => () => void
/** re-query readiness + versions from main */
check: () => Promise<void>
/** download + unpack ffmpeg from the pinned upstream archive */
download: () => Promise<void>
/** abort the in-flight download */
cancel: () => void
/** pick a folder that already holds ffmpeg + ffprobe and install from it */
locate: () => Promise<void>
}
// Set when the user cancels a download, so the (not-ok) awaited result is treated as a
// cancel rather than surfaced as an error — same idea as the app-update card's canceledRef.
let canceled = false
export const useSetup = create<SetupState>((set, get) => ({
ffmpegReady: null,
ffmpegVersion: null,
ffprobeVersion: null,
phase: 'idle',
fraction: undefined,
busy: false,
error: null,
init: () => {
void get().check()
return (
window.api?.onFfmpegSetupProgress?.((p) =>
set({ phase: p.phase, fraction: p.phase === 'downloading' ? p.fraction : undefined })
) ?? (() => {})
)
},
check: async () => {
try {
const s = await window.api.getFfmpegSetupStatus()
set({ ffmpegReady: s.ready, ffmpegVersion: s.ffmpeg, ffprobeVersion: s.ffprobe })
} catch (e) {
logError('getFfmpegSetupStatus')(e)
}
},
download: async () => {
if (get().busy) return
canceled = false
set({ busy: true, error: null, phase: 'downloading', fraction: undefined })
try {
const r = await window.api.downloadFfmpeg()
// The user hit Cancel: main aborted the download and cleaned up — don't surface its
// not-ok result as an error.
if (canceled) return
if (!r.ok) {
set({ error: r.error ?? 'The ffmpeg download failed.' })
return
}
await get().check() // flips ffmpegReady true, clearing the gate
} catch (e) {
if (!canceled) set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false, phase: 'idle', fraction: undefined })
}
},
cancel: () => {
canceled = true
window.api.cancelFfmpegDownload?.().catch(logError('cancelFfmpegDownload'))
},
locate: async () => {
if (get().busy) return
set({ busy: true, error: null })
try {
const r = await window.api.locateFfmpeg()
if (!r.ok) {
// A dismissed folder picker ('No folder selected.') isn't an error worth showing.
if (r.error && !/no folder selected/i.test(r.error)) set({ error: r.error })
return
}
await get().check()
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false })
}
}
}))
+22 -3
View File
@@ -39,7 +39,8 @@ const seedSources: Source[] =
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 9
itemCount: 9,
watched: true
},
{
id: 'src-mix',
@@ -99,6 +100,11 @@ interface SourcesState {
* enqueued.
*/
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
/**
* Load a source's items (lazily fetching + caching once) WITHOUT expanding it,
* so a per-row action can act on a collapsed source. Resolves with the items.
*/
ensureSourceItems: (sourceId: string) => Promise<MediaItem[]>
/** mark an item downloaded once its queue download completes (called by the downloads store) */
markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
/** toggle a source's watched-for-new-uploads flag (Phase J) */
@@ -300,6 +306,16 @@ export const useSources = create<SourcesState>((set, get) => ({
return items.length
},
ensureSourceItems: async (sourceId) => {
const cached = get().itemsBySource[sourceId]
if (cached) return cached
// Preview has no IPC — fall back to whatever seed items exist for the source.
if (PREVIEW) return get().itemsBySource[sourceId] ?? []
const items = await window.api.listSourceItems(sourceId)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: items } }))
return items
},
markDownloaded: (itemId, filePath, quality) => {
// Which loaded source holds this item — also the reconcile key, so overlapping
// completions in different sources never suppress each other's response.
@@ -469,13 +485,16 @@ if (!PREVIEW) {
.listSources()
.then((sources) => {
useSources.setState({ sources })
// If any source is watched, kick off a sync shortly after launch (covers the
// scheduled `--sync` launch and a normal launch alike).
// If any source is watched, kick off a sync shortly after launch (covers a fresh
// `--sync` launch and a normal launch alike).
if (sources.some((s) => s.watched)) {
setTimeout(() => useSources.getState().syncWatched(), 1500)
}
})
.catch(logError('startup listSources'))
// A scheduled `--sync` relaunch that hit the already-running instance nudges us here,
// so the daily sync fires even when AeroFetch was already open (e.g. tray mode).
window.api.onRunWatchedSync?.(() => useSources.getState().syncWatched())
window.api.onIndexProgress((p: IndexProgress) => {
useSources.setState({
indexing: {
+100 -32
View File
@@ -33,6 +33,7 @@ import { Select } from '../components/Select'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { selectUndownloaded } from '../store/librarySelect'
import { thumbUrl } from '../lib/thumb'
import { relTime } from '../lib/datetime'
import { MediaThumb } from '../components/MediaThumb'
@@ -109,13 +110,27 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1,
overflow: 'hidden'
},
// Row wrapping the expand button + an optional trailing action (e.g. "Download
// new"). The action is a sibling of the button, not nested inside it (nested
// <button>s are invalid), so both stay independently focusable.
cardHeadRow: {
display: 'flex',
alignItems: 'center'
},
cardHeadAction: {
flexShrink: 0,
paddingRight: SPACE.cozy
},
cardHead: {
display: 'flex',
alignItems: 'center',
gap: '12px',
// List-row padding tier (UI3): cozy on both axes (was 12/14).
padding: SPACE.cozy,
width: '100%',
// Fill the row, but shrink to make room for the trailing action (minWidth:0
// lets the title ellipsize instead of pushing the action off the edge).
flexGrow: 1,
minWidth: 0,
border: 'none',
backgroundColor: 'transparent',
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
@@ -235,6 +250,7 @@ export function LibraryView(): React.JSX.Element {
const cancelIndexing = useSources((s) => s.cancelIndexing)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const ensureSourceItems = useSources((s) => s.ensureSourceItems)
const setWatched = useSources((s) => s.setWatched)
const setProfile = useSources((s) => s.setProfile)
const profiles = useProfiles((s) => s.profiles)
@@ -279,6 +295,10 @@ export function LibraryView(): React.JSX.Element {
)
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null)
// The source whose per-row "Download new" is currently loading items + queuing,
// so its button shows a busy state (the action is async: it may fetch a
// collapsed source's items before enqueuing).
const [busySourceId, setBusySourceId] = useState<string | null>(null)
// Which playlist groups are expanded. Empty = all collapsed (the default), so
// an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
@@ -355,7 +375,11 @@ export function LibraryView(): React.JSX.Element {
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// Every URL that already has a queue entry (any status), so a source's
// not-downloaded-and-not-queued items can be picked with the shared selector —
// used both here for the expanded panel and by the per-row "Download new".
const queuedUrls = useMemo(() => new Set(statusByUrl.keys()), [statusByUrl])
const pendingItems = selectUndownloaded(items, queuedUrls)
// An item is worth (re)queuing when it isn't already downloaded or in flight:
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
@@ -431,6 +455,30 @@ export function LibraryView(): React.JSX.Element {
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// Per source-row action: queue every not-yet-downloaded, not-yet-queued video
// of a (possibly collapsed) watched source in one click — loading its items
// first if they aren't cached. Uses the source's own resolved kind (no
// kindOverride), and reports the outcome in the shared sync note.
async function downloadNew(src: Source): Promise<void> {
if (busySourceId) return
setBusySourceId(src.id)
setSyncNote(null)
try {
const its = await ensureSourceItems(src.id)
const queueable = selectUndownloaded(its, queuedUrls)
if (queueable.length === 0) {
setSyncNote(`Nothing new to download for ${src.title}.`)
return
}
const n = enqueueItems(src.id, queueable)
setSyncNote(`Queued ${n} from ${src.title}.`)
} catch {
setSyncNote(`Couldn't load videos for ${src.title}.`)
} finally {
setBusySourceId(null)
}
}
// Queue every actionable (pending/failed/canceled) video in one playlist group,
// so "download this whole playlist" is a single click.
function downloadGroup(groupItems: MediaItem[]): void {
@@ -644,6 +692,19 @@ export function LibraryView(): React.JSX.Element {
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
rowAction={
src.watched ? (
<Button
size="small"
appearance="subtle"
icon={busySourceId === src.id ? <Spinner size="tiny" /> : <ArrowDownloadRegular />}
onClick={() => downloadNew(src)}
disabled={busySourceId !== null}
>
Download new
</Button>
) : undefined
}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
@@ -802,48 +863,55 @@ function SourceCard({
source,
expanded,
onToggleExpand,
rowAction,
children
}: {
styles: ReturnType<typeof useStyles>
source: Source
expanded: boolean
onToggleExpand: () => void
/** an optional action rendered beside the header, OUTSIDE the expand button
* (nesting a button inside the header button is invalid) e.g. "Download new" */
rowAction?: React.ReactNode
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return (
<div className={styles.card}>
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
<div className={styles.cardHeadRow}>
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
{rowAction && <div className={styles.cardHeadAction}>{rowAction}</div>}
</div>
{expanded && children}
</div>
)
+182 -56
View File
@@ -5,6 +5,8 @@ import {
Caption1,
Field,
Button,
Spinner,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
@@ -12,6 +14,9 @@ import {
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
DismissRegular,
CheckmarkCircleFilled,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
@@ -22,6 +27,8 @@ import {
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { useSetup } from '../store/setup'
import { useErrorTextStyles } from '../components/ui/errorText'
import { SPACE, ICON } from '../components/ui/tokens'
const useStyles = makeStyles({
@@ -99,6 +106,31 @@ const useStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
// Media-components setup box (download/locate ffmpeg on first run).
setupBox: {
display: 'flex',
flexDirection: 'column',
gap: '10px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
setupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
setupButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
},
setupReadyIcon: {
fontSize: `${ICON.control}px`,
flexShrink: 0,
color: tokens.colorStatusSuccessForeground1
},
tips: {
display: 'flex',
flexDirection: 'column',
@@ -138,10 +170,88 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const errText = useErrorTextStyles()
const update = useSettings((s) => s.update)
const videoFolder = useSettings((s) => s.videoFolder)
const audioFolder = useSettings((s) => s.audioFolder)
const chooseFolder = useSettings((s) => s.chooseFolder)
// A brand-new user gets the full welcome; an already-onboarded user only lands here
// because ffmpeg is missing (e.g. upgrading from a bundled build) — show a compact
// "finish setup" card that auto-dismisses once the tools are installed.
const isFresh = !useSettings((s) => s.hasCompletedOnboarding)
const ready = useSetup((s) => s.ffmpegReady)
const phase = useSetup((s) => s.phase)
const fraction = useSetup((s) => s.fraction)
const busy = useSetup((s) => s.busy)
const setupError = useSetup((s) => s.error)
const download = useSetup((s) => s.download)
const cancel = useSetup((s) => s.cancel)
const locate = useSetup((s) => s.locate)
const componentsField = (
<Field label="Media components">
<div className={styles.setupBox}>
{ready === true ? (
<div className={styles.setupRow}>
<CheckmarkCircleFilled className={styles.setupReadyIcon} />
<Caption1>ffmpeg and ffprobe are installed and ready.</Caption1>
</div>
) : ready === null ? (
<div className={styles.setupRow}>
<Spinner size="tiny" />
<Caption1>Checking media components</Caption1>
</div>
) : (
<>
<Caption1 className={styles.folderNote}>
AeroFetch uses ffmpeg to merge and convert media. It isn&apos;t bundled download it
once (about 105 MB) and it&apos;s kept across updates. On an offline machine, point
AeroFetch at an ffmpeg you already have.
</Caption1>
{busy ? (
<>
<ProgressBar
value={phase === 'extracting' ? undefined : fraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.setupRow}>
<Caption1 className={styles.folderNote}>
{phase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{phase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancel}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.setupButtons}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={() => void download()}
>
Download components
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing ffmpeg
</Button>
</div>
)}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
</>
)}
</div>
</Field>
)
return (
<div className={styles.root}>
@@ -150,69 +260,85 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2 as="h1">Welcome to AeroFetch</Title2>
<Title2 as="h1">{isFresh ? 'Welcome to AeroFetch' : 'Finish setup'}</Title2>
</div>
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
{isFresh ? (
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp is bundled; ffmpeg is set up once below.
</Body1>
) : (
<Body1>
AeroFetch needs ffmpeg and ffprobe to process downloads. They aren&apos;t bundled set
them up once to continue.
</Body1>
)}
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos and audio save to your Documents folders by default. Pick a different folder now,
or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
{videoFolder || 'Documents\\Video'}
</Caption1>
{isFresh && (
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos and audio save to your Documents folders by default. Pick a different folder
now, or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
{videoFolder || 'Documents\\Video'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('videoFolder')}
>
Choose
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
{audioFolder || 'Documents\\Audio'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('audioFolder')}
>
Choose
</Button>
</div>
</Field>
)}
{componentsField}
{isFresh && (
<>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('videoFolder')}
appearance="primary"
icon={<RocketRegular />}
disabled={ready !== true}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Choose
Get started
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
{audioFolder || 'Documents\\Audio'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('audioFolder')}
>
Choose
</Button>
</div>
</Field>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
appearance="primary"
icon={<RocketRegular />}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</>
)}
</Card>
</div>
)
+65 -3
View File
@@ -7,12 +7,20 @@ import {
Caption1,
Text,
Spinner,
ProgressBar,
Skeleton,
SkeletonItem
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
InfoRegular,
ArrowSyncRegular,
ArrowDownloadRegular,
DismissRegular,
FolderRegular
} from '@fluentui/react-icons'
import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSetup } from '../../store/setup'
import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
@@ -43,6 +51,25 @@ export function AboutCard(): React.JSX.Element {
refreshFfmpeg
} = useAboutCard()
// ffmpeg is fetched on first run (not bundled); the setup store owns its download/locate
// orchestration + progress. Re-read the displayed versions once an action settles.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const setupBusy = useSetup((s) => s.busy)
const setupPhase = useSetup((s) => s.phase)
const setupFraction = useSetup((s) => s.fraction)
const setupError = useSetup((s) => s.error)
const downloadFfmpeg = useSetup((s) => s.download)
const cancelFfmpeg = useSetup((s) => s.cancel)
const locateFfmpeg = useSetup((s) => s.locate)
async function getFfmpeg(): Promise<void> {
await downloadFfmpeg()
refreshFfmpeg()
}
async function locate(): Promise<void> {
await locateFfmpeg()
refreshFfmpeg()
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
@@ -50,8 +77,8 @@ export function AboutCard(): React.JSX.Element {
<Subtitle2 as="h3">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
AeroFetch is a generic frontend for yt-dlp. It manages its own copy of yt-dlp (keeping it up
to date automatically) and downloads ffmpeg on first run.
</Caption1>
<div className={styles.folderRow}>
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
@@ -98,6 +125,41 @@ export function AboutCard(): React.JSX.Element {
/>
</div>
)}
{ffmpeg &&
(setupBusy ? (
<>
<ProgressBar
value={setupPhase === 'extracting' ? undefined : setupFraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{setupPhase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{setupPhase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelFfmpeg}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.folderRow}>
<Button icon={<ArrowDownloadRegular />} onClick={() => void getFfmpeg()}>
{ffmpegReady === true ? 'Repair ffmpeg' : 'Download ffmpeg'}
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing
</Button>
</div>
))}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
@@ -1,6 +1,12 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import {
BugRegular,
ChevronDownRegular,
ChevronRightRegular,
CopyRegular,
DeleteRegular
} from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { EmptyState } from '../../components/ui/EmptyState'
@@ -13,13 +19,25 @@ export function DiagnosticsCard(): React.JSX.Element {
const clearErrorLog = useErrorLog((s) => s.clear)
const [confirmClearLog, setConfirmClearLog] = useState(false)
// Which rows have their "Show details" transcript expanded; keyed by entry id.
const [expanded, setExpanded] = useState<Set<string>>(new Set())
function toggleExpanded(id: string): void {
setExpanded((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always
// non-empty here -- no need for an unreachable empty-report fallback (L159).
const report = errorEntries
.map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error, e.detail]
.filter(Boolean)
.join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report).catch(() => {})
@@ -78,17 +96,33 @@ export function DiagnosticsCard(): React.JSX.Element {
<EmptyState compact message="No errors yet." />
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
{errorEntries.slice(0, 20).map((e) => {
const isExpanded = expanded.has(e.id)
return (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
{e.detail && (
<>
<Button
appearance="transparent"
size="small"
icon={isExpanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
onClick={() => toggleExpanded(e.id)}
>
{isExpanded ? 'Hide details' : 'Show details'}
</Button>
{isExpanded && <pre className={styles.errorDetail}>{e.detail}</pre>}
</>
)}
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
)
})}
{errorEntries.length > 20 && (
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
)}
@@ -26,8 +26,12 @@ export function NetworkCard(): React.JSX.Element {
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
// View-model hook owns the PO-token minting state + IPC (L93/CC13).
const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
// View-model hook owns the PO-token minting state + IPC (L93/CC13), plus the
// aria2c-presence probe that keeps the toggle below honest.
const { mintingPot, potHint, clearPotHint, mintPoToken, aria2cAvailable } = useNetworkCard()
// Only treat aria2c as missing once the probe has actually resolved false, so a
// slow check doesn't briefly disable a working toggle.
const aria2cMissing = aria2cAvailable === false
return (
<Card className={styles.card}>
@@ -114,11 +118,21 @@ export function NetworkCard(): React.JSX.Element {
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
validationState={aria2cMissing ? 'warning' : 'none'}
validationMessage={
aria2cMissing
? "aria2c isn't included in this build, so this setting has no effect."
: undefined
}
>
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
<Switch
checked={useAria2c}
disabled={aria2cMissing}
onChange={(_, d) => update({ useAria2c: d.checked })}
/>
</Field>
{useAria2c && (
{useAria2c && !aria2cMissing && (
<Field
label="aria2c connections"
hint="Parallel connections per download (116). More can be faster on throttled connections; fewer is gentler on servers."
@@ -1,6 +1,4 @@
import {
Field,
Input,
Button,
Card,
Subtitle2,
@@ -16,7 +14,6 @@ import {
OpenRegular,
DismissRegular
} from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
@@ -24,8 +21,6 @@ import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update)
// View-model hook owns the check → download → install orchestration (L93/CC13).
const {
@@ -63,21 +58,6 @@ export function SoftwareUpdateCard(): React.JSX.Element {
</Button>
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
@@ -113,5 +113,21 @@ export const useSettingsStyles = makeStyles({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
// The Diagnostics card's "Show details" expander -- the fuller non-noise
// stderr transcript behind a failure's one-line headline. Scrollable and
// capped so a long SponsorBlock/ffmpeg transcript can't blow out the card.
errorDetail: {
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: tokens.colorNeutralForeground3,
backgroundColor: tokens.colorNeutralBackground1,
marginTop: '6px',
padding: '8px',
maxHeight: '200px',
overflowY: 'auto',
...shorthands.borderRadius(tokens.borderRadiusMedium)
}
})
@@ -38,7 +38,7 @@ export function useAboutCard(): AboutCardController {
.then(setVersion)
.catch(logError('getYtdlpVersion'))
.finally(() => setChecking(false))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
// ffmpeg/ffprobe are display-only (fetched on first run, not auto-updated); load them once.
// On a bridge failure, settle to "not found" rather than a permanent skeleton.
window.api
.getFfmpegVersions()
@@ -1,19 +1,33 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { logError } from '../../reportError'
export interface NetworkCardController {
mintingPot: boolean
potHint: string | null
clearPotHint: () => void
mintPoToken: () => Promise<void>
/** whether aria2c.exe shipped with this build; null while the check is in flight */
aria2cAvailable: boolean | null
}
/**
* View-model for the Network card's PO-token minting (L93/CC13): owns the fetch
* state and hint copy so the card stays presentational.
* state and hint copy so the card stays presentational. Also probes whether the
* optional aria2c downloader is bundled, so the card can disable its toggle rather
* than leave a setting that silently does nothing.
*/
export function useNetworkCard(): NetworkCardController {
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [aria2cAvailable, setAria2cAvailable] = useState<boolean | null>(null)
useEffect(() => {
// Detect whether the optional aria2c downloader is bundled so the toggle can
// disable itself instead of silently doing nothing. A failed probe leaves the
// state null (unknown), which keeps the toggle enabled rather than wrongly
// disabling a working feature.
window.api.aria2cAvailable().then(setAria2cAvailable).catch(logError('aria2cAvailable'))
}, [])
async function mintPoToken(): Promise<void> {
setMintingPot(true)
@@ -32,5 +46,5 @@ export function useNetworkCard(): NetworkCardController {
}
}
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken }
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken, aria2cAvailable }
}
+52 -11
View File
@@ -18,6 +18,18 @@ export const IpcChannels = {
appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
/** readiness (both binaries present) + versions of the managed ffmpeg/ffprobe */
ffmpegSetupStatus: 'ffmpeg:setup-status',
/** download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup) */
ffmpegSetupDownload: 'ffmpeg:setup-download',
/** abort the in-flight ffmpeg archive download */
ffmpegSetupCancel: 'ffmpeg:setup-cancel',
/** pick a folder that already holds ffmpeg.exe + ffprobe.exe and install from it */
ffmpegSetupLocate: 'ffmpeg:setup-locate',
/** main → renderer push channel for ffmpeg setup progress (download %, then extract) */
ffmpegSetupProgress: 'ffmpeg:setup-progress',
/** whether the optional aria2c.exe external downloader is present in the bin dir */
aria2cAvailable: 'aria2c:available',
probe: 'media:probe',
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
@@ -93,6 +105,9 @@ export const IpcChannels = {
scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress',
/** main renderer: a scheduled `--sync` relaunch hit the running instance run the
* watched-source sync here instead of no-opping (Phase J) */
runWatchedSync: 'sources:run-sync',
// --- Built-in yt-dlp terminal (Phase N) ---
terminalRun: 'terminal:run',
terminalCancel: 'terminal:cancel',
@@ -275,9 +290,9 @@ export interface YtdlpVersionResult {
}
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* Versions of the managed ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* are fetched on first run (not bundled) and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here
* just "what's installed".
*/
@@ -286,6 +301,33 @@ export interface FfmpegVersionResult {
ffprobe: string | null
}
/** Readiness of the managed ffmpeg/ffprobe: `ready` gates the first-run setup screen. */
export interface FfmpegSetupStatus {
/** true once both ffmpeg.exe and ffprobe.exe are present in the managed dir */
ready: boolean
/** installed versions (null when the binary is absent), for the About card */
ffmpeg: string | null
ffprobe: string | null
}
/** Live progress of the first-run ffmpeg acquisition (main → renderer). */
export interface FfmpegSetupProgress {
/** 'downloading' the archive, then a brief indeterminate 'extracting' phase */
phase: 'downloading' | 'extracting'
/** bytes received so far (downloading phase) */
received?: number
/** total archive bytes, when Content-Length is known */
total?: number
/** 0..1 download fraction, when total is known */
fraction?: number
}
/** Result of a download / locate setup action. */
export interface FfmpegSetupResult {
ok: boolean
error?: string
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo {
ok: boolean
@@ -684,13 +726,6 @@ export interface Settings {
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
*/
useHardwareAcceleration: boolean
/**
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
* only where the release repo allows anonymous access). When the release repo
* is private / the instance requires sign-in, paste a read-only token so the
* updater can check + download. Stored locally in settings, never shipped.
*/
updateToken: string
}
/**
@@ -753,8 +788,7 @@ export const DEFAULT_SETTINGS: Settings = {
minimizeToTray: false,
launchAtStartup: false,
isSidebarCollapsed: false,
useHardwareAcceleration: false,
updateToken: ''
useHardwareAcceleration: false
}
/**
@@ -845,6 +879,13 @@ export interface ErrorLogEntry {
url: string
kind: MediaKind
error: string
/**
* Fuller non-noise stderr context behind the Diagnostics card's "Show
* details" toggle undefined when `error` already says everything (most
* failures), present for the generic "Postprocessing: Conversion failed!"
* wrapper where the useful detail is the tagged step lines that preceded it.
*/
detail?: string
/** epoch milliseconds */
occurredAt: number
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { existsSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Regression guard: the `useAria2c` setting was a silent no-op because aria2c.exe
// was never bundled — download.ts gates the --downloader flag on the file existing
// (existsSync(getAria2cPath())), so with no binary the toggle did nothing. This
// asserts the binary ships in resources/bin (where getBinDir/getAria2cPath resolve
// it, and where electron-builder's extraResources copies it into packaged builds).
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const aria2cPath = join(repoRoot, 'resources', 'bin', 'aria2c.exe')
describe('bundled aria2c', () => {
it('ships aria2c.exe in resources/bin', () => {
expect(existsSync(aria2cPath)).toBe(true)
})
it('is a real executable, not an empty or git-lfs pointer placeholder', () => {
// The real binary is ~5.6 MB; a pointer/placeholder would be a few hundred
// bytes. 1 MB is a comfortable floor that catches either failure mode.
expect(statSync(aria2cPath).size).toBeGreaterThan(1_000_000)
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* Tests for the first-run ffmpeg acquisition (ffmpegSetup.ts). The shared streaming +
* checksum loop is mocked here it's already pinned by updaterDownload.test.ts so these
* focus on the ffmpeg-specific behaviour: the upstream trust gate, readiness + dev seed,
* the download tar-extract install-into-managed-dir flow, and the manual-locate
* fallback (accept a valid folder, reject a missing/blocked one).
*/
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { WebContents } from 'electron'
const ROOT = mkdtempSync(join(tmpdir(), 'aerofetch-ffmpeg-test-'))
const USERDATA = join(ROOT, 'userData')
const TEMP = join(ROOT, 'temp')
const RES = join(ROOT, 'resources')
const MANAGED = join(USERDATA, 'bin')
// getBinDir() resolves the dev seed under <getAppPath()>/resources/bin (its is.dev branch),
// so mocking is.dev=true + app.getAppPath()=ROOT points it at RES/bin. Deliberately NOT
// mutating the process-global process.resourcesPath, which would leak across test files.
// Shared, per-test-tunable control surface (hoisted so the vi.mock factories can read it).
const h = vi.hoisted(() => ({
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] as string[] }
}))
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }))
vi.mock('electron', () => ({
app: {
getPath: (k: string) => (k === 'userData' ? join(ROOT, 'userData') : join(ROOT, 'temp')),
getAppPath: () => ROOT
},
dialog: { showOpenDialog: async () => h.dialog },
BrowserWindow: class {}
}))
vi.mock('../src/main/logger', () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }
}))
// ffmpeg version read is display-only; stub it so readiness comes purely from file presence.
vi.mock('../src/main/ffmpeg', () => ({
getFfmpegVersions: async () => ({ ffmpeg: 'mock', ffprobe: 'mock' })
}))
// The streamer is pinned elsewhere: simulate a written archive + one progress tick.
vi.mock('../src/main/lib/verifiedDownload', () => ({
streamVerifiedFile: async (opts: {
filePath: string
onProgress: (p: { received: number; total: number; fraction: number }) => void
}) => {
if (!h.streamOk) return { ok: false, error: h.streamError }
writeFileSync(opts.filePath, 'FAKE-ZIP')
opts.onProgress({ received: 100, total: 100, fraction: 1 })
return { ok: true, filePath: opts.filePath }
}
}))
// tar.exe extract + `<tool> -version` validation both go through execFile.
vi.mock('child_process', () => ({
execFile: (
file: string,
args: string[],
_opts: unknown,
cb: (err: Error | null, stdout?: string) => void
) => {
if (file.endsWith('tar.exe')) {
if (h.tarFail) return cb(new Error('tar failed'))
const dest = args[args.indexOf('-C') + 1]
if (h.extractWrites) {
writeFileSync(join(dest, 'ffmpeg.exe'), 'FF')
writeFileSync(join(dest, 'ffprobe.exe'), 'FP')
}
return cb(null)
}
if (args.includes('-version')) {
return h.validateOk ? cb(null, 'ffmpeg version 8.1.2') : cb(new Error('blocked'))
}
return cb(null)
}
}))
const { isTrustedFfmpegUrl, getFfmpegSetupStatus, downloadFfmpeg, locateFfmpegManually } =
await import('../src/main/ffmpegSetup')
function fakeWc(): { wc: WebContents; sends: unknown[] } {
const sends: unknown[] = []
const wc = {
isDestroyed: () => false,
send: (_ch: string, p: unknown) => sends.push(p)
} as unknown as WebContents
return { wc, sends }
}
beforeEach(() => {
rmSync(MANAGED, { recursive: true, force: true })
rmSync(join(RES, 'bin'), { recursive: true, force: true })
mkdirSync(TEMP, { recursive: true })
Object.assign(h, {
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] }
})
})
afterAll(() => rmSync(ROOT, { recursive: true, force: true }))
describe('isTrustedFfmpegUrl', () => {
it('accepts HTTPS on an allowlisted upstream host', () => {
expect(
isTrustedFfmpegUrl('https://github.com/GyanD/codexffmpeg/releases/download/x/f.zip')
).toBe(true)
expect(isTrustedFfmpegUrl('https://release-assets.githubusercontent.com/abc?x=1')).toBe(true)
})
it('rejects http, an off-list host, and garbage', () => {
expect(isTrustedFfmpegUrl('http://github.com/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('https://evil.example/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('not a url')).toBe(false)
})
})
describe('getFfmpegSetupStatus', () => {
it('is ready only when both binaries are present', async () => {
mkdirSync(MANAGED, { recursive: true })
writeFileSync(join(MANAGED, 'ffmpeg.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(false) // ffprobe missing
writeFileSync(join(MANAGED, 'ffprobe.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(true)
})
it('seeds the managed copies from the dev bundle when present', async () => {
mkdirSync(join(RES, 'bin'), { recursive: true })
writeFileSync(join(RES, 'bin', 'ffmpeg.exe'), 'seed')
writeFileSync(join(RES, 'bin', 'ffprobe.exe'), 'seed')
const status = await getFfmpegSetupStatus()
expect(status.ready).toBe(true)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
})
})
describe('downloadFfmpeg', () => {
it('downloads, extracts, and installs both binaries into the managed dir', async () => {
const { wc, sends } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
// A downloading tick and the extracting phase both reach the renderer.
expect(sends).toContainEqual(expect.objectContaining({ phase: 'downloading' }))
expect(sends).toContainEqual(expect.objectContaining({ phase: 'extracting' }))
})
it('surfaces a download failure and installs nothing', async () => {
h.streamOk = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/network boom/)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when extraction fails, leaving the managed dir empty', async () => {
h.tarFail = true
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when the archive lacks the expected binaries', async () => {
h.extractWrites = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/missing/i)
})
})
describe('locateFfmpegManually', () => {
function folderWith(files: string[]): string {
const dir = mkdtempSync(join(ROOT, 'located-'))
for (const f of files) writeFileSync(join(dir, f), 'x')
return dir
}
it('installs from a folder that holds both runnable binaries', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
})
it('rejects a folder missing a binary', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/doesn't contain/i)
})
it('rejects binaries that will not run', async () => {
h.validateOk = false
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/could not be run/i)
})
it('reports a dismissed folder picker without installing anything', async () => {
h.dialog = { canceled: true, filePaths: [] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/no folder selected/i)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { selectUndownloaded } from '../src/renderer/src/store/librarySelect'
import type { MediaItem } from '../src/shared/ipc'
const item = (over: Partial<MediaItem> & { videoId: string }): MediaItem => ({
id: `src:${over.videoId}`,
sourceId: 'src',
title: over.videoId,
url: `https://youtube.com/watch?v=${over.videoId}`,
playlistTitle: 'Uploads',
playlistIndex: 1,
downloaded: false,
...over
})
describe('selectUndownloaded', () => {
it('keeps items that are neither downloaded nor already queued', () => {
const items = [item({ videoId: 'a' }), item({ videoId: 'b' })]
const result = selectUndownloaded(items, new Set())
expect(result.map((i) => i.videoId)).toEqual(['a', 'b'])
})
it('excludes already-downloaded items', () => {
const items = [item({ videoId: 'a', downloaded: true }), item({ videoId: 'b' })]
expect(selectUndownloaded(items, new Set()).map((i) => i.videoId)).toEqual(['b'])
})
it('excludes items whose URL already has a queue entry (any status)', () => {
const queued = item({ videoId: 'a' })
const fresh = item({ videoId: 'b' })
const result = selectUndownloaded([queued, fresh], new Set([queued.url]))
expect(result.map((i) => i.videoId)).toEqual(['b'])
})
it('returns an empty array when nothing is queueable', () => {
const items = [item({ videoId: 'a', downloaded: true })]
expect(selectUndownloaded(items, new Set())).toEqual([])
})
it('returns an empty array for no items', () => {
expect(selectUndownloaded([], new Set())).toEqual([])
})
})
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import { cleanError, describeDownloadError, stderrDetail } from '../src/main/log'
describe('cleanError', () => {
it('returns the last ERROR line, stripped of its prefix', () => {
const stderr = [
'[youtube] gHiZA4O5PQM: Downloading webpage',
'ERROR: [youtube] gHiZA4O5PQM: Video unavailable. This video is private'
].join('\n')
expect(cleanError(stderr)).toBe(
'[youtube] gHiZA4O5PQM: Video unavailable. This video is private'
)
})
it('falls back to the last line when nothing matches /error/', () => {
expect(cleanError('one\ntwo\nthree')).toBe('three')
})
it('returns empty string for blank stderr', () => {
expect(cleanError('')).toBe('')
})
})
describe('describeDownloadError', () => {
// A real non-verbose SponsorBlock-remove failure: yt-dlp emits only the
// [ModifyChapters] step lines and the bare wrapper error.
const modifyChaptersFailure = [
'[info] qkUTB65OfAc: Downloading 1 format(s): 137+251',
'[SponsorBlock] Found 1 segments in the SponsorBlock database',
'[Merger] Merging formats into "video.mp4"',
'Deleting original file video.f251.webm (pass -k to keep)',
'Deleting original file video.f137.mp4 (pass -k to keep)',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'[ModifyChapters] Removing chapters from video.mp4',
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
it('names the failing post-processing step for the bare "Conversion failed!" wrapper', () => {
const msg = describeDownloadError(modifyChaptersFailure)
expect(msg.startsWith('Postprocessing: Conversion failed!')).toBe(true)
expect(msg).toContain('ModifyChapters')
})
it('skips [download] progress and "Deleting original file" bookkeeping as context', () => {
const msg = describeDownloadError(modifyChaptersFailure)
expect(msg).not.toContain('Deleting original file')
expect(msg).not.toMatch(/\[download\]/)
})
it('leaves a self-explanatory (non-postprocessing) error unchanged', () => {
const stderr = [
'[youtube] gHiZA4O5PQM: Downloading webpage',
'ERROR: [youtube] gHiZA4O5PQM: Video unavailable. This video is private'
].join('\n')
expect(describeDownloadError(stderr)).toBe(cleanError(stderr))
})
it('does not echo the headline back as its own context', () => {
const stderr = 'ERROR: Postprocessing: Conversion failed!'
expect(describeDownloadError(stderr)).toBe('Postprocessing: Conversion failed!')
})
it('caps an over-long context line', () => {
const longPath = 'x'.repeat(400)
const stderr = [
`[ModifyChapters] Re-encoding "${longPath}" with appropriate keyframes`,
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
const msg = describeDownloadError(stderr)
expect(msg).toContain('…')
// headline + separator + a single capped context line, nowhere near 400 chars.
expect(msg.length).toBeLessThan(300)
})
it('returns empty string for blank stderr (falls through to the caller fallback)', () => {
expect(describeDownloadError('')).toBe('')
})
})
describe('stderrDetail', () => {
it('keeps the full non-noise transcript for the Diagnostics "Show details" expander', () => {
const stderr = [
'[info] qkUTB65OfAc: Downloading 1 format(s): 137+251',
'[SponsorBlock] Found 1 segments in the SponsorBlock database',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
const detail = stderrDetail(stderr)
expect(detail).toContain('[SponsorBlock] Found 1 segments')
expect(detail).toContain('[ModifyChapters] Re-encoding')
expect(detail).toContain('ERROR: Postprocessing: Conversion failed!')
})
it('strips [download] progress ticks and "Deleting original file" bookkeeping', () => {
const stderr = [
'[download] 42.0% of 23.21MiB at 15.11MiB/s ETA 00:00',
'Deleting original file video.f251.webm (pass -k to keep)',
'[ModifyChapters] Re-encoding "video.mp4" with appropriate keyframes',
'ERROR: Postprocessing: Conversion failed!'
].join('\n')
const detail = stderrDetail(stderr)
expect(detail).not.toMatch(/\[download\]/)
expect(detail).not.toContain('Deleting original file')
})
it('returns undefined when there is nothing beyond a single headline', () => {
expect(stderrDetail('ERROR: Postprocessing: Conversion failed!')).toBeUndefined()
})
it('returns undefined for blank stderr', () => {
expect(stderrDetail('')).toBeUndefined()
})
})
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { pickRetryOptions } from '../src/renderer/src/store/retryOptions'
import { DEFAULT_DOWNLOAD_OPTIONS, type Source, type MediaProfile } from '../src/shared/ipc'
import type { ProfileDefaults } from '../src/shared/profileResolve'
const source = (over: Partial<Source> = {}): Source => ({
id: 'ac8e6608',
url: 'https://youtube.com/@NetworkChuck',
kind: 'channel',
title: 'NetworkChuck',
channel: 'NetworkChuck',
addedAt: 0,
itemCount: 0,
...over
})
// Global defaults with SponsorBlock OFF — the state after the user toggled it.
const defaults: ProfileDefaults = {
kind: 'video',
videoQuality: '1080p',
audioQuality: 'Best',
options: { ...DEFAULT_DOWNLOAD_OPTIONS, sponsorBlock: false },
outputTemplate: ''
}
describe('pickRetryOptions', () => {
it('re-resolves a source-tied item to the current global options (SponsorBlock now off)', () => {
const group = pickRetryOptions('ac8e6608:qkUTB65OfAc', [source()], [], defaults)
expect(group?.options?.sponsorBlock).toBe(false)
expect(group?.extraArgs).toBeUndefined()
expect(group?.collectionOutputTemplate).toBeUndefined()
})
it('returns null for a manual paste (no mediaItemId) so explicit options are kept', () => {
expect(pickRetryOptions(undefined, [source()], [], defaults)).toBeNull()
})
it('returns null when the source has since been deleted', () => {
expect(pickRetryOptions('deleted99:vid', [source()], [], defaults)).toBeNull()
})
it("prefers the source's Media Profile options over the globals", () => {
const profile: MediaProfile = {
id: 'p1',
name: 'Archive',
options: { ...DEFAULT_DOWNLOAD_OPTIONS, sponsorBlock: true, sponsorBlockMode: 'remove' },
extraArgs: '--no-mtime'
}
const group = pickRetryOptions(
'ac8e6608:vid',
[source({ profileId: 'p1' })],
[profile],
defaults
)
expect(group?.options?.sponsorBlock).toBe(true)
expect(group?.extraArgs).toBe('--no-mtime')
})
it('falls back to the globals when the source points at a dangling profile id', () => {
const group = pickRetryOptions('ac8e6608:vid', [source({ profileId: 'gone' })], [], defaults)
expect(group?.options?.sponsorBlock).toBe(false)
})
})
+1 -1
View File
@@ -122,7 +122,7 @@ describe('parseSettingsField (CC9 schema parity)', () => {
it('secrets: plain strings pass through (encryption happens in settings.ts)', () => {
expect(parseSettingsField('proxy', 'socks5://u:p@h:1')).toEqual(ok('socks5://u:p@h:1'))
expect(parseSettingsField('updateToken', ' tok ')).toEqual(ok('tok'))
expect(parseSettingsField('youtubePoToken', 'po_token_value')).toEqual(ok('po_token_value'))
expect(parseSettingsField('proxy', 42)).toEqual(fail)
})
-8
View File
@@ -33,16 +33,12 @@ class FakeResponse extends EventEmitter {
}
class FakeRequest extends EventEmitter {
headers: Record<string, string> = {}
ended = false
aborted = false
redirectsFollowed = 0
constructor(public url: string) {
super()
}
setHeader(k: string, v: string): void {
this.headers[k] = v
}
end(): void {
this.ended = true
}
@@ -72,10 +68,6 @@ vi.mock('electron', () => ({
shell: {}
}))
vi.mock('../src/main/settings', () => ({
getSettings: () => ({ updateToken: '' })
}))
import { downloadAppUpdate, cancelAppUpdate } from '../src/main/updater'
import type { WebContents } from 'electron'
+5
View File
@@ -175,6 +175,11 @@ describe('isValidErrorLogEntry', () => {
expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true)
})
it('accepts an optional detail string and rejects a non-string one', () => {
expect(isValidErrorLogEntry({ ...validError, detail: 'the full transcript' })).toBe(true)
expect(isValidErrorLogEntry({ ...validError, detail: 42 })).toBe(false)
})
it('rejects a missing error message', () => {
const { error: _error, ...noError } = validError
expect(isValidErrorLogEntry(noError)).toBe(false)