Compare commits
13 Commits
v0.6.1
...
94ef814fa7
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ef814fa7 | |||
| 78a168ca5c | |||
| 3b9dd6788d | |||
| ccee9b7d3b | |||
| af9c285864 | |||
| 87e16ca28e | |||
| f3e10b100d | |||
| 6db27b946f | |||
| eb53de2ea5 | |||
| cb25262a2d | |||
| 27cae28e12 | |||
| 6cb9c5ca47 | |||
| 5de4b40c1d |
@@ -10,6 +10,8 @@ dist
|
|||||||
|
|
||||||
# Secrets — never commit
|
# Secrets — never commit
|
||||||
.gitea-token
|
.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
|
.update-token
|
||||||
|
|
||||||
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
|
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
|
||||||
|
|||||||
@@ -28,13 +28,19 @@ files:
|
|||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
- '!*.tsbuildinfo'
|
- '!*.tsbuildinfo'
|
||||||
|
|
||||||
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
|
# yt-dlp.exe (+ optional aria2c.exe) ship outside the asar archive so they can be spawned
|
||||||
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
|
# 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:
|
extraResources:
|
||||||
- from: resources/bin
|
- from: resources/bin
|
||||||
to: bin
|
to: bin
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
|
- '!ffmpeg.exe'
|
||||||
|
- '!ffprobe.exe'
|
||||||
# The app icon, copied so the runtime (system tray) can load it at
|
# The app icon, copied so the runtime (system tray) can load it at
|
||||||
# <resources>/icon.ico — build/ itself isn't bundled into the app.
|
# <resources>/icon.ico — build/ itself isn't bundled into the app.
|
||||||
- from: build/icon.ico
|
- from: build/icon.ico
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@shared': resolve('src/shared')
|
'@shared': resolve('src/shared')
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.6.1",
|
"version": "0.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.6.1",
|
"version": "0.7.2",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.6.1",
|
"version": "0.7.2",
|
||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
|
|||||||
+25
-20
@@ -1,19 +1,21 @@
|
|||||||
# Bundled binaries
|
# Bundled binaries
|
||||||
|
|
||||||
AeroFetch spawns these from the **main process** and bundles them via
|
AeroFetch spawns these from the **main process**. `yt-dlp.exe` (and optional
|
||||||
electron-builder `extraResources` (they ship outside the asar archive). The
|
`aria2c.exe`) ship in the installer via electron-builder `extraResources` (outside the
|
||||||
`.exe` binaries are committed to the repo so a fresh clone builds and runs
|
asar archive) and are committed here so a fresh clone builds and runs.
|
||||||
without a manual download step.
|
|
||||||
|
|
||||||
Drop the three required Windows executables here before running `npm run dev` or
|
`ffmpeg.exe` + `ffprobe.exe` are **NOT shipped in the installer** — they're the bulk of
|
||||||
building; `aria2c.exe` is optional:
|
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/
|
resources/bin/
|
||||||
├── yt-dlp.exe
|
├── yt-dlp.exe (bundled + committed)
|
||||||
├── ffmpeg.exe
|
├── ffmpeg.exe (dev seed only — excluded from the installer)
|
||||||
├── ffprobe.exe
|
├── ffprobe.exe (dev seed only — excluded from the installer)
|
||||||
└── aria2c.exe (optional)
|
└── aria2c.exe (optional, bundled)
|
||||||
```
|
```
|
||||||
|
|
||||||
## yt-dlp.exe
|
## yt-dlp.exe
|
||||||
@@ -22,17 +24,20 @@ resources/bin/
|
|||||||
- Grab `yt-dlp.exe`.
|
- Grab `yt-dlp.exe`.
|
||||||
- Unlicense / public domain.
|
- Unlicense / public domain.
|
||||||
|
|
||||||
## ffmpeg.exe + ffprobe.exe
|
## ffmpeg.exe + ffprobe.exe (dev seed only)
|
||||||
|
|
||||||
- Use an **LGPL** build to keep licensing simple, e.g.
|
- The app downloads these on first run from the pinned upstream archive in
|
||||||
https://github.com/BtbN/FFmpeg-Builds/releases (pick a `*-lgpl-shared` or
|
`src/main/config.ts` (`FFMPEG_ARCHIVE_URL` + `FFMPEG_ARCHIVE_SHA256`). To bump the
|
||||||
`*-lgpl` win64 build) or https://www.gyan.dev/ffmpeg/builds/.
|
version, edit those constants — recompute the SHA-256 from the new zip — in their own
|
||||||
- Copy **both** `ffmpeg.exe` and `ffprobe.exe` here — they ship together in the
|
commit (the "pinned decisions" discipline).
|
||||||
same archive's `bin/` folder, and their versions must match. yt-dlp needs
|
- For local dev, drop **both** `ffmpeg.exe` and `ffprobe.exe` here — they come together in
|
||||||
`ffprobe.exe` to read media durations; without it, duration-dependent
|
the same archive's `bin/` folder and their versions must match. yt-dlp needs
|
||||||
post-processing fails with "ffprobe not found" (`--sponsorblock-remove`,
|
`ffprobe.exe` to read media durations; without it, duration-dependent post-processing
|
||||||
`--force-keyframes-at-cuts`, and `--split-chapters` all error out).
|
fails with "ffprobe not found" (`--sponsorblock-remove`, `--force-keyframes-at-cuts`,
|
||||||
- Ship the LGPL license text alongside the installer before release.
|
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)
|
## aria2c.exe (optional)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+41
-13
@@ -42,15 +42,20 @@ export function getAppIconImage(): NativeImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
* AeroFetch keeps its OWN writable copies of the tool binaries under userData/bin,
|
||||||
* from the read-only bundled seed in resources/bin. The managed copy is what
|
* separate from anything in the read-only bundle (resources/bin). This managed dir is
|
||||||
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
|
* what actually gets spawned, and it survives an app reinstall or portable re-extraction
|
||||||
* portable re-extraction — which only ever replace the bundled seed — can never
|
* (which only ever touch the bundle) — so a self-updated yt-dlp is never rolled back to
|
||||||
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
|
* a stale bundled version, and the first-run-downloaded ffmpeg/ffprobe (which the
|
||||||
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
|
* installer no longer ships at all) persist across updates.
|
||||||
*
|
*
|
||||||
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
* - yt-dlp.exe — seeded from the bundled copy, then self-updates (`--update-to`).
|
||||||
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
* 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 {
|
function getManagedBinDir(): string {
|
||||||
return join(app.getPath('userData'), 'bin')
|
return join(app.getPath('userData'), 'bin')
|
||||||
@@ -66,17 +71,40 @@ export function getYtdlpPath(): string {
|
|||||||
return join(getManagedBinDir(), 'yt-dlp.exe')
|
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 {
|
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
|
* The managed ffprobe.exe under userData/bin. yt-dlp finds it via --ffmpeg-location
|
||||||
* spawns it directly — but it must be present, or duration-aware post-processing
|
* (so the app never spawns it directly), but it must be present, or duration-aware
|
||||||
* (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters) fails. This
|
* post-processing (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters)
|
||||||
* accessor exists so startDownload can assert its presence up front.
|
* fails. startDownload / the setup gate assert its presence up front.
|
||||||
*/
|
*/
|
||||||
export function getFfprobePath(): string {
|
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')
|
return join(getBinDir(), 'ffprobe.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-24
@@ -4,33 +4,44 @@
|
|||||||
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
|
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
|
||||||
* fork's update source by editing these three values.
|
* fork's update source by editing these three values.
|
||||||
*
|
*
|
||||||
* The update repo is PRIVATE, so the release API + downloads require a token.
|
* The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous
|
||||||
* A build-time read-only token (BAKED_UPDATE_TOKEN below) lets a shipped client
|
* reads work and the updater sends no auth at all; there is no secret in the bundle
|
||||||
* reach it without the user configuring anything; a user-set updateToken in
|
* to leak. (A prior design split releases into a separate releases-only repo so the
|
||||||
* Settings still takes precedence. On a build with no token baked in, the update
|
* source could stay private; that's retired now that the source repo is public
|
||||||
* check simply reports that it couldn't reach the server.
|
* 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_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||||
export const UPDATE_OWNER = 'debont80'
|
export const UPDATE_OWNER = 'debont80'
|
||||||
export const UPDATE_REPO = 'AeroFetch'
|
export const UPDATE_REPO = 'AeroFetch'
|
||||||
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||||
|
|
||||||
/**
|
// --- ffmpeg dependency source ------------------------------------------------
|
||||||
* Read-only Gitea token baked in at build time so the auto-updater can read the
|
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
|
||||||
* PRIVATE release repo without each user pasting a token into Settings. Injected
|
// they're fetched once on first run into the managed userData/bin dir (ffmpegSetup.ts).
|
||||||
* 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
|
// Unlike the app updater — which pulls from our own host-pinned release server — this
|
||||||
* bundle, never in source or git.
|
// 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
|
||||||
* SECURITY: a shipped client is decompilable, so treat this as PUBLIC. It MUST be
|
// swapped or tampered upstream asset fails the check and nothing is installed. Bumping
|
||||||
* a token from a dedicated service account with read-only access to ONLY this repo
|
// ffmpeg is a deliberate edit to these three constants in its own commit (recompute the
|
||||||
* — never a personal/push-capable token. The user's own updateToken overrides it
|
// hash from the new zip) — the same "pinned decisions" discipline the app version follows.
|
||||||
* (see authHeader in updater.ts), so a private fork can point elsewhere.
|
//
|
||||||
*
|
// Licensing note: gyan's "essentials" is a GPL build. That's fine here because AeroFetch
|
||||||
* `__AEROFETCH_UPDATE_TOKEN__` is a `define`-replaced literal in built code; under
|
// never redistributes it — it invokes ffmpeg.exe as a separate process (as yt-dlp does)
|
||||||
* plain vitest (no define) the identifier is undeclared, so the `typeof` guard
|
// and the user fetches the binary from upstream themselves. Swapping to an LGPL build
|
||||||
* keeps this from throwing and falls back to empty (no token).
|
// (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.
|
||||||
declare const __AEROFETCH_UPDATE_TOKEN__: string
|
export const FFMPEG_VERSION = '8.1.2'
|
||||||
export const BAKED_UPDATE_TOKEN: string =
|
export const FFMPEG_ARCHIVE_URL = `https://github.com/GyanD/codexffmpeg/releases/download/${FFMPEG_VERSION}/ffmpeg-${FFMPEG_VERSION}-essentials_build.zip`
|
||||||
typeof __AEROFETCH_UPDATE_TOKEN__ === 'string' ? __AEROFETCH_UPDATE_TOKEN__ : ''
|
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
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
export const VERSION_TIMEOUT_MS = 15_000
|
export const VERSION_TIMEOUT_MS = 15_000
|
||||||
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
|
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
|
||||||
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
|
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). */
|
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
|
||||||
export const META_PROBE_TIMEOUT_MS = 30_000
|
export const META_PROBE_TIMEOUT_MS = 30_000
|
||||||
/** Full format/metadata probe (`-J`) for the download bar. */
|
/** Full format/metadata probe (`-J`) for the download bar. */
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* This module is deliberately free of any electron / node-runtime dependency
|
* This module is deliberately free of any electron / node-runtime dependency
|
||||||
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
|
* (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.
|
* 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
|
* `buildArgs` takes the `binDir` as a parameter (used only for `--ffmpeg-location`)
|
||||||
* resolving it itself; the caller in download.ts passes `getBinDir()`.
|
* 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'
|
import { join } from 'path'
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
|
|||||||
(e.kind === 'video' || e.kind === 'audio') &&
|
(e.kind === 'video' || e.kind === 'audio') &&
|
||||||
typeof e.error === 'string' &&
|
typeof e.error === 'string' &&
|
||||||
typeof e.occurredAt === 'number' &&
|
typeof e.occurredAt === 'number' &&
|
||||||
isOptionalString(e.title)
|
isOptionalString(e.title) &&
|
||||||
|
isOptionalString(e.detail)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-7
@@ -5,7 +5,7 @@ import { join, parse } from 'path'
|
|||||||
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||||
import {
|
import {
|
||||||
getYtdlpPath,
|
getYtdlpPath,
|
||||||
getBinDir,
|
getFfmpegDir,
|
||||||
getAria2cPath,
|
getAria2cPath,
|
||||||
getFfmpegPath,
|
getFfmpegPath,
|
||||||
getFfprobePath,
|
getFfprobePath,
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
FILEPATH_MARKER,
|
FILEPATH_MARKER,
|
||||||
DEST_MARKER
|
DEST_MARKER
|
||||||
} from './core/buildArgs'
|
} from './core/buildArgs'
|
||||||
import { cleanError } from './log'
|
import { describeDownloadError, stderrDetail } from './log'
|
||||||
import { addErrorLog } from './errorlog'
|
import { addErrorLog } from './errorlog'
|
||||||
import {
|
import {
|
||||||
STALL_TIMEOUT_MS,
|
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)
|
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
|
// L136: incognito downloads are never recorded — not even a failure entry, which
|
||||||
// would persist the title + URL in the user-facing diagnostics log.
|
// would persist the title + URL in the user-facing diagnostics log.
|
||||||
if (opts.incognito) return
|
if (opts.incognito) return
|
||||||
@@ -127,6 +132,7 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
|
|||||||
url: opts.url,
|
url: opts.url,
|
||||||
kind: opts.kind,
|
kind: opts.kind,
|
||||||
error,
|
error,
|
||||||
|
detail,
|
||||||
occurredAt: Date.now()
|
occurredAt: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -237,7 +243,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
|||||||
youtubePoToken: settings.youtubePoToken
|
youtubePoToken: settings.youtubePoToken
|
||||||
}
|
}
|
||||||
const extraArgs = resolveExtraArgs(opts, settings)
|
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. */
|
/** 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(getFfmpegPath())) missingBins.push('ffmpeg.exe')
|
||||||
if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
|
if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
|
||||||
if (missingBins.length > 0) {
|
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 {
|
return {
|
||||||
ok: false,
|
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,
|
// 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
|
opts.incognito
|
||||||
)
|
)
|
||||||
} else {
|
} 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 })
|
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||||
logFailure(opts, getTitle(), msg)
|
logFailure(opts, getTitle(), msg, stderrDetail(stderrTail))
|
||||||
notify(
|
notify(
|
||||||
wc,
|
wc,
|
||||||
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
|
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ async function readToolVersion(path: string): Promise<string | null> {
|
|||||||
return m?.[1] ?? 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> {
|
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
|
||||||
const [ffmpeg, ffprobe] = await Promise.all([
|
const [ffmpeg, ffprobe] = await Promise.all([
|
||||||
readToolVersion(getFfmpegPath()),
|
readToolVersion(getFfmpegPath()),
|
||||||
|
|||||||
@@ -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." }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getSystemThemeInfo
|
getSystemThemeInfo
|
||||||
} from './ipc'
|
} from './ipc'
|
||||||
import { runStartupYtdlpAutoUpdate } from './ytdlp'
|
import { runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||||
|
import { ensureManagedFfmpeg } from './ffmpegSetup'
|
||||||
import { hasActiveDownloads } from './download'
|
import { hasActiveDownloads } from './download'
|
||||||
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
|
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
|
||||||
import { ensureMediaDirs } from './paths'
|
import { ensureMediaDirs } from './paths'
|
||||||
@@ -259,6 +260,15 @@ if (isPrimaryInstance) {
|
|||||||
// link it carried and hand it to the window we already have.
|
// link it carried and hand it to the window we already have.
|
||||||
app.on('second-instance', (_e, argv) => {
|
app.on('second-instance', (_e, argv) => {
|
||||||
if (!mainWindow) return
|
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)
|
focusWindow(mainWindow)
|
||||||
const incomingUrl = extractIncomingUrl(argv)
|
const incomingUrl = extractIncomingUrl(argv)
|
||||||
if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl)
|
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
|
// 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
|
// 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
|
// silently cause YouTube 403s. Best-effort and off the critical path — it
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ import {
|
|||||||
import { PAGE_BACKGROUND } from '@shared/theme'
|
import { PAGE_BACKGROUND } from '@shared/theme'
|
||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
import { getFfmpegVersions } from './ffmpeg'
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
|
import {
|
||||||
|
getFfmpegSetupStatus,
|
||||||
|
downloadFfmpeg,
|
||||||
|
cancelFfmpegDownload,
|
||||||
|
locateFfmpegManually
|
||||||
|
} from './ffmpegSetup'
|
||||||
|
import { getAria2cPath } from './binaries'
|
||||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||||
@@ -114,6 +121,21 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
|||||||
|
|
||||||
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
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.probe, (_e, url: string) => probeMedia(url))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
|
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,3 +12,61 @@ export function cleanError(stderr: string): string {
|
|||||||
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
|
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
|
||||||
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ function getStore(): Store<Settings> {
|
|||||||
let cachedSettings: Settings | null = null
|
let cachedSettings: Settings | null = null
|
||||||
|
|
||||||
// --- Credential encryption at rest ------------------------------------------
|
// --- Credential encryption at rest ------------------------------------------
|
||||||
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
|
// proxy / youtubePoToken can carry secrets (a proxy password, API tokens).
|
||||||
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
|
// They're stored encrypted via Electron safeStorage (DPAPI on Windows) so a
|
||||||
// so a leaked settings.json doesn't expose them. They're decrypted before
|
// leaked settings.json doesn't expose them. They're decrypted before reaching
|
||||||
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
|
// 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.
|
// 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
|
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
|
||||||
// legacy plaintext value (written before encryption existed, or while safeStorage
|
// legacy plaintext value (written before encryption existed, or while safeStorage
|
||||||
|
|||||||
@@ -102,9 +102,7 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings
|
|||||||
youtubePlayerClient: z.string().transform((v) => v.trim()),
|
youtubePlayerClient: z.string().transform((v) => v.trim()),
|
||||||
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
|
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
|
||||||
proxy: z.string(),
|
proxy: z.string(),
|
||||||
youtubePoToken: z.string(),
|
youtubePoToken: z.string()
|
||||||
// An access token has no spaces; trim before encrypting (like proxy creds).
|
|
||||||
updateToken: z.string().transform((v) => v.trim())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+18
-182
@@ -1,34 +1,9 @@
|
|||||||
import { app, net, shell, type WebContents } from 'electron'
|
import { app, net, shell, type WebContents } from 'electron'
|
||||||
import { createWriteStream, type WriteStream } from 'fs'
|
import { stat } from 'fs/promises'
|
||||||
import { stat, unlink } from 'fs/promises'
|
|
||||||
import { join, normalize, dirname } from 'path'
|
import { join, normalize, dirname } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { UPDATE_HOST, RELEASE_API } from './config'
|
||||||
import { getSettings } from './settings'
|
import { streamVerifiedFile } from './lib/verifiedDownload'
|
||||||
import { UPDATE_HOST, RELEASE_API, BAKED_UPDATE_TOKEN } from './config'
|
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
|
||||||
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}` } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update source -----------------------------------------------------------
|
// --- Update source -----------------------------------------------------------
|
||||||
// The Gitea repo whose Releases host the AeroFetch installers lives in
|
// 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)
|
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(RELEASE_API, {
|
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
|
// The API lives at an exact pinned URL and answers 200 directly. Refusing
|
||||||
// redirects keeps an authenticated check from ever forwarding the token to
|
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to
|
||||||
// another origin; a stray redirect fails safe into the catch below.
|
// a sign-in page) fails safe into the catch below rather than being followed.
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
signal: controller.signal
|
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
|
// 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
|
// cleared on settle. During the pre-download checksum phase it just flags the
|
||||||
// cancel; during the download phase it routes through `finish()` (abort the
|
// cancel; during the download phase it routes through `finish()` (abort the
|
||||||
@@ -238,7 +210,6 @@ function fetchTrustedText(
|
|||||||
resolve(r)
|
resolve(r)
|
||||||
}
|
}
|
||||||
const request = net.request({ url, redirect: 'manual' })
|
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)
|
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||||
request.on('redirect', (_s, _m, redirectUrl) => {
|
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||||
if (!isTrustedDownloadUrl(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. */
|
/** Stream the installer to a temp file, pushing progress events to the renderer. */
|
||||||
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
|
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
|
||||||
if (!isTrustedDownloadUrl(url)) {
|
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 —
|
// The streaming itself — redirects, hashing, timeout, truncation, teardown —
|
||||||
// lives in streamInstallerToFile (CL4). This function stays a linear
|
// lives in streamVerifiedFile (lib/verifiedDownload, CL4). This function stays a
|
||||||
// pre-flight: trust-check → checksum resolution → stream → done. The cancel
|
// 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:
|
// 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
|
// ownership moves from the checksum-phase earlyCancel above to the stream's
|
||||||
// cancel, and is released only if this download still owns the slot.
|
// cancel, and is released only if this download still owns the slot.
|
||||||
return streamInstallerToFile({
|
return streamVerifiedFile({
|
||||||
url,
|
url,
|
||||||
filePath,
|
filePath,
|
||||||
expectedSha,
|
expectedSha,
|
||||||
|
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
|
||||||
|
isTrusted: isTrustedDownloadUrl,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, 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) => {
|
onSettled: (cancel) => {
|
||||||
if (cancelActiveDownload === cancel) cancelActiveDownload = null
|
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
@@ -9,6 +9,9 @@ import {
|
|||||||
type YtdlpUpdateResult,
|
type YtdlpUpdateResult,
|
||||||
type YtdlpAutoUpdateStatus,
|
type YtdlpAutoUpdateStatus,
|
||||||
type FfmpegVersionResult,
|
type FfmpegVersionResult,
|
||||||
|
type FfmpegSetupStatus,
|
||||||
|
type FfmpegSetupProgress,
|
||||||
|
type FfmpegSetupResult,
|
||||||
type ProbeResult,
|
type ProbeResult,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
@@ -64,10 +67,34 @@ const api = {
|
|||||||
|
|
||||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
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> =>
|
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
|
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),
|
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
|
||||||
|
|
||||||
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
||||||
@@ -252,6 +279,14 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
|
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) ---
|
// --- Built-in yt-dlp terminal (Phase N) ---
|
||||||
|
|
||||||
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
|
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LiveRegion } from './components/LiveRegion'
|
|||||||
import { Toaster } from './components/ui/Toaster'
|
import { Toaster } from './components/ui/Toaster'
|
||||||
import { getTheme, pageBackground } from './lib/theme'
|
import { getTheme, pageBackground } from './lib/theme'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
|
import { useSetup } from './store/setup'
|
||||||
import { useNav } from './store/nav'
|
import { useNav } from './store/nav'
|
||||||
import { useDownloads } from './store/downloads'
|
import { useDownloads } from './store/downloads'
|
||||||
// Eagerly load the sources store for its startup side-effects (load persisted
|
// Eagerly load the sources store for its startup side-effects (load persisted
|
||||||
@@ -130,6 +131,11 @@ function App(): React.JSX.Element {
|
|||||||
useDownloads.getState().hydrate()
|
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
|
// 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
|
// (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.
|
// 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.
|
// by a one-frame flash of the (default-false) onboarding state.
|
||||||
const loaded = useSettings((s) => s.loaded)
|
const loaded = useSettings((s) => s.loaded)
|
||||||
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
|
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 (
|
return (
|
||||||
<FluentProvider
|
<FluentProvider
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
type Settings,
|
type Settings,
|
||||||
type CommandTemplate,
|
type CommandTemplate,
|
||||||
type MediaProfile
|
type MediaProfile,
|
||||||
|
type FfmpegSetupProgress
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +51,11 @@ let mockProfiles: MediaProfile[] = [
|
|||||||
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
|
{ 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 = {
|
export const mockApi: Api = {
|
||||||
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
||||||
checkForAppUpdate: async () => {
|
checkForAppUpdate: async () => {
|
||||||
@@ -75,9 +81,46 @@ export const mockApi: Api = {
|
|||||||
onAppUpdateProgress: () => () => {},
|
onAppUpdateProgress: () => () => {},
|
||||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
getFfmpegVersions: async () => ({
|
getFfmpegVersions: async () => ({
|
||||||
ffmpeg: '7.1-full_build (UI preview mock)',
|
ffmpeg: '8.1.2-essentials_build (UI preview mock)',
|
||||||
ffprobe: '7.1-full_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) => {
|
probe: async (url: string) => {
|
||||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||||||
@@ -167,6 +210,9 @@ export const mockApi: Api = {
|
|||||||
mockCookiesSavedAt = Date.now()
|
mockCookiesSavedAt = Date.now()
|
||||||
return { ok: true, cookieCount: 14 }
|
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 () =>
|
cookiesStatus: async () =>
|
||||||
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
|
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
|
||||||
cookiesClear: async () => {
|
cookiesClear: async () => {
|
||||||
@@ -225,6 +271,7 @@ export const mockApi: Api = {
|
|||||||
onSystemThemeUpdate: () => () => {},
|
onSystemThemeUpdate: () => () => {},
|
||||||
openHighContrastSettings: async () => {},
|
openHighContrastSettings: async () => {},
|
||||||
onExternalUrl: () => () => {},
|
onExternalUrl: () => () => {},
|
||||||
|
onRunWatchedSync: () => () => {},
|
||||||
// Media-manager sources — a seeded channel so the (Phase H) Library view has
|
// Media-manager sources — a seeded channel so the (Phase H) Library view has
|
||||||
// demo data in this browser-only preview.
|
// demo data in this browser-only preview.
|
||||||
listSources: async () => [
|
listSources: async () => [
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { logError } from '../reportError'
|
|||||||
import { revealFile } from '../reveal'
|
import { revealFile } from '../reveal'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
import { useHistory } from './history'
|
import { useHistory } from './history'
|
||||||
|
import { useSources } from './sources'
|
||||||
|
import { useProfiles } from './profiles'
|
||||||
import { emit, on } from './coordinator'
|
import { emit, on } from './coordinator'
|
||||||
|
import { pickRetryOptions } from './retryOptions'
|
||||||
import { RESOLVING, buildItem } from './downloadItem'
|
import { RESOLVING, buildItem } from './downloadItem'
|
||||||
import { fromPersisted, persistableItems } from './queuePersist'
|
import { fromPersisted, persistableItems } from './queuePersist'
|
||||||
import { seed } from './downloadSeed'
|
import { seed } from './downloadSeed'
|
||||||
@@ -27,6 +30,39 @@ export type { MediaKind, ChosenFormat, DownloadItem, DownloadStatus, AddOptions,
|
|||||||
// we've loaded it back.
|
// we've loaded it back.
|
||||||
let queueHydrated = false
|
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
|
// Side-effects to run exactly once when a download completes: record it to
|
||||||
// history (unless private) and mark its source MediaItem downloaded. Shared by
|
// 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
|
// 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) => {
|
retry: (id) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
items: s.items.map((i) =>
|
items: s.items.map((i) => (i.id === id ? requeueForRetry(i) : i))
|
||||||
i.id === id
|
|
||||||
? {
|
|
||||||
...i,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0,
|
|
||||||
error: undefined,
|
|
||||||
speedBytesPerSec: undefined,
|
|
||||||
etaSeconds: undefined
|
|
||||||
}
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|
||||||
retryAll: () => {
|
retryAll: () => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
items: s.items.map((i) =>
|
items: s.items.map((i) => (i.status === 'error' ? requeueForRetry(i) : i))
|
||||||
i.status === 'error'
|
|
||||||
? {
|
|
||||||
...i,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0,
|
|
||||||
error: undefined,
|
|
||||||
speedBytesPerSec: undefined,
|
|
||||||
etaSeconds: undefined
|
|
||||||
}
|
|
||||||
: i
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ const seed: ErrorLogEntry[] = PREVIEW
|
|||||||
kind: 'video',
|
kind: 'video',
|
||||||
error: '[youtube] err0r: Private video. Sign in if you have access.',
|
error: '[youtube] err0r: Private video. Sign in if you have access.',
|
||||||
occurredAt: Date.now() - 1000 * 60 * 12
|
occurredAt: Date.now() - 1000 * 60 * 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'e2',
|
||||||
|
title: 'you’re 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -39,7 +39,8 @@ const seedSources: Source[] =
|
|||||||
channel: 'DevChannel',
|
channel: 'DevChannel',
|
||||||
addedAt: Date.now() - 86_400_000,
|
addedAt: Date.now() - 86_400_000,
|
||||||
lastIndexedAt: Date.now() - 3_600_000,
|
lastIndexedAt: Date.now() - 3_600_000,
|
||||||
itemCount: 9
|
itemCount: 9,
|
||||||
|
watched: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'src-mix',
|
id: 'src-mix',
|
||||||
@@ -99,6 +100,11 @@ interface SourcesState {
|
|||||||
* enqueued.
|
* enqueued.
|
||||||
*/
|
*/
|
||||||
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
|
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) */
|
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
||||||
markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
|
markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
|
||||||
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
/** 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
|
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) => {
|
markDownloaded: (itemId, filePath, quality) => {
|
||||||
// Which loaded source holds this item — also the reconcile key, so overlapping
|
// Which loaded source holds this item — also the reconcile key, so overlapping
|
||||||
// completions in different sources never suppress each other's response.
|
// completions in different sources never suppress each other's response.
|
||||||
@@ -469,13 +485,16 @@ if (!PREVIEW) {
|
|||||||
.listSources()
|
.listSources()
|
||||||
.then((sources) => {
|
.then((sources) => {
|
||||||
useSources.setState({ sources })
|
useSources.setState({ sources })
|
||||||
// If any source is watched, kick off a sync shortly after launch (covers the
|
// If any source is watched, kick off a sync shortly after launch (covers a fresh
|
||||||
// scheduled `--sync` launch and a normal launch alike).
|
// `--sync` launch and a normal launch alike).
|
||||||
if (sources.some((s) => s.watched)) {
|
if (sources.some((s) => s.watched)) {
|
||||||
setTimeout(() => useSources.getState().syncWatched(), 1500)
|
setTimeout(() => useSources.getState().syncWatched(), 1500)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(logError('startup listSources'))
|
.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) => {
|
window.api.onIndexProgress((p: IndexProgress) => {
|
||||||
useSources.setState({
|
useSources.setState({
|
||||||
indexing: {
|
indexing: {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Select } from '../components/Select'
|
|||||||
import { useNav } from '../store/nav'
|
import { useNav } from '../store/nav'
|
||||||
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
|
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
|
import { selectUndownloaded } from '../store/librarySelect'
|
||||||
import { thumbUrl } from '../lib/thumb'
|
import { thumbUrl } from '../lib/thumb'
|
||||||
import { relTime } from '../lib/datetime'
|
import { relTime } from '../lib/datetime'
|
||||||
import { MediaThumb } from '../components/MediaThumb'
|
import { MediaThumb } from '../components/MediaThumb'
|
||||||
@@ -109,13 +110,27 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
overflow: 'hidden'
|
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: {
|
cardHead: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
// List-row padding tier (UI3): cozy on both axes (was 12/14).
|
// List-row padding tier (UI3): cozy on both axes (was 12/14).
|
||||||
padding: SPACE.cozy,
|
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',
|
border: 'none',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
|
// 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 cancelIndexing = useSources((s) => s.cancelIndexing)
|
||||||
const removeSource = useSources((s) => s.removeSource)
|
const removeSource = useSources((s) => s.removeSource)
|
||||||
const enqueueItems = useSources((s) => s.enqueueItems)
|
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||||
|
const ensureSourceItems = useSources((s) => s.ensureSourceItems)
|
||||||
const setWatched = useSources((s) => s.setWatched)
|
const setWatched = useSources((s) => s.setWatched)
|
||||||
const setProfile = useSources((s) => s.setProfile)
|
const setProfile = useSources((s) => s.setProfile)
|
||||||
const profiles = useProfiles((s) => s.profiles)
|
const profiles = useProfiles((s) => s.profiles)
|
||||||
@@ -279,6 +295,10 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = 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
|
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
||||||
// an expanded source shows just its group headers until one is opened.
|
// an expanded source shows just its group headers until one is opened.
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||||
@@ -355,7 +375,11 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const effStatus = (it: MediaItem): ItemStatus =>
|
const effStatus = (it: MediaItem): ItemStatus =>
|
||||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
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:
|
// 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/
|
// 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'}.`)
|
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,
|
// Queue every actionable (pending/failed/canceled) video in one playlist group,
|
||||||
// so "download this whole playlist" is a single click.
|
// so "download this whole playlist" is a single click.
|
||||||
function downloadGroup(groupItems: MediaItem[]): void {
|
function downloadGroup(groupItems: MediaItem[]): void {
|
||||||
@@ -644,6 +692,19 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
source={src}
|
source={src}
|
||||||
expanded={selectedSourceId === src.id}
|
expanded={selectedSourceId === src.id}
|
||||||
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : 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.detail}>
|
||||||
<div className={styles.actionRow}>
|
<div className={styles.actionRow}>
|
||||||
@@ -802,48 +863,55 @@ function SourceCard({
|
|||||||
source,
|
source,
|
||||||
expanded,
|
expanded,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
|
rowAction,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
styles: ReturnType<typeof useStyles>
|
styles: ReturnType<typeof useStyles>
|
||||||
source: Source
|
source: Source
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
onToggleExpand: () => void
|
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
|
children: React.ReactNode
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const focus = useFocusStyles()
|
const focus = useFocusStyles()
|
||||||
const text = useTextStyles()
|
const text = useTextStyles()
|
||||||
return (
|
return (
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<button
|
<div className={styles.cardHeadRow}>
|
||||||
type="button"
|
<button
|
||||||
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
type="button"
|
||||||
onClick={onToggleExpand}
|
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||||
aria-expanded={expanded}
|
onClick={onToggleExpand}
|
||||||
aria-label={source.title}
|
aria-expanded={expanded}
|
||||||
>
|
aria-label={source.title}
|
||||||
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
>
|
||||||
<div className={styles.srcIcon}>
|
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
<VideoClipMultipleRegular />
|
<div className={styles.srcIcon}>
|
||||||
</div>
|
<VideoClipMultipleRegular />
|
||||||
<div className={styles.srcMeta}>
|
</div>
|
||||||
<span className={styles.srcTitleRow}>
|
<div className={styles.srcMeta}>
|
||||||
<Text className={styles.srcTitle}>{source.title}</Text>
|
<span className={styles.srcTitleRow}>
|
||||||
{source.watched && (
|
<Text className={styles.srcTitle}>{source.title}</Text>
|
||||||
<span className={styles.watchBadge}>
|
{source.watched && (
|
||||||
<AlertRegular fontSize={11} /> Watching
|
<span className={styles.watchBadge}>
|
||||||
</span>
|
<AlertRegular fontSize={11} /> Watching
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
<Caption1 className={text.muted}>
|
</span>
|
||||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
<Caption1 className={text.muted}>
|
||||||
{META_SEP}
|
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
||||||
{source.itemCount} videos
|
{META_SEP}
|
||||||
{source.channel && source.channel !== source.title
|
{source.itemCount} videos
|
||||||
? `${META_SEP}${source.channel}`
|
{source.channel && source.channel !== source.title
|
||||||
: ''}
|
? `${META_SEP}${source.channel}`
|
||||||
</Caption1>
|
: ''}
|
||||||
</div>
|
</Caption1>
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
{rowAction && <div className={styles.cardHeadAction}>{rowAction}</div>}
|
||||||
|
</div>
|
||||||
{expanded && children}
|
{expanded && children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Caption1,
|
Caption1,
|
||||||
Field,
|
Field,
|
||||||
Button,
|
Button,
|
||||||
|
Spinner,
|
||||||
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
mergeClasses,
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -12,6 +14,9 @@ import {
|
|||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
|
ArrowDownloadRegular,
|
||||||
|
DismissRegular,
|
||||||
|
CheckmarkCircleFilled,
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
@@ -22,6 +27,8 @@ import {
|
|||||||
KeyboardRegular
|
KeyboardRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useSetup } from '../store/setup'
|
||||||
|
import { useErrorTextStyles } from '../components/ui/errorText'
|
||||||
import { SPACE, ICON } from '../components/ui/tokens'
|
import { SPACE, ICON } from '../components/ui/tokens'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@@ -99,6 +106,31 @@ const useStyles = makeStyles({
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
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: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -138,10 +170,88 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
|
|
||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
const videoFolder = useSettings((s) => s.videoFolder)
|
const videoFolder = useSettings((s) => s.videoFolder)
|
||||||
const audioFolder = useSettings((s) => s.audioFolder)
|
const audioFolder = useSettings((s) => s.audioFolder)
|
||||||
const chooseFolder = useSettings((s) => s.chooseFolder)
|
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't bundled — download it
|
||||||
|
once (about 105 MB) and it'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 (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -150,69 +260,85 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
<div className={styles.mark}>
|
<div className={styles.mark}>
|
||||||
<ArrowDownloadFilled />
|
<ArrowDownloadFilled />
|
||||||
</div>
|
</div>
|
||||||
<Title2 as="h1">Welcome to AeroFetch</Title2>
|
<Title2 as="h1">{isFresh ? 'Welcome to AeroFetch' : 'Finish setup'}</Title2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Body1>
|
{isFresh ? (
|
||||||
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
|
<Body1>
|
||||||
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
|
||||||
</Body1>
|
audio. yt-dlp is bundled; ffmpeg is set up once below.
|
||||||
|
</Body1>
|
||||||
|
) : (
|
||||||
|
<Body1>
|
||||||
|
AeroFetch needs ffmpeg and ffprobe to process downloads. They aren't bundled — set
|
||||||
|
them up once to continue.
|
||||||
|
</Body1>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label="Where downloads go">
|
{isFresh && (
|
||||||
<Caption1 className={styles.folderNote}>
|
<Field label="Where downloads go">
|
||||||
Videos and audio save to your Documents folders by default. Pick a different folder now,
|
<Caption1 className={styles.folderNote}>
|
||||||
or change it any time in Settings.
|
Videos and audio save to your Documents folders by default. Pick a different folder
|
||||||
</Caption1>
|
now, or change it any time in Settings.
|
||||||
<div className={styles.folderRow}>
|
</Caption1>
|
||||||
<VideoClipRegular className={styles.folderIcon} />
|
<div className={styles.folderRow}>
|
||||||
<div className={styles.folderCol}>
|
<VideoClipRegular className={styles.folderIcon} />
|
||||||
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
<div className={styles.folderCol}>
|
||||||
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
|
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
||||||
{videoFolder || 'Documents\\Video'}
|
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
|
||||||
</Caption1>
|
{videoFolder || 'Documents\\Video'}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FolderRegular />}
|
||||||
|
onClick={() => chooseFolder('videoFolder')}
|
||||||
|
>
|
||||||
|
Choose…
|
||||||
|
</Button>
|
||||||
</div>
|
</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
|
<Button
|
||||||
size="small"
|
appearance="primary"
|
||||||
icon={<FolderRegular />}
|
icon={<RocketRegular />}
|
||||||
onClick={() => chooseFolder('videoFolder')}
|
disabled={ready !== true}
|
||||||
|
onClick={() => update({ hasCompletedOnboarding: true })}
|
||||||
>
|
>
|
||||||
Choose…
|
Get started
|
||||||
</Button>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import {
|
|||||||
Caption1,
|
Caption1,
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
ProgressBar,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonItem
|
SkeletonItem
|
||||||
} from '@fluentui/react-components'
|
} 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 { type YtdlpUpdateChannel } from '@shared/ipc'
|
||||||
import { useSettings } from '../../store/settings'
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useSetup } from '../../store/setup'
|
||||||
import { Select } from '../../components/Select'
|
import { Select } from '../../components/Select'
|
||||||
import { useErrorTextStyles } from '../../components/ui/errorText'
|
import { useErrorTextStyles } from '../../components/ui/errorText'
|
||||||
import { useSettingsStyles } from './settingsStyles'
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
@@ -43,6 +51,25 @@ export function AboutCard(): React.JSX.Element {
|
|||||||
refreshFfmpeg
|
refreshFfmpeg
|
||||||
} = useAboutCard()
|
} = 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 (
|
return (
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
@@ -50,8 +77,8 @@ export function AboutCard(): React.JSX.Element {
|
|||||||
<Subtitle2 as="h3">About</Subtitle2>
|
<Subtitle2 as="h3">About</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
AeroFetch is a generic frontend for yt-dlp. It manages its own copy of yt-dlp (keeping it up
|
||||||
yt-dlp, keeping it up to date automatically.
|
to date automatically) and downloads ffmpeg on first run.
|
||||||
</Caption1>
|
</Caption1>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
|
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
|
||||||
@@ -98,6 +125,41 @@ export function AboutCard(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{ytdlpLastUpdateCheck > 0 && (
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
|
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
|
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 { useErrorLog } from '../../store/errorlog'
|
||||||
import { useErrorTextStyles } from '../../components/ui/errorText'
|
import { useErrorTextStyles } from '../../components/ui/errorText'
|
||||||
import { EmptyState } from '../../components/ui/EmptyState'
|
import { EmptyState } from '../../components/ui/EmptyState'
|
||||||
@@ -13,13 +19,25 @@ export function DiagnosticsCard(): React.JSX.Element {
|
|||||||
const clearErrorLog = useErrorLog((s) => s.clear)
|
const clearErrorLog = useErrorLog((s) => s.clear)
|
||||||
|
|
||||||
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
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 {
|
function copyErrorReport(): void {
|
||||||
// The button is disabled when there are no entries, so `report` is always
|
// 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).
|
// non-empty here -- no need for an unreachable empty-report fallback (L159).
|
||||||
const report = errorEntries
|
const report = errorEntries
|
||||||
.map((e) =>
|
.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')
|
.join('\n\n---\n\n')
|
||||||
navigator.clipboard.writeText(report).catch(() => {})
|
navigator.clipboard.writeText(report).catch(() => {})
|
||||||
@@ -78,17 +96,33 @@ export function DiagnosticsCard(): React.JSX.Element {
|
|||||||
<EmptyState compact message="No errors yet." />
|
<EmptyState compact message="No errors yet." />
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.errorList}>
|
<div className={styles.errorList}>
|
||||||
{errorEntries.slice(0, 20).map((e) => (
|
{errorEntries.slice(0, 20).map((e) => {
|
||||||
<div key={e.id} className={styles.errorRow}>
|
const isExpanded = expanded.has(e.id)
|
||||||
<div className={styles.errorRowHeader}>
|
return (
|
||||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
<div key={e.id} className={styles.errorRow}>
|
||||||
<Caption1 className={styles.hint}>
|
<div className={styles.errorRowHeader}>
|
||||||
{new Date(e.occurredAt).toLocaleString()}
|
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||||
</Caption1>
|
<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>
|
</div>
|
||||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
{errorEntries.length > 20 && (
|
{errorEntries.length > 20 && (
|
||||||
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
|
<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 youtubePoToken = useSettings((s) => s.youtubePoToken)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
// View-model hook owns the PO-token minting state + IPC (L93/CC13).
|
// View-model hook owns the PO-token minting state + IPC (L93/CC13), plus the
|
||||||
const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
|
// 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 (
|
return (
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -114,11 +118,21 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
<Field
|
<Field
|
||||||
label="Use aria2c downloader"
|
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."
|
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>
|
</Field>
|
||||||
|
|
||||||
{useAria2c && (
|
{useAria2c && !aria2cMissing && (
|
||||||
<Field
|
<Field
|
||||||
label="aria2c connections"
|
label="aria2c connections"
|
||||||
hint="Parallel connections per download (1–16). More can be faster on throttled connections; fewer is gentler on servers."
|
hint="Parallel connections per download (1–16). More can be faster on throttled connections; fewer is gentler on servers."
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Subtitle2,
|
Subtitle2,
|
||||||
@@ -16,7 +14,6 @@ import {
|
|||||||
OpenRegular,
|
OpenRegular,
|
||||||
DismissRegular
|
DismissRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../../store/settings'
|
|
||||||
import { useErrorTextStyles } from '../../components/ui/errorText'
|
import { useErrorTextStyles } from '../../components/ui/errorText'
|
||||||
import { useSettingsStyles } from './settingsStyles'
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
|
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
|
||||||
@@ -24,8 +21,6 @@ import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
|
|||||||
export function SoftwareUpdateCard(): React.JSX.Element {
|
export function SoftwareUpdateCard(): React.JSX.Element {
|
||||||
const styles = useSettingsStyles()
|
const styles = useSettingsStyles()
|
||||||
const errText = useErrorTextStyles()
|
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).
|
// View-model hook owns the check → download → install orchestration (L93/CC13).
|
||||||
const {
|
const {
|
||||||
@@ -63,21 +58,6 @@ export function SoftwareUpdateCard(): React.JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{appUpd?.ok && appUpd.available && (
|
||||||
<>
|
<>
|
||||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||||
|
|||||||
@@ -113,5 +113,21 @@ export const useSettingsStyles = makeStyles({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
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)
|
.then(setVersion)
|
||||||
.catch(logError('getYtdlpVersion'))
|
.catch(logError('getYtdlpVersion'))
|
||||||
.finally(() => setChecking(false))
|
.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.
|
// On a bridge failure, settle to "not found" rather than a permanent skeleton.
|
||||||
window.api
|
window.api
|
||||||
.getFfmpegVersions()
|
.getFfmpegVersions()
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { logError } from '../../reportError'
|
||||||
|
|
||||||
export interface NetworkCardController {
|
export interface NetworkCardController {
|
||||||
mintingPot: boolean
|
mintingPot: boolean
|
||||||
potHint: string | null
|
potHint: string | null
|
||||||
clearPotHint: () => void
|
clearPotHint: () => void
|
||||||
mintPoToken: () => Promise<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
|
* 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 {
|
export function useNetworkCard(): NetworkCardController {
|
||||||
const [mintingPot, setMintingPot] = useState(false)
|
const [mintingPot, setMintingPot] = useState(false)
|
||||||
const [potHint, setPotHint] = useState<string | null>(null)
|
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> {
|
async function mintPoToken(): Promise<void> {
|
||||||
setMintingPot(true)
|
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
@@ -18,6 +18,18 @@ export const IpcChannels = {
|
|||||||
appUpdateProgress: 'app:update-progress',
|
appUpdateProgress: 'app:update-progress',
|
||||||
ytdlpVersion: 'ytdlp:version',
|
ytdlpVersion: 'ytdlp:version',
|
||||||
ffmpegVersion: 'ffmpeg: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',
|
probe: 'media:probe',
|
||||||
downloadStart: 'download:start',
|
downloadStart: 'download:start',
|
||||||
downloadCancel: 'download:cancel',
|
downloadCancel: 'download:cancel',
|
||||||
@@ -93,6 +105,9 @@ export const IpcChannels = {
|
|||||||
scheduledSyncSet: 'sources:scheduled-sync-set',
|
scheduledSyncSet: 'sources:scheduled-sync-set',
|
||||||
/** main → renderer push channel for live indexing progress */
|
/** main → renderer push channel for live indexing progress */
|
||||||
indexProgress: 'sources:index-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) ---
|
// --- Built-in yt-dlp terminal (Phase N) ---
|
||||||
terminalRun: 'terminal:run',
|
terminalRun: 'terminal:run',
|
||||||
terminalCancel: 'terminal:cancel',
|
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
|
* 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 —
|
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here —
|
||||||
* just "what's installed".
|
* just "what's installed".
|
||||||
*/
|
*/
|
||||||
@@ -286,6 +301,33 @@ export interface FfmpegVersionResult {
|
|||||||
ffprobe: string | null
|
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. */
|
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
|
||||||
export interface AppUpdateInfo {
|
export interface AppUpdateInfo {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -684,13 +726,6 @@ export interface Settings {
|
|||||||
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
|
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
|
||||||
*/
|
*/
|
||||||
useHardwareAcceleration: boolean
|
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,
|
minimizeToTray: false,
|
||||||
launchAtStartup: false,
|
launchAtStartup: false,
|
||||||
isSidebarCollapsed: false,
|
isSidebarCollapsed: false,
|
||||||
useHardwareAcceleration: false,
|
useHardwareAcceleration: false
|
||||||
updateToken: ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -845,6 +879,13 @@ export interface ErrorLogEntry {
|
|||||||
url: string
|
url: string
|
||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
error: string
|
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 */
|
/** epoch milliseconds */
|
||||||
occurredAt: number
|
occurredAt: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -122,7 +122,7 @@ describe('parseSettingsField (CC9 schema parity)', () => {
|
|||||||
|
|
||||||
it('secrets: plain strings pass through (encryption happens in settings.ts)', () => {
|
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('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)
|
expect(parseSettingsField('proxy', 42)).toEqual(fail)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,12 @@ class FakeResponse extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeRequest extends EventEmitter {
|
class FakeRequest extends EventEmitter {
|
||||||
headers: Record<string, string> = {}
|
|
||||||
ended = false
|
ended = false
|
||||||
aborted = false
|
aborted = false
|
||||||
redirectsFollowed = 0
|
redirectsFollowed = 0
|
||||||
constructor(public url: string) {
|
constructor(public url: string) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
setHeader(k: string, v: string): void {
|
|
||||||
this.headers[k] = v
|
|
||||||
}
|
|
||||||
end(): void {
|
end(): void {
|
||||||
this.ended = true
|
this.ended = true
|
||||||
}
|
}
|
||||||
@@ -72,10 +68,6 @@ vi.mock('electron', () => ({
|
|||||||
shell: {}
|
shell: {}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../src/main/settings', () => ({
|
|
||||||
getSettings: () => ({ updateToken: '' })
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { downloadAppUpdate, cancelAppUpdate } from '../src/main/updater'
|
import { downloadAppUpdate, cancelAppUpdate } from '../src/main/updater'
|
||||||
import type { WebContents } from 'electron'
|
import type { WebContents } from 'electron'
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ describe('isValidErrorLogEntry', () => {
|
|||||||
expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true)
|
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', () => {
|
it('rejects a missing error message', () => {
|
||||||
const { error: _error, ...noError } = validError
|
const { error: _error, ...noError } = validError
|
||||||
expect(isValidErrorLogEntry(noError)).toBe(false)
|
expect(isValidErrorLogEntry(noError)).toBe(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user