Format code with Prettier (8 modified files from H1 decomposition)
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+84
-14
@@ -51,6 +51,18 @@ preview mock + Settings defaults — `DEFAULT_SETTINGS` in `@shared`, one typed
|
|||||||
eslint green; the touched files are prettier-clean (3 pre-existing format violations in
|
eslint green; the touched files are prettier-clean (3 pre-existing format violations in
|
||||||
DownloadOptionsForm/LibraryView/SettingsView are untouched and out of scope).
|
DownloadOptionsForm/LibraryView/SettingsView are untouched and out of scope).
|
||||||
|
|
||||||
|
**Session 2026-07-01 (main-process structure — the H1-adjacent decompose batch):** **L2** (the ~200-line
|
||||||
|
flat IPC block moved into one `src/main/ipc.ts` `registerIpcHandlers(getMainWindow)`; index.ts is now
|
||||||
|
lifecycle + window creation and imports the shared theme helpers from ipc.ts), **L69** (path helpers split
|
||||||
|
into `src/main/paths.ts`; settings.ts is now purely the electron-store layer), **L10** (a main-process
|
||||||
|
`src/main/constants.ts` centralizes the spawn timeouts, execFile buffers, stderr tail, and the three store
|
||||||
|
caps — nine modules migrated off bare literals), **CL2** (`buildArgs` → single `BuildArgsInput` object),
|
||||||
|
**CL3** (`startDownload`'s stream/close/error/watchdog wiring extracted into `wireChildProcess`), and **L1**
|
||||||
|
(the scheduled-download promoter tick moved from store-module load into an App `useEffect` so a store import
|
||||||
|
no longer starts a stray timer). All verified: typecheck (node+web) + 253 tests + eslint + production build
|
||||||
|
green; the touched files are prettier-clean (the 8 remaining prettier warnings are all pre-existing,
|
||||||
|
untouched files — DownloadOptionsForm/SettingsView/settings/*Card).
|
||||||
|
|
||||||
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
|
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
|
||||||
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
|
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
|
||||||
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
|
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
|
||||||
@@ -439,9 +451,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
|
|
||||||
## Low
|
## Low
|
||||||
|
|
||||||
- [ ] **L1 — Module-load `setInterval` in `store/downloads.ts`** runs on import (incl. tests/preview).
|
- [x] **L1 — Module-load `setInterval` in `store/downloads.ts`** runs on import (incl. tests/preview).
|
||||||
- [ ] **L2 — `index.ts` flat IPC registration (~150 lines)** — let each main module export its own
|
*Fixed: the scheduled-download promoter tick moved out of store-module scope into an `App.tsx`
|
||||||
`register(ipcMain)`.
|
`useEffect` (interval `SCHEDULE_TICK_MS`, cleared on unmount). Importing the store in tests/preview no
|
||||||
|
longer starts a stray never-cleared timer; the store keeps the `promoteDueScheduled` action, App owns the
|
||||||
|
cadence. (The preview `startFakeTicker` interval is created on demand per launched item, not at load, so
|
||||||
|
it was already fine.)*
|
||||||
|
- [x] **L2 — `index.ts` flat IPC registration (~150 lines)** — let each main module export its own
|
||||||
|
`register(ipcMain)`. *Fixed: the whole ~200-line handler block moved into one dedicated
|
||||||
|
[ipc.ts](src/main/ipc.ts) exporting `registerIpcHandlers(getMainWindow)` — the single place the renderer's
|
||||||
|
IPC contract is wired. index.ts now imports it (plus the three shared theme helpers `resolveBackgroundMode`
|
||||||
|
/`applyNativeTheme`/`getSystemThemeInfo`, also moved to ipc.ts) and shed ~50 imports it only needed for the
|
||||||
|
handlers, leaving it as app-lifecycle + window creation. The window-coupled handlers (folder picker,
|
||||||
|
theme-synced background, taskbar progress) take the live window via the `getMainWindow` accessor rather
|
||||||
|
than a captured `mainWindow`.*
|
||||||
- [x] **L3 — Stale comments.** `getYtdlpVersion` JSDoc still calls it the "Step-1 spike";
|
- [x] **L3 — Stale comments.** `getYtdlpVersion` JSDoc still calls it the "Step-1 spike";
|
||||||
`vitest.config.ts` claims tests "only exercise buildArgs.ts" (9 test files now exist).
|
`vitest.config.ts` claims tests "only exercise buildArgs.ts" (9 test files now exist).
|
||||||
- [x] **L4 — `electron-builder.yml` nits.** `copyright: yt-dlp frontend` is not a copyright
|
- [x] **L4 — `electron-builder.yml` nits.** `copyright: yt-dlp frontend` is not a copyright
|
||||||
@@ -458,9 +481,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
|
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
|
||||||
- [x] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
|
- [x] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
|
||||||
60×34) with no shared aspect/size constant.
|
60×34) with no shared aspect/size constant.
|
||||||
- [ ] **L10 — Scattered magic numbers.** Timeouts/caps spread across modules (probe 60s, indexer
|
- [x] **L10 — Scattered magic numbers.** Timeouts/caps spread across modules (probe 60s, indexer
|
||||||
180s, probeMeta 30s, update-idle 60s; MAX_ENTRIES 500/200, MAX_TEMPLATES 100, MAX_ITEMS 20000).
|
180s, probeMeta 30s, update-idle 60s; MAX_ENTRIES 500/200, MAX_TEMPLATES 100, MAX_ITEMS 20000).
|
||||||
Consider a central constants module.
|
Consider a central constants module. *Fixed: a main-process [constants.ts](src/main/constants.ts) now
|
||||||
|
holds the process-spawn timeouts (`VERSION_TIMEOUT_MS`, `META_PROBE_TIMEOUT_MS`, `PROBE_TIMEOUT_MS`,
|
||||||
|
`INDEX_TIMEOUT_MS`, `YTDLP_UPDATE_TIMEOUT_MS`, `FEED_FETCH_TIMEOUT_MS`, `STALL_TIMEOUT_MS`), the execFile
|
||||||
|
`maxBuffer` sizes (`META`/`PROBE`/`INDEX_MAX_BUFFER`), `STDERR_TAIL_BYTES`, and the three hand-rolled store
|
||||||
|
caps (`ERRORLOG_MAX_ENTRIES`, `TEMPLATES_MAX`, `MEDIA_ITEMS_MAX`). download/ffmpeg/indexer/probe/ytdlp/sync/
|
||||||
|
errorlog/templates/sources import from it instead of bare literals. Values already single-sourced in the
|
||||||
|
shared contract (`HISTORY_MAX_ENTRIES`) and updater.ts's already-named local timeouts were left in place —
|
||||||
|
they aren't scattered literals.*
|
||||||
- [x] **L11 — "Queue (N)" overcounts.** DownloadsView's header count is `items.length` (includes
|
- [x] **L11 — "Queue (N)" overcounts.** DownloadsView's header count is `items.length` (includes
|
||||||
completed/error/canceled), not the active queue.
|
completed/error/canceled), not the active queue.
|
||||||
- [x] **L12 — Command palette polish.** No scroll-into-view for keyboard selection in the 50vh
|
- [x] **L12 — Command palette polish.** No scroll-into-view for keyboard selection in the 50vh
|
||||||
@@ -568,8 +598,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
- [ ] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again").
|
- [ ] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again").
|
||||||
- [x] **L68 — Background-running notification fires once per process** (`notifiedBackground` never
|
- [x] **L68 — Background-running notification fires once per process** (`notifiedBackground` never
|
||||||
resets) — won't remind on later window closes.
|
resets) — won't remind on later window closes.
|
||||||
- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
|
- [x] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
|
||||||
`ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`.
|
`ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`. *Fixed: the three
|
||||||
|
path helpers moved to [paths.ts](src/main/paths.ts); settings.ts is now just the electron-store persistence
|
||||||
|
layer (its now-unused `path`/`fs` imports dropped). download.ts and index.ts import the helpers from
|
||||||
|
`./paths`. `applyLaunchAtStartup` stays in settings.ts — it's OS login-item integration referenced by
|
||||||
|
`applySettings`, not a path helper.*
|
||||||
- [x] **L70 — `crypto.randomUUID` fallback is effectively unreachable** in Electron/Node 26
|
- [x] **L70 — `crypto.randomUUID` fallback is effectively unreachable** in Electron/Node 26
|
||||||
(`typeof crypto !== 'undefined'` is always true) — dead defensive branch (see M7). *Fixed with M7: the
|
(`typeof crypto !== 'undefined'` is always true) — dead defensive branch (see M7). *Fixed with M7: the
|
||||||
branch now lives once in `newId` (kept so the fn is total in any host) and is covered by a test that stubs
|
branch now lives once in `newId` (kept so the fn is total in any host) and is covered by a test that stubs
|
||||||
@@ -968,23 +1002,51 @@ root cause is already filed.
|
|||||||
subtitles, SponsorBlock), passed via `AddOptions.options` → `startDownload`. The panel also carries the
|
subtitles, SponsorBlock), passed via `AddOptions.options` → `startDownload`. The panel also carries the
|
||||||
M6 incognito checkbox and sits beside the M5 command preview. Overrides are one-shot: they reset to the
|
M6 incognito checkbox and sits beside the M5 command preview. Overrides are one-shot: they reset to the
|
||||||
global defaults after each download (matching Trim/Schedule).*
|
global defaults after each download (matching Trim/Schedule).*
|
||||||
- [ ] **UX2 — "Fetch" vs "Download" is ambiguous, and Enter does the wrong thing.** Two unlabeled
|
- [x] **UX2 — "Fetch" vs "Download" is ambiguous, and Enter does the wrong thing.** Two unlabeled
|
||||||
icon buttons (magnifier = "Fetch", clipboard = "Paste") sit next to "Download"; a first-timer can't
|
icon buttons (magnifier = "Fetch", clipboard = "Paste") sit next to "Download"; a first-timer can't
|
||||||
tell that Download works without Fetch. Pressing **Enter** in the URL field runs a *probe*, not the
|
tell that Download works without Fetch. Pressing **Enter** in the URL field runs a *probe*, not the
|
||||||
download (L27) — violating "type URL + Enter = go". **Fix:** label the action, make Enter download
|
download (L27) — violating "type URL + Enter = go". **Fix:** label the action, make Enter download
|
||||||
(or fetch-then-download), and present Fetch as optional ("Preview formats").
|
(or fetch-then-download), and present Fetch as optional ("Preview formats").
|
||||||
- [ ] **UX3 — Library "Index" is jargon and a hidden two-stage workflow.** Pasting a channel URL in
|
*Fixed in [DownloadBar.tsx](src/renderer/src/components/DownloadBar.tsx): **Enter is now "go"** — it
|
||||||
|
starts the download (or adds the fetched playlist when the playlist panel is open) instead of probing;
|
||||||
|
probing stays an explicit, optional click. The magnifier button is relabeled from "Check URL" to
|
||||||
|
**"Preview available formats (optional)"** (both its tooltip and `aria-label`), and the URL placeholder
|
||||||
|
now reads "Paste a video or playlist URL, then press Enter to download" so it's clear Download works
|
||||||
|
without Fetch. `download()` already enqueues with the presets when nothing has been probed, so the
|
||||||
|
Enter-downloads path needs no probe. (resolves L27's Enter inconsistency too.)*
|
||||||
|
- [x] **UX3 — Library "Index" is jargon and a hidden two-stage workflow.** Pasting a channel URL in
|
||||||
the Downloads bar tries to treat it as one item; whole channels belong in the Library tab, but
|
the Downloads bar tries to treat it as one item; whole channels belong in the Library tab, but
|
||||||
nothing signposts that. "Index" reads like "Download" but only catalogs — you must then expand,
|
nothing signposts that. "Index" reads like "Download" but only catalogs — you must then expand,
|
||||||
select, and Download. **Fix:** rename to "Add channel/playlist," and after indexing surface a clear
|
select, and Download. **Fix:** rename to "Add channel/playlist," and after indexing surface a clear
|
||||||
"Download N videos" next step; detect channel URLs in the Downloads bar and suggest the Library.
|
"Download N videos" next step; detect channel URLs in the Downloads bar and suggest the Library.
|
||||||
|
*Fixed across three seams. **(1) De-jargoned the Library** ([LibraryView.tsx](src/renderer/src/components/LibraryView.tsx)):
|
||||||
|
the primary button is now **"Add"** (was "Index"), "Re-index" → **"Refresh"**, and the header
|
||||||
|
description / empty state / placeholder / error copy drop "index" for "add". The post-add next step
|
||||||
|
already exists — `indexSource` auto-selects the new source so its card expands with a prominent
|
||||||
|
"Download N pending" primary button. **(2) Channel detection in the Downloads bar** — a new pure
|
||||||
|
[`looksLikeChannelOrPlaylist`](src/renderer/src/lib/urlHelpers.ts) (conservative: only clear YouTube
|
||||||
|
channel/dedicated-playlist shapes; a `/watch` video, even with a `list=`, is never flagged and keeps
|
||||||
|
working in the bar) drives a dismissible brand-tinted nudge in
|
||||||
|
[DownloadBar.tsx](src/renderer/src/components/DownloadBar.tsx): "This looks like a channel or playlist.
|
||||||
|
Add it in the Library…" with an **Open in Library** action. **(3) The handoff** goes through a small
|
||||||
|
new [store/nav.ts](src/renderer/src/store/nav.ts) (App's tab state moved into it) that carries a
|
||||||
|
one-shot `pendingLibraryUrl`; the Library consumes it on mount to pre-fill its add field. Unit-tested
|
||||||
|
(`looksLikeChannelOrPlaylist` in `test/clipboardLink.test.ts`) and verified end-to-end in the browser
|
||||||
|
preview (channel URL → nudge → Open in Library → Library tab with the URL pre-filled).*
|
||||||
- [x] **UX4 — Destructive actions have no confirmation and no undo** (L13). "Clear history,"
|
- [x] **UX4 — Destructive actions have no confirmation and no undo** (L13). "Clear history,"
|
||||||
"Clear log," "Remove source" (deletes an entire indexed channel + items), and "Delete selected"
|
"Clear log," "Remove source" (deletes an entire indexed channel + items), and "Delete selected"
|
||||||
are all one click. A user exploring can wipe data irreversibly. **Fix:** confirm destructive/bulk
|
are all one click. A user exploring can wipe data irreversibly. **Fix:** confirm destructive/bulk
|
||||||
deletes (or offer an undo toast).
|
deletes (or offer an undo toast).
|
||||||
- [ ] **UX5 — First run can't choose the download folder.** [Onboarding.tsx](src/renderer/src/components/Onboarding.tsx)
|
- [x] **UX5 — First run can't choose the download folder.** [Onboarding.tsx](src/renderer/src/components/Onboarding.tsx)
|
||||||
only *describes* Documents\Video/Audio — there's no picker (the ROADMAP claims one). The user must
|
only *describes* Documents\Video/Audio — there's no picker (the ROADMAP claims one). The user must
|
||||||
later discover Settings → Downloads. **Fix:** put the folder picker in onboarding as documented.
|
later discover Settings → Downloads. **Fix:** put the folder picker in onboarding as documented.
|
||||||
|
*Fixed: the "Where downloads go" block in [Onboarding.tsx](src/renderer/src/components/Onboarding.tsx)
|
||||||
|
now has interactive Video and Audio rows — each shows the current folder (or the `Documents\Video` /
|
||||||
|
`Documents\Audio` default) with a **Choose…** button that opens the OS picker via the settings store's
|
||||||
|
`chooseDir` (the same proven path DownloadsCard uses, seeded at the current value — W5). Picking a
|
||||||
|
folder persists immediately, so the choice is already saved when the user clicks "Get started";
|
||||||
|
leaving it untouched keeps the Documents defaults. Verified in the browser preview with onboarding
|
||||||
|
forced on.*
|
||||||
|
|
||||||
### Medium — confusion, feedback & organization
|
### Medium — confusion, feedback & organization
|
||||||
|
|
||||||
@@ -1258,10 +1320,18 @@ filed. Several listed categories are **N/A** to this stack and worth recording s
|
|||||||
`'path|'` are hard-coded in `buildArgs.ts` (`PROGRESS_TEMPLATE` / `--print after_move:path|…`) and again
|
`'path|'` are hard-coded in `buildArgs.ts` (`PROGRESS_TEMPLATE` / `--print after_move:path|…`) and again
|
||||||
in `download.ts` (`line.startsWith('prog|')` / `'path|'`). Change one and the other breaks silently.
|
in `download.ts` (`line.startsWith('prog|')` / `'path|'`). Change one and the other breaks silently.
|
||||||
**Fix:** export shared marker constants from one module.
|
**Fix:** export shared marker constants from one module.
|
||||||
- [ ] **CL2 — Long positional parameter list.** `buildArgs(opts, outputTemplate, o, binDir, access,
|
- [x] **CL2 — Long positional parameter list.** `buildArgs(opts, outputTemplate, o, binDir, access,
|
||||||
extraArgs)` takes six positional args (and `o` vs `opts` is easy to swap). **Fix:** pass a single options object.
|
extraArgs)` takes six positional args (and `o` vs `opts` is easy to swap). **Fix:** pass a single options
|
||||||
- [ ] **CL3 — Large method: `startDownload`** (~130 lines) bundles spawn + dual metadata path + four inline
|
object. *Fixed: `buildArgs(input: BuildArgsInput)` now takes one named object (`opts`/`outputTemplate`/
|
||||||
`child` event handlers. **Fix:** extract the stdout-parse + close/error wiring (pairs with CC3's spawn-stream helper).
|
`options`/`binDir`/`access`/`extraArgs?`) destructured at the top, so callers can't transpose the two
|
||||||
|
path-like strings or `opts`/`options`. download.ts's `buildCommand` and both test harnesses updated.*
|
||||||
|
- [x] **CL3 — Large method: `startDownload`** (~130 lines) bundles spawn + dual metadata path + four inline
|
||||||
|
`child` event handlers. **Fix:** extract the stdout-parse + close/error wiring (pairs with CC3's spawn-stream
|
||||||
|
helper). *Fixed: the stdout/stderr parse, the close/error handlers, and the B1 idle watchdog moved into a
|
||||||
|
`wireChildProcess({ wc, opts, rec, cleanup, getTitle })` helper in [download.ts](src/main/download.ts).
|
||||||
|
`startDownload` is now a linear pre-flight (binary checks → URL normalise → concurrency guard → cookies →
|
||||||
|
spawn → metadata probe → wire). `getTitle` is a getter because the parallel metadata probe may fill the
|
||||||
|
resolved title in after wiring is set up. Behaviour unchanged.*
|
||||||
- [ ] **CL4 — Deep nesting: `updater.downloadAppUpdate`** — Promise → `net.request` → `response` → `data`
|
- [ ] **CL4 — Deep nesting: `updater.downloadAppUpdate`** — Promise → `net.request` → `response` → `data`
|
||||||
with nested conditionals/teardown is the hardest-to-follow block. **Fix:** extract a `streamToFile` helper.
|
with nested conditionals/teardown is the hardest-to-follow block. **Fix:** extract a `streamToFile` helper.
|
||||||
- [x] **CL5 — Superfluous exports.** `PROGRESS_TEMPLATE` (L167) and `getManagedBinDir` are `export`ed but
|
- [x] **CL5 — Superfluous exports.** `PROGRESS_TEMPLATE` (L167) and `getManagedBinDir` are `export`ed but
|
||||||
|
|||||||
+23
-8
@@ -370,14 +370,29 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildArgs(
|
/**
|
||||||
opts: StartDownloadOptions,
|
* Everything buildArgs needs to construct a yt-dlp argv. A single options object
|
||||||
outputTemplate: string,
|
* (rather than six positional params) so callers can't transpose `opts`/`options`
|
||||||
o: DownloadOptions,
|
* or the two path-like strings, and new inputs can be added without churning every
|
||||||
binDir: string,
|
* call site (CL2).
|
||||||
access: AccessOptions,
|
*/
|
||||||
extraArgs: string[] = []
|
export interface BuildArgsInput {
|
||||||
): string[] {
|
/** The per-download request (url, kind, quality, chosen format, trim, …). */
|
||||||
|
opts: StartDownloadOptions
|
||||||
|
/** The resolved `-o` output template (flat filename or collection folder tree). */
|
||||||
|
outputTemplate: string
|
||||||
|
/** The post-processing options group (per-download override or the persisted default). */
|
||||||
|
options: DownloadOptions
|
||||||
|
/** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */
|
||||||
|
binDir: string
|
||||||
|
/** Global access/networking settings (proxy, cookies, rate limit, …). */
|
||||||
|
access: AccessOptions
|
||||||
|
/** Custom-command extra args, already consent-gated by the caller. */
|
||||||
|
extraArgs?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArgs(input: BuildArgsInput): string[] {
|
||||||
|
const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input
|
||||||
const args = [
|
const args = [
|
||||||
'--newline',
|
'--newline',
|
||||||
'--no-color',
|
'--no-color',
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Central home for the main-process timeouts, buffer sizes, and store caps that
|
||||||
|
* were previously scattered as bare literals across the spawn/probe/index/store
|
||||||
|
* modules (L10). Collecting them here makes the operational envelope reviewable
|
||||||
|
* in one place and stops the same "how long / how big" decision drifting between
|
||||||
|
* modules. Values that are already single-sourced in the shared contract
|
||||||
|
* (e.g. HISTORY_MAX_ENTRIES) stay there; this file is for the main-only ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Child-process timeouts (ms) --------------------------------------------
|
||||||
|
|
||||||
|
/** `<binary> -version` probes (yt-dlp, ffmpeg, ffprobe) — a quick liveness call. */
|
||||||
|
export const VERSION_TIMEOUT_MS = 15_000
|
||||||
|
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
|
||||||
|
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
|
||||||
|
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
|
||||||
|
export const META_PROBE_TIMEOUT_MS = 30_000
|
||||||
|
/** Full format/metadata probe (`-J`) for the download bar. */
|
||||||
|
export const PROBE_TIMEOUT_MS = 60_000
|
||||||
|
/** Channel/playlist indexing walk — a big channel legitimately takes minutes. */
|
||||||
|
export const INDEX_TIMEOUT_MS = 180_000
|
||||||
|
/** RSS feed fetch for a watched source's new-item check. */
|
||||||
|
export const FEED_FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle watchdog for a running download: if a spawned yt-dlp emits no stdout or
|
||||||
|
* stderr for this long, treat it as wedged and kill it so the concurrency slot
|
||||||
|
* frees (B1). Generous on purpose so a long, output-less post-processing step
|
||||||
|
* (e.g. a large ffmpeg merge) isn't mistaken for a stall.
|
||||||
|
*/
|
||||||
|
export const STALL_TIMEOUT_MS = 5 * 60_000
|
||||||
|
|
||||||
|
// --- execFile maxBuffer sizes (bytes) ---------------------------------------
|
||||||
|
// yt-dlp JSON for a big channel/format list is large; cap generously so a valid
|
||||||
|
// response is never truncated (which would look like a parse failure).
|
||||||
|
|
||||||
|
/** Metadata --print output (three short fields). */
|
||||||
|
export const META_MAX_BUFFER = 4 * 1024 * 1024
|
||||||
|
/** Single-video `-J` probe JSON. */
|
||||||
|
export const PROBE_MAX_BUFFER = 64 * 1024 * 1024
|
||||||
|
/** Whole-channel index JSON. */
|
||||||
|
export const INDEX_MAX_BUFFER = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
// --- Misc ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How much of a failed download's stderr to retain for the error message. */
|
||||||
|
export const STDERR_TAIL_BYTES = 4000
|
||||||
|
|
||||||
|
// --- JSON-store row caps ----------------------------------------------------
|
||||||
|
// Each hand-rolled JSON store trims to its cap on write so a store file can't
|
||||||
|
// grow without bound.
|
||||||
|
|
||||||
|
/** Diagnostics error log (errorlog.json). */
|
||||||
|
export const ERRORLOG_MAX_ENTRIES = 200
|
||||||
|
/** Saved custom-command templates (templates.json). */
|
||||||
|
export const TEMPLATES_MAX = 100
|
||||||
|
/** Indexed media items across all sources (media-items.json). */
|
||||||
|
export const MEDIA_ITEMS_MAX = 20_000
|
||||||
+49
-24
@@ -13,7 +13,8 @@ import {
|
|||||||
getAppIconImage,
|
getAppIconImage,
|
||||||
YTDLP_MISSING_MSG
|
YTDLP_MISSING_MSG
|
||||||
} from './binaries'
|
} from './binaries'
|
||||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
import { getSettings } from './settings'
|
||||||
|
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
|
||||||
import { ensureManagedYtdlp } from './ytdlp'
|
import { ensureManagedYtdlp } from './ytdlp'
|
||||||
import { materializeCookies, hasStoredCookies } from './cookies'
|
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||||
import { listTemplates } from './templates'
|
import { listTemplates } from './templates'
|
||||||
@@ -29,6 +30,12 @@ import {
|
|||||||
} from './buildArgs'
|
} from './buildArgs'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { addErrorLog } from './errorlog'
|
import { addErrorLog } from './errorlog'
|
||||||
|
import {
|
||||||
|
STALL_TIMEOUT_MS,
|
||||||
|
META_PROBE_TIMEOUT_MS,
|
||||||
|
META_MAX_BUFFER,
|
||||||
|
STDERR_TAIL_BYTES
|
||||||
|
} from './constants'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
@@ -62,14 +69,6 @@ function releaseActive(id: string, rec: ActiveDownload): void {
|
|||||||
if (active.get(id) === rec) active.delete(id)
|
if (active.get(id) === rec) active.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
|
|
||||||
// stderr — for this long, treat it as wedged, kill it, and error the item so the
|
|
||||||
// concurrency slot frees instead of leaking forever. Generous on purpose so a
|
|
||||||
// long, output-less post-processing step (e.g. a large ffmpeg merge) isn't
|
|
||||||
// mistaken for a stall; --socket-timeout (buildArgs) handles the common
|
|
||||||
// dead-connection case at the network layer.
|
|
||||||
const STALL_TIMEOUT_MS = 5 * 60_000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||||
@@ -137,7 +136,7 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
|||||||
'--',
|
'--',
|
||||||
url
|
url
|
||||||
],
|
],
|
||||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
{ windowsHide: true, maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
if (err) return resolve(null)
|
if (err) return resolve(null)
|
||||||
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
|
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
|
||||||
@@ -211,7 +210,7 @@ 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, getBinDir(), access, extraArgs)
|
return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), 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. */
|
||||||
@@ -333,6 +332,34 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter
|
||||||
|
// because the parallel probeMeta above may fill it in after this returns.
|
||||||
|
wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle })
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Child-process wiring ---------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and
|
||||||
|
* run the B1 idle watchdog. Extracted from startDownload so that function reads as
|
||||||
|
* a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3).
|
||||||
|
*
|
||||||
|
* `getTitle` is read lazily on each event: the completion/error paths need the
|
||||||
|
* best title known *at settle time*, which the parallel metadata probe may only
|
||||||
|
* fill in after wiring is set up.
|
||||||
|
*/
|
||||||
|
function wireChildProcess(params: {
|
||||||
|
wc: WebContents
|
||||||
|
opts: StartDownloadOptions
|
||||||
|
rec: ActiveDownload
|
||||||
|
cleanup: () => void
|
||||||
|
getTitle: () => string | undefined
|
||||||
|
}): void {
|
||||||
|
const { wc, opts, rec, cleanup, getTitle } = params
|
||||||
|
const child = rec.child
|
||||||
|
|
||||||
let stdoutBuf = ''
|
let stdoutBuf = ''
|
||||||
let stderrTail = ''
|
let stderrTail = ''
|
||||||
let filePath: string | undefined
|
let filePath: string | undefined
|
||||||
@@ -360,13 +387,13 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
|||||||
if (settled || rec.canceled || rec.paused) return
|
if (settled || rec.canceled || rec.paused) return
|
||||||
settled = true
|
settled = true
|
||||||
clearWatchdog()
|
clearWatchdog()
|
||||||
cleanupCookies()
|
cleanup()
|
||||||
releaseActive(opts.id, rec)
|
releaseActive(opts.id, rec)
|
||||||
killTree(rec)
|
killTree(rec)
|
||||||
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
|
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
|
||||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||||
logFailure(opts, resolvedTitle, msg)
|
logFailure(opts, getTitle(), msg)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
notify(wc, getTitle() ?? 'Download failed', msg)
|
||||||
}, STALL_TIMEOUT_MS)
|
}, STALL_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
bumpWatchdog()
|
bumpWatchdog()
|
||||||
@@ -397,20 +424,20 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
|||||||
|
|
||||||
child.stderr?.on('data', (chunk: Buffer) => {
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
bumpWatchdog()
|
bumpWatchdog()
|
||||||
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
|
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
clearWatchdog()
|
clearWatchdog()
|
||||||
cleanupCookies()
|
cleanup()
|
||||||
releaseActive(opts.id, rec)
|
releaseActive(opts.id, rec)
|
||||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||||
if (!rec.canceled && !rec.paused) {
|
if (!rec.canceled && !rec.paused) {
|
||||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||||
logFailure(opts, resolvedTitle, err.message)
|
logFailure(opts, getTitle(), err.message)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
notify(wc, getTitle() ?? 'Download failed', err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,23 +445,21 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
clearWatchdog()
|
clearWatchdog()
|
||||||
cleanupCookies()
|
cleanup()
|
||||||
releaseActive(opts.id, rec)
|
releaseActive(opts.id, rec)
|
||||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||||
if (rec.canceled || rec.paused) return
|
if (rec.canceled || rec.paused) return
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
send(wc, { type: 'done', id: opts.id, filePath })
|
send(wc, { type: 'done', id: opts.id, filePath })
|
||||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
notify(wc, getTitle() ?? 'Download complete', 'Finished downloading.')
|
||||||
} else {
|
} else {
|
||||||
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
const msg = cleanError(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, resolvedTitle, msg)
|
logFailure(opts, getTitle(), msg)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
notify(wc, getTitle() ?? 'Download failed', msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { ErrorLogEntry } from '@shared/ipc'
|
import type { ErrorLogEntry } from '@shared/ipc'
|
||||||
import { isValidErrorLogEntry } from './validation'
|
import { isValidErrorLogEntry } from './validation'
|
||||||
import { createJsonStore } from './jsonStore'
|
import { createJsonStore } from './jsonStore'
|
||||||
|
import { ERRORLOG_MAX_ENTRIES } from './constants'
|
||||||
|
|
||||||
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
||||||
// report survives the queue item being cleared (Seal's "debug report"). Atomic
|
// report survives the queue item being cleared (Seal's "debug report"). Atomic
|
||||||
// writes / corruption backup / caching come from the shared jsonStore (R1–R3).
|
// writes / corruption backup / caching come from the shared jsonStore (R1–R3).
|
||||||
const MAX_ENTRIES = 200
|
|
||||||
|
|
||||||
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
|
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
|
||||||
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
|
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
|
||||||
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, MAX_ENTRIES)
|
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, ERRORLOG_MAX_ENTRIES)
|
||||||
|
|
||||||
export function listErrorLog(): ErrorLogEntry[] {
|
export function listErrorLog(): ErrorLogEntry[] {
|
||||||
return store.read()
|
return store.read()
|
||||||
|
|||||||
+14
-8
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getFfmpegPath, getFfprobePath } from './binaries'
|
import { getFfmpegPath, getFfprobePath } from './binaries'
|
||||||
|
import { VERSION_TIMEOUT_MS } from './constants'
|
||||||
import type { FfmpegVersionResult } from '@shared/ipc'
|
import type { FfmpegVersionResult } from '@shared/ipc'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,15 +15,20 @@ import type { FfmpegVersionResult } from '@shared/ipc'
|
|||||||
function readToolVersion(path: string): Promise<string | null> {
|
function readToolVersion(path: string): Promise<string | null> {
|
||||||
if (!existsSync(path)) return Promise.resolve(null)
|
if (!existsSync(path)) return Promise.resolve(null)
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
|
execFile(
|
||||||
if (err) {
|
path,
|
||||||
resolve(null)
|
['-version'],
|
||||||
return
|
{ windowsHide: true, timeout: VERSION_TIMEOUT_MS },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
||||||
|
const m = firstLine.match(/version\s+(\S+)/i)
|
||||||
|
resolve(m?.[1] ?? null)
|
||||||
}
|
}
|
||||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
)
|
||||||
const m = firstLine.match(/version\s+(\S+)/i)
|
|
||||||
resolve(m?.[1] ?? null)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-283
@@ -1,77 +1,26 @@
|
|||||||
import {
|
import { app, shell, BrowserWindow, nativeTheme, Notification, Menu } from 'electron'
|
||||||
app,
|
|
||||||
shell,
|
|
||||||
BrowserWindow,
|
|
||||||
ipcMain,
|
|
||||||
dialog,
|
|
||||||
clipboard,
|
|
||||||
nativeTheme,
|
|
||||||
Notification,
|
|
||||||
Menu
|
|
||||||
} from 'electron'
|
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { existsSync } from 'fs'
|
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import {
|
import { IpcChannels } from '@shared/ipc'
|
||||||
IpcChannels,
|
|
||||||
type StartDownloadOptions,
|
|
||||||
type Settings,
|
|
||||||
type HistoryEntry,
|
|
||||||
type CommandTemplate,
|
|
||||||
type YtdlpUpdateChannel,
|
|
||||||
type SystemThemeInfo,
|
|
||||||
type TaskbarProgress
|
|
||||||
} from '@shared/ipc'
|
|
||||||
import { PAGE_BACKGROUND } from '@shared/theme'
|
import { PAGE_BACKGROUND } from '@shared/theme'
|
||||||
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
|
import {
|
||||||
import { getFfmpegVersions } from './ffmpeg'
|
registerIpcHandlers,
|
||||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
resolveBackgroundMode,
|
||||||
import { probeMedia } from './probe'
|
applyNativeTheme,
|
||||||
|
getSystemThemeInfo
|
||||||
|
} from './ipc'
|
||||||
|
import { runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||||
import { getAppIconImage } from './binaries'
|
import { getAppIconImage } from './binaries'
|
||||||
import {
|
import { hasActiveDownloads } from './download'
|
||||||
startDownload,
|
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
|
||||||
cancelDownload,
|
import { ensureMediaDirs } from './paths'
|
||||||
pauseDownload,
|
|
||||||
previewCommand,
|
|
||||||
hasActiveDownloads
|
|
||||||
} from './download'
|
|
||||||
import { runTerminal, cancelTerminal } from './terminal'
|
|
||||||
import {
|
|
||||||
getSettings,
|
|
||||||
setSettings,
|
|
||||||
ensureMediaDirs,
|
|
||||||
applyLaunchAtStartup,
|
|
||||||
migrateSecretsAtRest
|
|
||||||
} from './settings'
|
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
import { migrateLegacyCookies } from './cookies'
|
||||||
import {
|
|
||||||
openCookieLoginWindow,
|
|
||||||
getCookiesStatus,
|
|
||||||
clearCookies,
|
|
||||||
migrateLegacyCookies
|
|
||||||
} from './cookies'
|
|
||||||
import { attachEditContextMenu } from './contextMenu'
|
import { attachEditContextMenu } from './contextMenu'
|
||||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
|
||||||
import { flushAllStores } from './jsonStore'
|
import { flushAllStores } from './jsonStore'
|
||||||
import { exportBackup, importBackup } from './backup'
|
|
||||||
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
||||||
import {
|
import { isSyncLaunch } from './schedule'
|
||||||
listSources,
|
|
||||||
getSource,
|
|
||||||
removeSource,
|
|
||||||
listMediaItems,
|
|
||||||
setMediaItemDownloaded,
|
|
||||||
setSourceWatched
|
|
||||||
} from './sources'
|
|
||||||
import { indexSource } from './indexer'
|
|
||||||
import { syncWatchedSources } from './sync'
|
|
||||||
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
|
|
||||||
import { createTray, markQuitting, isQuitting } from './tray'
|
import { createTray, markQuitting, isQuitting } from './tray'
|
||||||
import { getActiveBadge, getErrorBadge } from './badge'
|
|
||||||
import { openPoTokenWindow } from './poToken'
|
|
||||||
|
|
||||||
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
||||||
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
||||||
@@ -110,25 +59,6 @@ if (is.dev && devScript) {
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
// Resolve 'system' against the OS preference so callers always get a concrete color.
|
|
||||||
function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
|
|
||||||
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
|
|
||||||
// themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
|
|
||||||
// restores OS control. Called at startup and on every theme change.
|
|
||||||
function applyNativeTheme(theme: Settings['theme']): void {
|
|
||||||
nativeTheme.themeSource = theme
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemThemeInfo(): SystemThemeInfo {
|
|
||||||
return {
|
|
||||||
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
|
||||||
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell the user (once per run) that closing the window left AeroFetch running so
|
// Tell the user (once per run) that closing the window left AeroFetch running so
|
||||||
// an in-progress download could finish — shown only when they haven't already
|
// an in-progress download could finish — shown only when they haven't already
|
||||||
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
||||||
@@ -263,204 +193,6 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
|
||||||
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
|
|
||||||
// mode. Catch it here and show a hard error so the failure is never invisible.
|
|
||||||
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
|
|
||||||
dialog.showErrorBox(
|
|
||||||
'AeroFetch could not start',
|
|
||||||
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
|
|
||||||
)
|
|
||||||
app.quit()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
|
||||||
downloadAppUpdate(url, e.sender)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
|
||||||
const result = startDownload(e.sender, opts)
|
|
||||||
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
|
|
||||||
// download.ts's own close/error handlers, so log them here instead.
|
|
||||||
if (!result.ok) {
|
|
||||||
addErrorLog({
|
|
||||||
id: opts.id,
|
|
||||||
url: opts.url,
|
|
||||||
kind: opts.kind,
|
|
||||||
error: result.error ?? 'Unknown error',
|
|
||||||
occurredAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
|
||||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
|
||||||
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
|
||||||
runTerminal(e.sender, id, args)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
|
||||||
// Seed the picker at the currently-configured folder so it opens where the
|
|
||||||
// user already points, not a generic default (W5). 'createDirectory' is a
|
|
||||||
// macOS-only property and a no-op on Windows, so it's dropped (L58).
|
|
||||||
const defaultPath = current && existsSync(current) ? current : undefined
|
|
||||||
// Use the parented overload only when we actually have a window — passing a
|
|
||||||
// forced non-null window that's gone can throw (L53).
|
|
||||||
const res = win
|
|
||||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
|
|
||||||
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
|
|
||||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
|
||||||
ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => {
|
|
||||||
try {
|
|
||||||
const { protocol } = new URL(url)
|
|
||||||
if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url)
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed URLs.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
|
||||||
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
|
||||||
const result = setSettings(partial)
|
|
||||||
// Keep the window's native background and title bar in sync with the theme
|
|
||||||
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
|
|
||||||
// caption always matches the in-app theme (W3). Use the validated result.
|
|
||||||
if (partial.theme) {
|
|
||||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
|
||||||
PAGE_BACKGROUND[resolveBackgroundMode(result.theme)]
|
|
||||||
)
|
|
||||||
applyNativeTheme(result.theme)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
|
|
||||||
shell.openExternal('ms-settings:easeofaccess-highcontrast')
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.historyList, () => listHistory())
|
|
||||||
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
|
||||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
|
||||||
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
|
||||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
|
|
||||||
// Parent the sign-in window to the app window (W6) so it groups under
|
|
||||||
// AeroFetch instead of spawning a second taskbar button.
|
|
||||||
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
|
||||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
|
||||||
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
|
|
||||||
saveTemplate(template)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
|
||||||
previewCommand(opts)
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
|
|
||||||
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.backupExport, (e) =>
|
|
||||||
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.backupImport, async (e) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
|
||||||
const result = await importBackup(win)
|
|
||||||
// A restored backup may have changed the theme; keep the native window
|
|
||||||
// background and title bar in sync the same way settingsSet does (W3).
|
|
||||||
if (result.ok) {
|
|
||||||
const theme = getSettings().theme
|
|
||||||
win?.setBackgroundColor(PAGE_BACKGROUND[resolveBackgroundMode(theme)])
|
|
||||||
applyNativeTheme(theme)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
|
||||||
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
|
||||||
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
|
||||||
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
|
||||||
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
|
||||||
setMediaItemDownloaded(id, filePath)
|
|
||||||
)
|
|
||||||
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
|
||||||
// and resolves with the final result.
|
|
||||||
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
|
||||||
indexSource(url, (p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
|
||||||
const src = getSource(id)
|
|
||||||
if (!src) return { ok: false, error: 'Source not found.' }
|
|
||||||
return indexSource(src.url, (p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
|
||||||
setSourceWatched(id, watched)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
|
||||||
syncWatchedSources((p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
|
||||||
|
|
||||||
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
|
|
||||||
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
||||||
if (p.mode === 'none') {
|
|
||||||
mainWindow.setProgressBar(-1)
|
|
||||||
mainWindow.setOverlayIcon(null, '')
|
|
||||||
mainWindow.setTitle('AeroFetch')
|
|
||||||
} else {
|
|
||||||
mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
|
||||||
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
|
|
||||||
const n = p.badgeCount ?? 0
|
|
||||||
const label =
|
|
||||||
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
|
|
||||||
mainWindow.setOverlayIcon(badge, label)
|
|
||||||
mainWindow.setTitle(p.mode === 'error' ? 'AeroFetch — Error' : `AeroFetch — ${label}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
|
|
||||||
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
|
|
||||||
const token = await openPoTokenWindow()
|
|
||||||
if (token) await setSettings({ youtubePoToken: token })
|
|
||||||
return token
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push OS theme/contrast changes to every window, and keep the native
|
// Push OS theme/contrast changes to every window, and keep the native
|
||||||
// background in sync for windows currently following 'system'.
|
// background in sync for windows currently following 'system'.
|
||||||
function registerSystemThemeBridge(): void {
|
function registerSystemThemeBridge(): void {
|
||||||
@@ -510,7 +242,7 @@ if (isPrimaryInstance) {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers(() => mainWindow)
|
||||||
registerSystemThemeBridge()
|
registerSystemThemeBridge()
|
||||||
registerSendToShortcut()
|
registerSendToShortcut()
|
||||||
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
|
import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +51,7 @@ function probeFlat(url: string): Promise<FlatInfo> {
|
|||||||
execFile(
|
execFile(
|
||||||
getYtdlpPath(),
|
getYtdlpPath(),
|
||||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||||
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
|
{ windowsHide: true, maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
const msg = (err as { killed?: boolean }).killed
|
const msg = (err as { killed?: boolean }).killed
|
||||||
|
|||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* IPC surface for the main process. Every `ipcMain.handle` (and the one
|
||||||
|
* fire-and-forget `taskbarProgress` receiver) lives here, extracted from index.ts
|
||||||
|
* so that module is just app lifecycle + window creation and this is the single
|
||||||
|
* place the renderer's IPC contract is wired (L2).
|
||||||
|
*
|
||||||
|
* The handful of handlers that must touch the window (folder picker parenting,
|
||||||
|
* theme-synced background, taskbar progress) get the current window through the
|
||||||
|
* `getMainWindow` accessor rather than a captured reference, so they always act on
|
||||||
|
* the live window. The small theme helpers they share are defined and exported
|
||||||
|
* here too, since index's system-theme bridge uses them as well.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import {
|
||||||
|
IpcChannels,
|
||||||
|
type StartDownloadOptions,
|
||||||
|
type Settings,
|
||||||
|
type HistoryEntry,
|
||||||
|
type CommandTemplate,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
type SystemThemeInfo,
|
||||||
|
type TaskbarProgress
|
||||||
|
} from '@shared/ipc'
|
||||||
|
import { PAGE_BACKGROUND } from '@shared/theme'
|
||||||
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
|
import { probeMedia } from './probe'
|
||||||
|
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||||
|
import { runTerminal, cancelTerminal } from './terminal'
|
||||||
|
import { getSettings, setSettings } from './settings'
|
||||||
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
|
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||||
|
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||||
|
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||||
|
import { exportBackup, importBackup } from './backup'
|
||||||
|
import {
|
||||||
|
listSources,
|
||||||
|
getSource,
|
||||||
|
removeSource,
|
||||||
|
listMediaItems,
|
||||||
|
setMediaItemDownloaded,
|
||||||
|
setSourceWatched
|
||||||
|
} from './sources'
|
||||||
|
import { indexSource } from './indexer'
|
||||||
|
import { syncWatchedSources } from './sync'
|
||||||
|
import { getScheduledSync, setScheduledSync } from './schedule'
|
||||||
|
import { getActiveBadge, getErrorBadge } from './badge'
|
||||||
|
import { openPoTokenWindow } from './poToken'
|
||||||
|
|
||||||
|
// --- Theme helpers (shared with index's system-theme bridge) ----------------
|
||||||
|
|
||||||
|
/** Resolve 'system' against the OS preference so callers always get a concrete color. */
|
||||||
|
export function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
|
||||||
|
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
|
||||||
|
* themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
|
||||||
|
* restores OS control. Called at startup and on every theme change.
|
||||||
|
*/
|
||||||
|
export function applyNativeTheme(theme: Settings['theme']): void {
|
||||||
|
nativeTheme.themeSource = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemThemeInfo(): SystemThemeInfo {
|
||||||
|
return {
|
||||||
|
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
||||||
|
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IPC registration -------------------------------------------------------
|
||||||
|
|
||||||
|
export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||||
|
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
|
||||||
|
// mode. Catch it here and show a hard error so the failure is never invisible.
|
||||||
|
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'AeroFetch could not start',
|
||||||
|
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
|
||||||
|
)
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
||||||
|
downloadAppUpdate(url, e.sender)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||||
|
const result = startDownload(e.sender, opts)
|
||||||
|
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
|
||||||
|
// download.ts's own close/error handlers, so log them here instead.
|
||||||
|
if (!result.ok) {
|
||||||
|
addErrorLog({
|
||||||
|
id: opts.id,
|
||||||
|
url: opts.url,
|
||||||
|
kind: opts.kind,
|
||||||
|
error: result.error ?? 'Unknown error',
|
||||||
|
occurredAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
||||||
|
runTerminal(e.sender, id, args)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||||
|
// Seed the picker at the currently-configured folder so it opens where the
|
||||||
|
// user already points, not a generic default (W5). 'createDirectory' is a
|
||||||
|
// macOS-only property and a no-op on Windows, so it's dropped (L58).
|
||||||
|
const defaultPath = current && existsSync(current) ? current : undefined
|
||||||
|
// Use the parented overload only when we actually have a window — passing a
|
||||||
|
// forced non-null window that's gone can throw (L53).
|
||||||
|
const res = win
|
||||||
|
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
|
||||||
|
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
|
||||||
|
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
||||||
|
ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(url)
|
||||||
|
if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url)
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
||||||
|
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
||||||
|
const result = setSettings(partial)
|
||||||
|
// Keep the window's native background and title bar in sync with the theme
|
||||||
|
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
|
||||||
|
// caption always matches the in-app theme (W3). Use the validated result.
|
||||||
|
if (partial.theme) {
|
||||||
|
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
||||||
|
PAGE_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||||
|
)
|
||||||
|
applyNativeTheme(result.theme)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
|
||||||
|
shell.openExternal('ms-settings:easeofaccess-highcontrast')
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.historyList, () => listHistory())
|
||||||
|
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||||
|
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
||||||
|
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
||||||
|
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
|
||||||
|
// Parent the sign-in window to the app window (W6) so it groups under
|
||||||
|
// AeroFetch instead of spawning a second taskbar button.
|
||||||
|
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||||
|
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
||||||
|
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
|
||||||
|
saveTemplate(template)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||||
|
previewCommand(opts)
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
|
||||||
|
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.backupExport, (e) =>
|
||||||
|
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.backupImport, async (e) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||||
|
const result = await importBackup(win)
|
||||||
|
// A restored backup may have changed the theme; keep the native window
|
||||||
|
// background and title bar in sync the same way settingsSet does (W3).
|
||||||
|
if (result.ok) {
|
||||||
|
const theme = getSettings().theme
|
||||||
|
win?.setBackgroundColor(PAGE_BACKGROUND[resolveBackgroundMode(theme)])
|
||||||
|
applyNativeTheme(theme)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
||||||
|
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
||||||
|
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
||||||
|
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
||||||
|
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
||||||
|
setMediaItemDownloaded(id, filePath)
|
||||||
|
)
|
||||||
|
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
||||||
|
// and resolves with the final result.
|
||||||
|
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
||||||
|
indexSource(url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
||||||
|
const src = getSource(id)
|
||||||
|
if (!src) return { ok: false, error: 'Source not found.' }
|
||||||
|
return indexSource(src.url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||||
|
setSourceWatched(id, watched)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||||
|
syncWatchedSources((p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
||||||
|
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
||||||
|
|
||||||
|
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
|
||||||
|
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (!win || win.isDestroyed()) return
|
||||||
|
if (p.mode === 'none') {
|
||||||
|
win.setProgressBar(-1)
|
||||||
|
win.setOverlayIcon(null, '')
|
||||||
|
win.setTitle('AeroFetch')
|
||||||
|
} else {
|
||||||
|
win.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
||||||
|
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
|
||||||
|
const n = p.badgeCount ?? 0
|
||||||
|
const label =
|
||||||
|
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
|
||||||
|
win.setOverlayIcon(badge, label)
|
||||||
|
win.setTitle(p.mode === 'error' ? 'AeroFetch — Error' : `AeroFetch — ${label}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
|
||||||
|
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
|
||||||
|
const token = await openPoTokenWindow()
|
||||||
|
if (token) await setSettings({ youtubePoToken: token })
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem path helpers for the main process. Kept separate from settings.ts
|
||||||
|
* (the electron-store persistence layer) so path derivation and the settings
|
||||||
|
* store don't share a module — they have different concerns and dependencies (L69).
|
||||||
|
* All resolve app paths, so callers must run post-`app.ready`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||||
|
export function getDownloadArchivePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'download-archive.txt')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default per-kind download destination: video → Documents\Video,
|
||||||
|
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||||
|
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||||
|
*/
|
||||||
|
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||||
|
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||||
|
* at startup) so they exist the moment the app opens, not just after the first
|
||||||
|
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||||
|
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||||
|
*/
|
||||||
|
export function ensureMediaDirs(): void {
|
||||||
|
for (const kind of ['video', 'audio'] as const) {
|
||||||
|
try {
|
||||||
|
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the download path will be created on demand instead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
|
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
|
||||||
import { fmtBytes } from '@shared/format'
|
import { fmtBytes } from '@shared/format'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
@@ -133,7 +134,7 @@ export function probeMedia(url: string): Promise<ProbeResult> {
|
|||||||
ytdlp,
|
ytdlp,
|
||||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||||
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
||||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
{ windowsHide: true, maxBuffer: PROBE_MAX_BUFFER, timeout: PROBE_TIMEOUT_MS },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
// execFile sets `killed` when it terminated the process on timeout.
|
// execFile sets `killed` when it terminated the process on timeout.
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import { join } from 'path'
|
|
||||||
import { mkdirSync } from 'fs'
|
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
import {
|
import {
|
||||||
@@ -43,36 +41,6 @@ export function applyLaunchAtStartup(enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
|
||||||
export function getDownloadArchivePath(): string {
|
|
||||||
return join(app.getPath('userData'), 'download-archive.txt')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default per-kind download destination: video → Documents\Video,
|
|
||||||
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
|
||||||
* folder (Settings → Download folder), so downloads are sorted by type.
|
|
||||||
*/
|
|
||||||
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
|
||||||
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the Documents\Video and Documents\Audio folders up front (called once
|
|
||||||
* at startup) so they exist the moment the app opens, not just after the first
|
|
||||||
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
|
||||||
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
|
||||||
*/
|
|
||||||
export function ensureMediaDirs(): void {
|
|
||||||
for (const kind of ['video', 'audio'] as const) {
|
|
||||||
try {
|
|
||||||
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
|
||||||
} catch {
|
|
||||||
/* non-fatal — the download path will be created on demand instead */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||||
// files (which predate downloadOptions) and to validate renderer writes.
|
// files (which predate downloadOptions) and to validate renderer writes.
|
||||||
|
|||||||
+5
-5
@@ -16,11 +16,11 @@ import type { Source, MediaItem } from '@shared/ipc'
|
|||||||
import { isValidSource, isValidMediaItem } from './validation'
|
import { isValidSource, isValidMediaItem } from './validation'
|
||||||
import { mergeItemsPreservingState } from './indexerCore'
|
import { mergeItemsPreservingState } from './indexerCore'
|
||||||
import { createJsonStore } from './jsonStore'
|
import { createJsonStore } from './jsonStore'
|
||||||
|
// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) so a runaway index
|
||||||
// A generous global cap so a runaway index can't grow the file unbounded; large
|
// can't grow the file unbounded; large enough for several big channels. When
|
||||||
// enough for several big channels. When exceeded, the most-recently-written
|
// exceeded, the most-recently-written source's items are kept (they're placed
|
||||||
// source's items are kept (they're placed first by replaceMediaItems).
|
// first by replaceMediaItems).
|
||||||
const MAX_ITEMS = 20000
|
import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants'
|
||||||
|
|
||||||
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
||||||
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@
|
|||||||
import { listSources, listMediaItems } from './sources'
|
import { listSources, listMediaItems } from './sources'
|
||||||
import { indexSource } from './indexer'
|
import { indexSource } from './indexer'
|
||||||
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
||||||
|
import { FEED_FETCH_TIMEOUT_MS } from './constants'
|
||||||
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
||||||
|
|
||||||
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
||||||
@@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise<string[]> {
|
|||||||
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
||||||
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
||||||
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
||||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
|
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
|
||||||
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
||||||
return parseRssVideoIds(await res.text())
|
return parseRssVideoIds(await res.text())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { CommandTemplate } from '@shared/ipc'
|
import type { CommandTemplate } from '@shared/ipc'
|
||||||
import { isTemplateLike } from './validation'
|
import { isTemplateLike } from './validation'
|
||||||
import { createJsonStore } from './jsonStore'
|
import { createJsonStore } from './jsonStore'
|
||||||
|
import { TEMPLATES_MAX } from './constants'
|
||||||
|
|
||||||
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
|
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
|
||||||
// backup / caching come from the shared jsonStore (R1–R3).
|
// backup / caching come from the shared jsonStore (R1–R3).
|
||||||
const MAX_TEMPLATES = 100
|
|
||||||
|
|
||||||
// A persisted template entry must at least be an object carrying an id (see
|
// A persisted template entry must at least be an object carrying an id (see
|
||||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||||
// malformed entries. (audit S5)
|
// malformed entries. (audit S5)
|
||||||
const store = createJsonStore('templates.json', isTemplateLike, MAX_TEMPLATES)
|
const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
|
||||||
|
|
||||||
export function listTemplates(): CommandTemplate[] {
|
export function listTemplates(): CommandTemplate[] {
|
||||||
// Normalise each surviving entry through sanitize() so name/args are always
|
// Normalise each surviving entry through sanitize() so name/args are always
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
|
|||||||
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
|
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings } from './settings'
|
||||||
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +48,7 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
|||||||
execFile(
|
execFile(
|
||||||
ytdlpPath,
|
ytdlpPath,
|
||||||
['--version'],
|
['--version'],
|
||||||
{ windowsHide: true, timeout: 15_000 },
|
{ windowsHide: true, timeout: VERSION_TIMEOUT_MS },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
const msg = (err as { killed?: boolean }).killed
|
const msg = (err as { killed?: boolean }).killed
|
||||||
@@ -93,7 +94,7 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
|
|||||||
execFile(
|
execFile(
|
||||||
ytdlpPath,
|
ytdlpPath,
|
||||||
['--update-to', channel],
|
['--update-to', channel],
|
||||||
{ windowsHide: true, timeout: 60_000 },
|
{ windowsHide: true, timeout: YTDLP_UPDATE_TIMEOUT_MS },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
const msg = (err as { killed?: boolean }).killed
|
const msg = (err as { killed?: boolean }).killed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { DownloadsView } from './components/DownloadsView'
|
import { DownloadsView } from './components/DownloadsView'
|
||||||
import { LibraryView } from './components/LibraryView'
|
import { LibraryView } from './components/LibraryView'
|
||||||
import { HistoryView } from './components/HistoryView'
|
import { HistoryView } from './components/HistoryView'
|
||||||
@@ -11,6 +11,7 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
|||||||
import { LiveRegion } from './components/LiveRegion'
|
import { LiveRegion } from './components/LiveRegion'
|
||||||
import { getTheme, pageBackground } from './theme'
|
import { getTheme, pageBackground } from './theme'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
|
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
|
||||||
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
|
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
|
||||||
@@ -22,6 +23,10 @@ import { summarizeQueue } from './store/queueStats'
|
|||||||
import { useResolvedDark } from './store/systemTheme'
|
import { useResolvedDark } from './store/systemTheme'
|
||||||
import { logError } from './reportError'
|
import { logError } from './reportError'
|
||||||
|
|
||||||
|
// How often to check whether a scheduled ('saved' + due) download should promote
|
||||||
|
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
|
||||||
|
const SCHEDULE_TICK_MS = 15_000
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
provider: {
|
provider: {
|
||||||
height: '100vh'
|
height: '100vh'
|
||||||
@@ -46,7 +51,8 @@ function App(): React.JSX.Element {
|
|||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
const showTerminal = useSettings((s) => s.customCommandEnabled)
|
const showTerminal = useSettings((s) => s.customCommandEnabled)
|
||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const [tab, setTab] = useState<TabValue>('downloads')
|
const tab = useNav((s) => s.tab)
|
||||||
|
const setTab = useNav((s) => s.setTab)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
// Track the element that was focused before the palette opened so we can restore it on close.
|
// Track the element that was focused before the palette opened so we can restore it on close.
|
||||||
const prePaletteRef = useRef<Element | null>(null)
|
const prePaletteRef = useRef<Element | null>(null)
|
||||||
@@ -92,6 +98,27 @@ function App(): React.JSX.Element {
|
|||||||
return useDownloads.subscribe((st) => push(st.items))
|
return useDownloads.subscribe((st) => push(st.items))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||||
|
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
||||||
|
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
||||||
|
// Lives here (not at the downloads store's module load) so importing the store in
|
||||||
|
// tests/preview doesn't start a stray timer, and the interval is cleared cleanly
|
||||||
|
// on unmount (L1).
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
const st = useDownloads.getState()
|
||||||
|
const now = Date.now()
|
||||||
|
if (
|
||||||
|
st.items.some(
|
||||||
|
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
st.promoteDueScheduled()
|
||||||
|
}
|
||||||
|
}, SCHEDULE_TICK_MS)
|
||||||
|
return () => clearInterval(tick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Memoized so CommandPalette sees a stable array reference between renders --
|
// Memoized so CommandPalette sees a stable array reference between renders --
|
||||||
// only rebuilds when the theme label needs to change (L32).
|
// only rebuilds when the theme label needs to change (L32).
|
||||||
const paletteActions = useMemo<PaletteAction[]>(
|
const paletteActions = useMemo<PaletteAction[]>(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
ErrorCircleRegular,
|
ErrorCircleRegular,
|
||||||
LinkRegular,
|
LinkRegular,
|
||||||
|
LibraryRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
CutRegular,
|
CutRegular,
|
||||||
CalendarClockRegular,
|
CalendarClockRegular,
|
||||||
@@ -62,7 +63,8 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
selected,
|
selected,
|
||||||
allSelected,
|
allSelected,
|
||||||
suggestion,
|
suggestion,
|
||||||
suggestionSource
|
suggestionSource,
|
||||||
|
channelHint
|
||||||
} = bar
|
} = bar
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,20 +82,23 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
onChange={(_, d) => bar.onUrlChange(d.value)}
|
onChange={(_, d) => bar.onUrlChange(d.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== 'Enter') return
|
if (e.key !== 'Enter') return
|
||||||
if (info || probeError || playlist) bar.download()
|
// Enter is "go": start the download (or add a fetched playlist). Preview
|
||||||
else void bar.fetchFormats()
|
// formats stays an explicit, optional click on the button (UX2).
|
||||||
|
if (playlist) {
|
||||||
|
if (selected.size > 0) bar.addPlaylist()
|
||||||
|
} else bar.download()
|
||||||
}}
|
}}
|
||||||
placeholder="Paste a video or playlist URL, then check…"
|
placeholder="Paste a video or playlist URL, then press Enter to download"
|
||||||
size="large"
|
size="large"
|
||||||
contentBefore={<ArrowDownloadRegular />}
|
contentBefore={<ArrowDownloadRegular />}
|
||||||
/>
|
/>
|
||||||
<Hint label="Check URL" placement="bottom" align="end">
|
<Hint label="Preview available formats (optional)" placement="bottom" align="end">
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
|
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||||
onClick={bar.fetchFormats}
|
onClick={bar.fetchFormats}
|
||||||
disabled={!url.trim() || probing}
|
disabled={!url.trim() || probing}
|
||||||
aria-label="Check URL for formats or playlist"
|
aria-label="Preview available formats (optional)"
|
||||||
/>
|
/>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Paste from clipboard" placement="bottom" align="end">
|
<Hint label="Paste from clipboard" placement="bottom" align="end">
|
||||||
@@ -126,6 +131,26 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{channelHint && (
|
||||||
|
<div className={styles.channelHint}>
|
||||||
|
<LibraryRegular />
|
||||||
|
<Caption1 className={styles.channelHintText}>
|
||||||
|
This looks like a channel or playlist. Add it in the Library to download many videos at
|
||||||
|
once.
|
||||||
|
</Caption1>
|
||||||
|
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
|
||||||
|
Open in Library
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={bar.dismissChannelHint}
|
||||||
|
aria-label="Dismiss Library suggestion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{dup && (
|
{dup && (
|
||||||
<div className={styles.dupRow}>
|
<div className={styles.dupRow}>
|
||||||
<WarningRegular />
|
<WarningRegular />
|
||||||
|
|||||||
@@ -120,11 +120,17 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{kind !== 'audio' && (
|
{kind !== 'audio' && (
|
||||||
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
|
<Field
|
||||||
|
label="Video container"
|
||||||
|
hint="The file format used when video and audio are combined into one file."
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
aria-label="Video container"
|
aria-label="Video container"
|
||||||
value={value.videoContainer}
|
value={value.videoContainer}
|
||||||
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
options={VIDEO_CONTAINERS.map((c) => ({
|
||||||
|
value: c,
|
||||||
|
label: VIDEO_CONTAINER_LABELS[c]
|
||||||
|
}))}
|
||||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
SearchRegular,
|
AddRegular,
|
||||||
ArrowSyncRegular,
|
ArrowSyncRegular,
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
@@ -31,6 +31,7 @@ import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
|||||||
import { isPreview as PREVIEW } from '../isPreview'
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { useSources } from '../store/sources'
|
import { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useNav } from '../store/nav'
|
||||||
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
||||||
import { logError } from '../reportError'
|
import { logError } from '../reportError'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
@@ -269,6 +270,16 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
|
||||||
|
// add field with it once, so the user lands here ready to add it.
|
||||||
|
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
|
||||||
|
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingLibraryUrl === null) return
|
||||||
|
const handed = consumeLibraryUrl()
|
||||||
|
if (handed) setUrl(handed)
|
||||||
|
}, [pendingLibraryUrl, consumeLibraryUrl])
|
||||||
|
|
||||||
// Reset any pending Remove confirmation when the expanded source changes so a
|
// Reset any pending Remove confirmation when the expanded source changes so a
|
||||||
// stale confirm can't reappear after navigating between sources.
|
// stale confirm can't reappear after navigating between sources.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -376,7 +387,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
if (!u || indexing.active) return
|
if (!u || indexing.active) return
|
||||||
const res = await indexSource(u)
|
const res = await indexSource(u)
|
||||||
if (res.ok) setUrl('')
|
if (res.ok) setUrl('')
|
||||||
else setError(res.error ?? 'Could not index that link.')
|
else setError(res.error ?? 'Could not add that link.')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(id: string, on: boolean): void {
|
function toggle(id: string, on: boolean): void {
|
||||||
@@ -469,11 +480,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<span className={styles.groupTitle}>{row.title}</span>
|
<span className={styles.groupTitle}>{row.title}</span>
|
||||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
onClick={() => toggleGroup(row.items, !allOn)}
|
|
||||||
>
|
|
||||||
{allOn ? 'None' : 'All'}
|
{allOn ? 'None' : 'All'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -519,7 +526,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="Library"
|
title="Library"
|
||||||
description="Index a channel or playlist once, then download it into organized folders."
|
description="Add a channel or playlist once, then download its videos into organized folders."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.addRow}>
|
<div className={styles.addRow}>
|
||||||
@@ -529,7 +536,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(_, d) => setUrl(d.value)}
|
onChange={(_, d) => setUrl(d.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||||
placeholder="Paste a channel or playlist URL…"
|
placeholder="Paste a channel or playlist URL to add it…"
|
||||||
size="large"
|
size="large"
|
||||||
contentBefore={<LibraryRegular />}
|
contentBefore={<LibraryRegular />}
|
||||||
disabled={indexing.active}
|
disabled={indexing.active}
|
||||||
@@ -537,11 +544,11 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
|
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
|
||||||
onClick={onIndex}
|
onClick={onIndex}
|
||||||
disabled={!url.trim() || indexing.active}
|
disabled={!url.trim() || indexing.active}
|
||||||
>
|
>
|
||||||
Index
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -603,7 +610,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<div className={styles.progress}>
|
<div className={styles.progress}>
|
||||||
<Spinner size="tiny" />
|
<Spinner size="tiny" />
|
||||||
<Text>
|
<Text>
|
||||||
{indexing.message ?? 'Indexing…'}
|
{indexing.message ?? 'Adding…'}
|
||||||
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +620,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
{sources.length === 0 && !indexing.active ? (
|
{sources.length === 0 && !indexing.active ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<LibraryRegular fontSize={40} />
|
<LibraryRegular fontSize={40} />
|
||||||
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
|
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
@@ -692,7 +699,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
onClick={() => reindexSource(src.id)}
|
onClick={() => reindexSource(src.id)}
|
||||||
disabled={indexing.active}
|
disabled={indexing.active}
|
||||||
>
|
>
|
||||||
Re-index
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{confirmRemoveId === src.id ? (
|
{confirmRemoveId === src.id ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
@@ -14,7 +15,10 @@ import {
|
|||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
RocketRegular
|
RocketRegular,
|
||||||
|
FolderRegular,
|
||||||
|
MusicNote2Regular,
|
||||||
|
VideoClipRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
|
||||||
@@ -55,7 +59,41 @@ const useStyles = makeStyles({
|
|||||||
fontSize: '26px'
|
fontSize: '26px'
|
||||||
},
|
},
|
||||||
folderNote: {
|
folderNote: {
|
||||||
color: tokens.colorNeutralForeground3
|
color: tokens.colorNeutralForeground3,
|
||||||
|
marginBottom: '8px'
|
||||||
|
},
|
||||||
|
folderRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
folderRowGap: {
|
||||||
|
marginTop: '8px'
|
||||||
|
},
|
||||||
|
folderIcon: {
|
||||||
|
fontSize: '18px',
|
||||||
|
flexShrink: 0,
|
||||||
|
color: tokens.colorCompoundBrandForeground1
|
||||||
|
},
|
||||||
|
folderCol: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
folderLabel: {
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
folderPath: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -93,6 +131,9 @@ 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 update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
const videoDir = useSettings((s) => s.videoDir)
|
||||||
|
const audioDir = useSettings((s) => s.audioDir)
|
||||||
|
const chooseDir = useSettings((s) => s.chooseDir)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -111,10 +152,33 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
|
|
||||||
<Field label="Where downloads go">
|
<Field label="Where downloads go">
|
||||||
<Caption1 className={styles.folderNote}>
|
<Caption1 className={styles.folderNote}>
|
||||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
Videos and audio save to your Documents folders by default. Pick a different folder now,
|
||||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
|
or change it any time in Settings.
|
||||||
Settings.
|
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<VideoClipRegular className={styles.folderIcon} />
|
||||||
|
<div className={styles.folderCol}>
|
||||||
|
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
||||||
|
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
|
||||||
|
{videoDir || 'Documents\\Video'}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
|
Choose…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
|
||||||
|
<MusicNote2Regular className={styles.folderIcon} />
|
||||||
|
<div className={styles.folderCol}>
|
||||||
|
<Caption1 className={styles.folderLabel}>Audio</Caption1>
|
||||||
|
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
|
||||||
|
{audioDir || 'Documents\\Audio'}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||||
|
Choose…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className={styles.tips}>
|
<div className={styles.tips}>
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
||||||
<div data-settings-search>
|
<div data-settings-search>
|
||||||
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." />
|
<ScreenHeader
|
||||||
|
title="Settings"
|
||||||
|
description="Folders, formats, network, cookies, and more."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div data-settings-search>
|
<div data-settings-search>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ export const useDownloadBarStyles = makeStyles({
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
},
|
},
|
||||||
|
// The channel/playlist nudge toward the Library (UX3): same brand-tinted banner
|
||||||
|
// as the link suggestion, but its message wraps instead of truncating.
|
||||||
|
channelHint: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 8px 8px 12px',
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||||
|
},
|
||||||
|
channelHintText: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
},
|
||||||
url: {
|
url: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { useDownloads, type MediaKind } from '../../store/downloads'
|
|||||||
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||||
import { sameVideo } from '../../store/queueStats'
|
import { sameVideo } from '../../store/queueStats'
|
||||||
import { useSettings } from '../../store/settings'
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useNav } from '../../store/nav'
|
||||||
import {
|
import {
|
||||||
useClipboardLink,
|
useClipboardLink,
|
||||||
looksLikeUrl,
|
looksLikeUrl,
|
||||||
|
looksLikeChannelOrPlaylist,
|
||||||
firstUrlInText,
|
firstUrlInText,
|
||||||
type SuggestionSource
|
type SuggestionSource
|
||||||
} from '../../useClipboardLink'
|
} from '../../useClipboardLink'
|
||||||
@@ -77,6 +79,10 @@ export interface DownloadBarController {
|
|||||||
suggestion: string | null
|
suggestion: string | null
|
||||||
suggestionSource: SuggestionSource
|
suggestionSource: SuggestionSource
|
||||||
dismissSuggestion: () => void
|
dismissSuggestion: () => void
|
||||||
|
// channel/playlist nudge → Library (UX3)
|
||||||
|
channelHint: boolean
|
||||||
|
openInLibrary: () => void
|
||||||
|
dismissChannelHint: () => void
|
||||||
// handlers
|
// handlers
|
||||||
onDragOver: (e: React.DragEvent) => void
|
onDragOver: (e: React.DragEvent) => void
|
||||||
onDrop: (e: React.DragEvent) => void
|
onDrop: (e: React.DragEvent) => void
|
||||||
@@ -108,6 +114,7 @@ export interface DownloadBarController {
|
|||||||
export function useDownloadBar(): DownloadBarController {
|
export function useDownloadBar(): DownloadBarController {
|
||||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||||
const addMany = useDownloads((s) => s.addMany)
|
const addMany = useDownloads((s) => s.addMany)
|
||||||
|
const openLibraryWith = useNav((s) => s.openLibraryWith)
|
||||||
const settingsLoaded = useSettings((s) => s.loaded)
|
const settingsLoaded = useSettings((s) => s.loaded)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
@@ -128,6 +135,10 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
|
||||||
|
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
|
||||||
|
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
|
||||||
|
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
|
||||||
|
|
||||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||||
const [dup, setDup] = useState<string | null>(null)
|
const [dup, setDup] = useState<string | null>(null)
|
||||||
const confirmDup = useRef(false)
|
const confirmDup = useRef(false)
|
||||||
@@ -213,6 +224,16 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
|
||||||
|
// but only while the bar is still idle for it (nothing probed/resolved yet) and
|
||||||
|
// the user hasn't waved it away (UX3).
|
||||||
|
const channelHint =
|
||||||
|
looksLikeChannelOrPlaylist(url) &&
|
||||||
|
!channelHintDismissed &&
|
||||||
|
info === null &&
|
||||||
|
playlist === null &&
|
||||||
|
probeError === null
|
||||||
|
|
||||||
function clearProbe(): void {
|
function clearProbe(): void {
|
||||||
setInfo(null)
|
setInfo(null)
|
||||||
setProbeError(null)
|
setProbeError(null)
|
||||||
@@ -260,6 +281,8 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
if (dup) setDup(null)
|
if (dup) setDup(null)
|
||||||
setCommandPreview(null)
|
setCommandPreview(null)
|
||||||
confirmDup.current = false
|
confirmDup.current = false
|
||||||
|
// A new URL gets a fresh chance to nudge toward the Library.
|
||||||
|
setChannelHintDismissed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKindChange(next: MediaKind): void {
|
function onKindChange(next: MediaKind): void {
|
||||||
@@ -324,6 +347,22 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
|
||||||
|
// add field with it, and clear the bar (the URL now lives over there) (UX3).
|
||||||
|
function openInLibrary(): void {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
openLibraryWith(trimmed)
|
||||||
|
setUrl('')
|
||||||
|
// Clear the URL-specific command preview too, so no stale command lingers over
|
||||||
|
// the now-empty bar (the same invariant onUrlChange keeps).
|
||||||
|
setCommandPreview(null)
|
||||||
|
clearProbe()
|
||||||
|
}
|
||||||
|
function dismissChannelHint(): void {
|
||||||
|
setChannelHintDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Selection helpers for the playlist panel.
|
// Selection helpers for the playlist panel.
|
||||||
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
||||||
function toggleEntry(index: number, on: boolean): void {
|
function toggleEntry(index: number, on: boolean): void {
|
||||||
@@ -497,6 +536,10 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
suggestion,
|
suggestion,
|
||||||
suggestionSource,
|
suggestionSource,
|
||||||
dismissSuggestion,
|
dismissSuggestion,
|
||||||
|
// channel/playlist nudge → Library (UX3)
|
||||||
|
channelHint,
|
||||||
|
openInLibrary,
|
||||||
|
dismissChannelHint,
|
||||||
// handlers
|
// handlers
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export function AppearanceCard(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={mergeClasses(styles.swatch, accentColor === opt.value && styles.swatchActive)}
|
className={mergeClasses(
|
||||||
|
styles.swatch,
|
||||||
|
accentColor === opt.value && styles.swatchActive
|
||||||
|
)}
|
||||||
style={{ backgroundColor: opt.swatch }}
|
style={{ backgroundColor: opt.swatch }}
|
||||||
onClick={() => update({ accentColor: opt.value as AccentColor })}
|
onClick={() => update({ accentColor: opt.value as AccentColor })}
|
||||||
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
|
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export function CustomCommandsCard(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||||
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
|
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other option,
|
||||||
option, so a template flag can override a setting above it.
|
so a template flag can override a setting above it.
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function DiagnosticsCard(): React.JSX.Element {
|
|||||||
</Caption1>
|
</Caption1>
|
||||||
|
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Button icon={<CopyRegular />} onClick={copyErrorReport} disabled={errorEntries.length === 0}>
|
<Button
|
||||||
|
icon={<CopyRegular />}
|
||||||
|
onClick={copyErrorReport}
|
||||||
|
disabled={errorEntries.length === 0}
|
||||||
|
>
|
||||||
Copy full report
|
Copy full report
|
||||||
</Button>
|
</Button>
|
||||||
{confirmClearLog ? (
|
{confirmClearLog ? (
|
||||||
@@ -77,7 +81,9 @@ export function DiagnosticsCard(): React.JSX.Element {
|
|||||||
<div key={e.id} className={styles.errorRow}>
|
<div key={e.id} className={styles.errorRow}>
|
||||||
<div className={styles.errorRowHeader}>
|
<div className={styles.errorRowHeader}>
|
||||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||||
<Caption1 className={styles.hint}>{new Date(e.occurredAt).toLocaleString()}</Caption1>
|
<Caption1 className={styles.hint}>
|
||||||
|
{new Date(e.occurredAt).toLocaleString()}
|
||||||
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Field, Input, SpinButton, Switch, Button, Card, Subtitle2 } from '@fluentui/react-components'
|
import {
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
SpinButton,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Subtitle2
|
||||||
|
} from '@fluentui/react-components'
|
||||||
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
|
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||||
import { type MediaKind } from '@shared/ipc'
|
import { type MediaKind } from '@shared/ipc'
|
||||||
import { useSettings } from '../../store/settings'
|
import { useSettings } from '../../store/settings'
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function FilenamesCard(): React.JSX.Element {
|
|||||||
label="Filename template"
|
label="Filename template"
|
||||||
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
|
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
|
||||||
>
|
>
|
||||||
<Input value={filenameTemplate} onChange={(_, d) => update({ filenameTemplate: d.value })} />
|
<Input
|
||||||
|
value={filenameTemplate}
|
||||||
|
onChange={(_, d) => update({ filenameTemplate: d.value })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
label="Rate limit"
|
label="Rate limit"
|
||||||
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
|
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
|
||||||
>
|
>
|
||||||
<Input value={rateLimit} placeholder="2M" onChange={(_, d) => update({ rateLimit: d.value })} />
|
<Input
|
||||||
|
value={rateLimit}
|
||||||
|
placeholder="2M"
|
||||||
|
onChange={(_, d) => update({ rateLimit: d.value })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -62,6 +62,34 @@ export function looksLikeSingleVideo(text: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for URLs that point at a whole channel or playlist rather than a single
|
||||||
|
* video — the shapes that belong in the Library (index once, download in bulk),
|
||||||
|
* not the one-off Downloads bar (UX3). Conservative on purpose: it only flags the
|
||||||
|
* clear YouTube channel and dedicated-playlist shapes, so a plain `/watch?v=…`
|
||||||
|
* video (even one carrying a `list=` context) is never flagged and keeps working
|
||||||
|
* in the Downloads bar; a non-YouTube link is never flagged.
|
||||||
|
*/
|
||||||
|
export function looksLikeChannelOrPlaylist(text: string): boolean {
|
||||||
|
let u: URL
|
||||||
|
try {
|
||||||
|
u = new URL(text.trim())
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const host = u.hostname.replace(/^www\./, '').toLowerCase()
|
||||||
|
const isYouTube =
|
||||||
|
host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com'
|
||||||
|
if (!isYouTube) return false
|
||||||
|
// A dedicated playlist page (not a /watch video that merely carries a list).
|
||||||
|
if (u.pathname === '/playlist' && u.searchParams.has('list')) return true
|
||||||
|
// Channel shapes: /channel/UC…, /c/Name, /user/Name, /@handle — with an optional
|
||||||
|
// /videos, /streams, /shorts, /playlists tab suffix.
|
||||||
|
if (/^\/(channel|c|user)\/[^/]+/i.test(u.pathname)) return true
|
||||||
|
if (/^\/@[^/]+/.test(u.pathname)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the first http(s) URL from a multi-line blob of text.
|
* Return the first http(s) URL from a multi-line blob of text.
|
||||||
* Lines are trimmed; '#' comment lines are skipped.
|
* Lines are trimmed; '#' comment lines are skipped.
|
||||||
|
|||||||
@@ -402,18 +402,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
// no direct import from sources, so the two stores stay acyclic.
|
// no direct import from sources, so the two stores stay acyclic.
|
||||||
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
|
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
|
||||||
|
|
||||||
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
// The scheduled-download promoter tick lives in App (a useEffect), not here, so
|
||||||
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
// importing this store in tests/preview doesn't start a stray never-cleared timer
|
||||||
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
// on module load (L1). See SCHEDULE_TICK_MS / App.tsx.
|
||||||
setInterval(() => {
|
|
||||||
const st = useDownloads.getState()
|
|
||||||
const now = Date.now()
|
|
||||||
if (
|
|
||||||
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
|
|
||||||
) {
|
|
||||||
st.promoteDueScheduled()
|
|
||||||
}
|
|
||||||
}, 15_000)
|
|
||||||
|
|
||||||
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
||||||
if (!PREVIEW) {
|
if (!PREVIEW) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { TabValue } from '../components/Sidebar'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active screen, owned in a store so any component can navigate without
|
||||||
|
* threading an `onTabChange` prop through the tree. It also carries a one-shot
|
||||||
|
* URL handoff: the Downloads bar detects a channel/playlist link (which belongs
|
||||||
|
* in the Library, not the one-off queue) and hands it here; the Library consumes
|
||||||
|
* it once to pre-fill its add field (UX3).
|
||||||
|
*/
|
||||||
|
interface NavState {
|
||||||
|
tab: TabValue
|
||||||
|
setTab: (tab: TabValue) => void
|
||||||
|
/** A channel/playlist URL handed from the Downloads bar to the Library. */
|
||||||
|
pendingLibraryUrl: string | null
|
||||||
|
/** Switch to the Library tab and stage `url` for its add field. */
|
||||||
|
openLibraryWith: (url: string) => void
|
||||||
|
/** Read and clear the staged Library URL (returns null if none is pending). */
|
||||||
|
consumeLibraryUrl: () => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNav = create<NavState>((set, get) => ({
|
||||||
|
tab: 'downloads',
|
||||||
|
setTab: (tab) => set({ tab }),
|
||||||
|
pendingLibraryUrl: null,
|
||||||
|
openLibraryWith: (url) => set({ tab: 'library', pendingLibraryUrl: url }),
|
||||||
|
consumeLibraryUrl: () => {
|
||||||
|
const url = get().pendingLibraryUrl
|
||||||
|
if (url !== null) set({ pendingLibraryUrl: null })
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -149,7 +149,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
|
|
||||||
reindexSource: async (id) => {
|
reindexSource: async (id) => {
|
||||||
const src = get().sources.find((s) => s.id === id)
|
const src = get().sources.find((s) => s.id === id)
|
||||||
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
|
set({ indexing: { active: true, url: src?.url, message: 'Refreshing…' } })
|
||||||
if (PREVIEW) {
|
if (PREVIEW) {
|
||||||
await new Promise((r) => setTimeout(r, 700))
|
await new Promise((r) => setTimeout(r, 700))
|
||||||
set({ indexing: { active: false } })
|
set({ indexing: { active: false } })
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { useSettings } from './store/settings'
|
|||||||
// Pure URL helpers extracted to a standalone module so they can be tested
|
// Pure URL helpers extracted to a standalone module so they can be tested
|
||||||
// without pulling in the Zustand store (L40). Re-exported here for existing
|
// without pulling in the Zustand store (L40). Re-exported here for existing
|
||||||
// consumers (DownloadBar, LibraryView) without an import-path change.
|
// consumers (DownloadBar, LibraryView) without an import-path change.
|
||||||
export { looksLikeUrl, looksLikeSingleVideo, firstUrlInText } from './lib/urlHelpers'
|
export {
|
||||||
|
looksLikeUrl,
|
||||||
|
looksLikeSingleVideo,
|
||||||
|
looksLikeChannelOrPlaylist,
|
||||||
|
firstUrlInText
|
||||||
|
} from './lib/urlHelpers'
|
||||||
import { looksLikeUrl } from './lib/urlHelpers'
|
import { looksLikeUrl } from './lib/urlHelpers'
|
||||||
|
|
||||||
/** Where an offered link came from — drives the banner's wording. */
|
/** Where an offered link came from — drives the banner's wording. */
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function build(
|
|||||||
access: AccessOptions = NO_ACCESS,
|
access: AccessOptions = NO_ACCESS,
|
||||||
extraArgs: string[] = []
|
extraArgs: string[] = []
|
||||||
): string[] {
|
): string[] {
|
||||||
return buildArgs(o, OUT, d, BIN, access, extraArgs)
|
return buildArgs({ opts: o, outputTemplate: OUT, options: d, binDir: BIN, access, extraArgs })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when `seq` appears as a contiguous run inside `argv`. */
|
/** True when `seq` appears as a contiguous run inside `argv`. */
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
// Import from the pure utility module directly — no Zustand store init needed (L40).
|
// Import from the pure utility module directly — no Zustand store init needed (L40).
|
||||||
import { looksLikeUrl, looksLikeSingleVideo, youtubeId } from '../src/renderer/src/lib/urlHelpers'
|
import {
|
||||||
|
looksLikeUrl,
|
||||||
|
looksLikeSingleVideo,
|
||||||
|
looksLikeChannelOrPlaylist,
|
||||||
|
youtubeId
|
||||||
|
} from '../src/renderer/src/lib/urlHelpers'
|
||||||
|
|
||||||
describe('youtubeId', () => {
|
describe('youtubeId', () => {
|
||||||
it('extracts the id from a watch URL', () => {
|
it('extracts the id from a watch URL', () => {
|
||||||
@@ -103,3 +108,37 @@ describe('looksLikeSingleVideo', () => {
|
|||||||
expect(looksLikeSingleVideo('')).toBe(false)
|
expect(looksLikeSingleVideo('')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('looksLikeChannelOrPlaylist', () => {
|
||||||
|
it('flags YouTube channel URLs', () => {
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/@SomeChannel')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/@SomeChannel/videos')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/channel/UC123')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/c/SomeChannel')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/user/SomeUser')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://m.youtube.com/@SomeChannel')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a dedicated playlist page', () => {
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/playlist?list=PL123')).toBe(true)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://music.youtube.com/playlist?list=PL123')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag single videos, even inside a playlist context', () => {
|
||||||
|
// A /watch video belongs in the Downloads bar, not the Library — even with a list.
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/watch?v=abc')).toBe(false)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/watch?v=abc&list=PL123')).toBe(false)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://youtu.be/dQw4w9WgXcQ')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag non-YouTube hosts or a bare playlist path without a list', () => {
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://vimeo.com/channels/staffpicks')).toBe(false)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://example.com/@handle')).toBe(false)
|
||||||
|
expect(looksLikeChannelOrPlaylist('https://www.youtube.com/playlist')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for non-URLs', () => {
|
||||||
|
expect(looksLikeChannelOrPlaylist('not a url')).toBe(false)
|
||||||
|
expect(looksLikeChannelOrPlaylist('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -154,7 +154,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
embedThumbnail: true,
|
embedThumbnail: true,
|
||||||
cropThumbnail: true
|
cropThumbnail: true
|
||||||
}
|
}
|
||||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(id)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: NO_ACCESS
|
||||||
|
})
|
||||||
console.log('[crop] argv:', JSON.stringify(argv))
|
console.log('[crop] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
@@ -191,7 +197,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
sponsorBlockMode: 'remove' as const,
|
sponsorBlockMode: 'remove' as const,
|
||||||
sponsorBlockCategories: ['music_offtopic' as const]
|
sponsorBlockCategories: ['music_offtopic' as const]
|
||||||
}
|
}
|
||||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(id)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: NO_ACCESS
|
||||||
|
})
|
||||||
console.log('[sb] argv:', JSON.stringify(argv))
|
console.log('[sb] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
@@ -219,7 +231,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
audioFormat: 'opus' as const,
|
audioFormat: 'opus' as const,
|
||||||
embedThumbnail: false
|
embedThumbnail: false
|
||||||
}
|
}
|
||||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(id)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: NO_ACCESS
|
||||||
|
})
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
||||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||||
@@ -248,7 +266,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
preferredVideoCodec: 'vp9' as const,
|
preferredVideoCodec: 'vp9' as const,
|
||||||
embedThumbnail: false
|
embedThumbnail: false
|
||||||
}
|
}
|
||||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(id)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: NO_ACCESS
|
||||||
|
})
|
||||||
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
||||||
@@ -285,7 +309,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
embedChapters: true,
|
embedChapters: true,
|
||||||
embedThumbnail: false
|
embedThumbnail: false
|
||||||
}
|
}
|
||||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(id)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: NO_ACCESS
|
||||||
|
})
|
||||||
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
||||||
@@ -320,7 +350,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
}
|
}
|
||||||
const access = { ...NO_ACCESS, restrictFilenames: true }
|
const access = { ...NO_ACCESS, restrictFilenames: true }
|
||||||
// %(title)s so the spaces in "Me at the zoo" actually exercise the sanitizer.
|
// %(title)s so the spaces in "Me at the zoo" actually exercise the sanitizer.
|
||||||
const argv = buildArgs(opts, join(outDir, '%(title)s.%(ext)s'), options, BIN_DIR, access)
|
const argv = buildArgs({
|
||||||
|
opts,
|
||||||
|
outputTemplate: join(outDir, '%(title)s.%(ext)s'),
|
||||||
|
options,
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access
|
||||||
|
})
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
||||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||||
@@ -335,13 +371,22 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
it('downloadArchive: a second run of the same URL is skipped via the archive', () => {
|
it('downloadArchive: a second run of the same URL is skipped via the archive', () => {
|
||||||
const archive = join(outDir, 'archive.txt')
|
const archive = join(outDir, 'archive.txt')
|
||||||
const mk = (id: string): string[] =>
|
const mk = (id: string): string[] =>
|
||||||
buildArgs(
|
buildArgs({
|
||||||
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
|
opts: {
|
||||||
join(outDir, 'arch-%(id)s.%(ext)s'),
|
id,
|
||||||
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
|
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||||
BIN_DIR,
|
kind: 'audio',
|
||||||
{ ...NO_ACCESS, downloadArchivePath: archive }
|
quality: 'Best'
|
||||||
)
|
},
|
||||||
|
outputTemplate: join(outDir, 'arch-%(id)s.%(ext)s'),
|
||||||
|
options: {
|
||||||
|
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
audioFormat: 'mp3' as const,
|
||||||
|
embedThumbnail: false
|
||||||
|
},
|
||||||
|
binDir: BIN_DIR,
|
||||||
|
access: { ...NO_ACCESS, downloadArchivePath: archive }
|
||||||
|
})
|
||||||
|
|
||||||
const first = runYtdlp(mk('a1'))
|
const first = runYtdlp(mk('a1'))
|
||||||
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
||||||
@@ -380,14 +425,14 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
'--write-info-json',
|
'--write-info-json',
|
||||||
'--no-mtime'
|
'--no-mtime'
|
||||||
])
|
])
|
||||||
const argv = buildArgs(
|
const argv = buildArgs({
|
||||||
opts,
|
opts,
|
||||||
join(outDir, 'extra-%(id)s.%(ext)s'),
|
outputTemplate: join(outDir, 'extra-%(id)s.%(ext)s'),
|
||||||
options,
|
options,
|
||||||
BIN_DIR,
|
binDir: BIN_DIR,
|
||||||
NO_ACCESS,
|
access: NO_ACCESS,
|
||||||
extra
|
extraArgs: extra
|
||||||
)
|
})
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
||||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user