diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 5181f95..893bfab 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -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 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 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, @@ -439,9 +451,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n ## Low -- [ ] **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 - `register(ipcMain)`. +- [x] **L1 — Module-load `setInterval` in `store/downloads.ts`** runs on import (incl. tests/preview). + *Fixed: the scheduled-download promoter tick moved out of store-module scope into an `App.tsx` + `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"; `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 @@ -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. - [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. -- [ ] **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). - 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 completed/error/canceled), not the active queue. - [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"). - [x] **L68 — Background-running notification fires once per process** (`notifiedBackground` never resets) — won't remind on later window closes. -- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/ - `ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`. +- [x] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/ + `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 (`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 @@ -968,23 +1002,51 @@ root cause is already filed. 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 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 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 (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 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 "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," "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 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 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 @@ -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 in `download.ts` (`line.startsWith('prog|')` / `'path|'`). Change one and the other breaks silently. **Fix:** export shared marker constants from one module. -- [ ] **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. -- [ ] **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). +- [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. *Fixed: `buildArgs(input: BuildArgsInput)` now takes one named object (`opts`/`outputTemplate`/ + `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` 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 diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 76ee4db..997c8c6 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -370,14 +370,29 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string return args } -export function buildArgs( - opts: StartDownloadOptions, - outputTemplate: string, - o: DownloadOptions, - binDir: string, - access: AccessOptions, - extraArgs: string[] = [] -): string[] { +/** + * Everything buildArgs needs to construct a yt-dlp argv. A single options object + * (rather than six positional params) so callers can't transpose `opts`/`options` + * or the two path-like strings, and new inputs can be added without churning every + * call site (CL2). + */ +export interface BuildArgsInput { + /** 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 = [ '--newline', '--no-color', diff --git a/src/main/constants.ts b/src/main/constants.ts new file mode 100644 index 0000000..c47075b --- /dev/null +++ b/src/main/constants.ts @@ -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) -------------------------------------------- + +/** ` -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 diff --git a/src/main/download.ts b/src/main/download.ts index 52f9abc..aeffdd0 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -13,7 +13,8 @@ import { getAppIconImage, YTDLP_MISSING_MSG } from './binaries' -import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings' +import { getSettings } from './settings' +import { getDownloadArchivePath, getDefaultMediaDir } from './paths' import { ensureManagedYtdlp } from './ytdlp' import { materializeCookies, hasStoredCookies } from './cookies' import { listTemplates } from './templates' @@ -29,6 +30,12 @@ import { } from './buildArgs' import { cleanError } from './log' import { addErrorLog } from './errorlog' +import { + STALL_TIMEOUT_MS, + META_PROBE_TIMEOUT_MS, + META_MAX_BUFFER, + STDERR_TAIL_BYTES +} from './constants' import { IpcChannels, type StartDownloadOptions, @@ -62,14 +69,6 @@ function releaseActive(id: string, rec: ActiveDownload): void { 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 * 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 { '--', url ], - { windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 }, + { windowsHide: true, maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }, (err, stdout) => { if (err) return resolve(null) 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 } 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. */ @@ -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 stderrTail = '' let filePath: string | undefined @@ -360,13 +387,13 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star if (settled || rec.canceled || rec.paused) return settled = true clearWatchdog() - cleanupCookies() + cleanup() releaseActive(opts.id, rec) killTree(rec) 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 }) - logFailure(opts, resolvedTitle, msg) - notify(wc, resolvedTitle ?? 'Download failed', msg) + logFailure(opts, getTitle(), msg) + notify(wc, getTitle() ?? 'Download failed', msg) }, STALL_TIMEOUT_MS) } bumpWatchdog() @@ -397,20 +424,20 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star child.stderr?.on('data', (chunk: Buffer) => { bumpWatchdog() - stderrTail = (stderrTail + chunk.toString()).slice(-4000) + stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES) }) child.on('error', (err) => { if (settled) return settled = true clearWatchdog() - cleanupCookies() + cleanup() releaseActive(opts.id, rec) // A paused download was killed on purpose — stay silent, like a cancel. if (!rec.canceled && !rec.paused) { send(wc, { type: 'error', id: opts.id, error: err.message }) - logFailure(opts, resolvedTitle, err.message) - notify(wc, resolvedTitle ?? 'Download failed', err.message) + logFailure(opts, getTitle(), err.message) + notify(wc, getTitle() ?? 'Download failed', err.message) } }) @@ -418,23 +445,21 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star if (settled) return settled = true clearWatchdog() - cleanupCookies() + cleanup() releaseActive(opts.id, rec) // Canceled: renderer already showed 'canceled'. Paused: renderer showed // 'paused' and keeps the .part for a later resume. Either way, no event. if (rec.canceled || rec.paused) return if (code === 0) { send(wc, { type: 'done', id: opts.id, filePath }) - notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.') + notify(wc, getTitle() ?? 'Download complete', 'Finished downloading.') } else { const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}` send(wc, { type: 'error', id: opts.id, error: msg }) - logFailure(opts, resolvedTitle, msg) - notify(wc, resolvedTitle ?? 'Download failed', msg) + logFailure(opts, getTitle(), msg) + notify(wc, getTitle() ?? 'Download failed', msg) } }) - - return { ok: true } } // Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve diff --git a/src/main/errorlog.ts b/src/main/errorlog.ts index 9589167..74df3d7 100644 --- a/src/main/errorlog.ts +++ b/src/main/errorlog.ts @@ -1,15 +1,14 @@ import type { ErrorLogEntry } from '@shared/ipc' import { isValidErrorLogEntry } from './validation' import { createJsonStore } from './jsonStore' +import { ERRORLOG_MAX_ENTRIES } from './constants' // 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 // 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 // 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[] { return store.read() diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index f75c5c2..28c82c4 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -1,6 +1,7 @@ import { execFile } from 'child_process' import { existsSync } from 'fs' import { getFfmpegPath, getFfprobePath } from './binaries' +import { VERSION_TIMEOUT_MS } from './constants' import type { FfmpegVersionResult } from '@shared/ipc' /** @@ -14,15 +15,20 @@ import type { FfmpegVersionResult } from '@shared/ipc' function readToolVersion(path: string): Promise { if (!existsSync(path)) return Promise.resolve(null) return new Promise((resolve) => { - execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => { - if (err) { - resolve(null) - return + execFile( + path, + ['-version'], + { 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) - }) + ) }) } diff --git a/src/main/index.ts b/src/main/index.ts index dbbbf74..865d71f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,77 +1,26 @@ -import { - app, - shell, - BrowserWindow, - ipcMain, - dialog, - clipboard, - nativeTheme, - Notification, - Menu -} from 'electron' +import { app, shell, BrowserWindow, nativeTheme, Notification, Menu } from 'electron' import { join, resolve } from 'path' -import { existsSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import { - IpcChannels, - type StartDownloadOptions, - type Settings, - type HistoryEntry, - type CommandTemplate, - type YtdlpUpdateChannel, - type SystemThemeInfo, - type TaskbarProgress -} from '@shared/ipc' +import { IpcChannels } from '@shared/ipc' import { PAGE_BACKGROUND } from '@shared/theme' -import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp' -import { getFfmpegVersions } from './ffmpeg' -import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' -import { probeMedia } from './probe' +import { + registerIpcHandlers, + resolveBackgroundMode, + applyNativeTheme, + getSystemThemeInfo +} from './ipc' +import { runStartupYtdlpAutoUpdate } from './ytdlp' import { getAppIconImage } from './binaries' -import { - startDownload, - cancelDownload, - 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 { hasActiveDownloads } from './download' +import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings' +import { ensureMediaDirs } from './paths' import { setupPortableData } from './portable' -import { safeOpenPath, safeShowInFolder } from './reveal' -import { - openCookieLoginWindow, - getCookiesStatus, - clearCookies, - migrateLegacyCookies -} from './cookies' +import { migrateLegacyCookies } from './cookies' import { attachEditContextMenu } from './contextMenu' -import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { flushAllStores } from './jsonStore' -import { exportBackup, importBackup } from './backup' import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' -import { - listSources, - getSource, - removeSource, - listMediaItems, - setMediaItemDownloaded, - setSourceWatched -} from './sources' -import { indexSource } from './indexer' -import { syncWatchedSources } from './sync' -import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule' +import { isSyncLaunch } from './schedule' 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 // 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 -// 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 // 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. @@ -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) => { - 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 // background in sync for windows currently following 'system'. function registerSystemThemeBridge(): void { @@ -510,7 +242,7 @@ if (isPrimaryInstance) { optimizer.watchWindowShortcuts(window) }) - registerIpcHandlers() + registerIpcHandlers(() => mainWindow) registerSystemThemeBridge() registerSendToShortcut() // Apply the persisted theme to the OS title bar before the window opens (W3). diff --git a/src/main/indexer.ts b/src/main/indexer.ts index aa9c4db..fcb2925 100644 --- a/src/main/indexer.ts +++ b/src/main/indexer.ts @@ -12,6 +12,7 @@ import { execFile } from 'child_process' import { existsSync } from 'fs' import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries' +import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants' import { cleanError } from './log' import { assertHttpUrl } from './url' import { @@ -50,7 +51,7 @@ function probeFlat(url: string): Promise { execFile( getYtdlpPath(), ['-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) => { if (err) { const msg = (err as { killed?: boolean }).killed diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..bc5201e --- /dev/null +++ b/src/main/ipc.ts @@ -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) => { + 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 + }) +} diff --git a/src/main/paths.ts b/src/main/paths.ts new file mode 100644 index 0000000..3488b05 --- /dev/null +++ b/src/main/paths.ts @@ -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 */ + } + } +} diff --git a/src/main/probe.ts b/src/main/probe.ts index 59e467b..260a47a 100644 --- a/src/main/probe.ts +++ b/src/main/probe.ts @@ -1,6 +1,7 @@ import { execFile } from 'child_process' import { existsSync } from 'fs' import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries' +import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants' import { fmtBytes } from '@shared/format' import { cleanError } from './log' import { assertHttpUrl } from './url' @@ -133,7 +134,7 @@ export function probeMedia(url: string): Promise { ytdlp, // `--` terminates option parsing so the URL can never be read as a flag. ['-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) => { if (err) { // execFile sets `killed` when it terminated the process on timeout. diff --git a/src/main/settings.ts b/src/main/settings.ts index 13622ab..4a0a33b 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,6 +1,4 @@ import { app, safeStorage } from 'electron' -import { join } from 'path' -import { mkdirSync } from 'fs' import Store from 'electron-store' import { isSafeFilenameTemplate, isSafeOutputDir } from './validation' 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 // defaults for any missing/invalid field. Used both to migrate older settings // files (which predate downloadOptions) and to validate renderer writes. diff --git a/src/main/sources.ts b/src/main/sources.ts index 2785af3..ddb2acc 100644 --- a/src/main/sources.ts +++ b/src/main/sources.ts @@ -16,11 +16,11 @@ import type { Source, MediaItem } from '@shared/ipc' import { isValidSource, isValidMediaItem } from './validation' import { mergeItemsPreservingState } from './indexerCore' import { createJsonStore } from './jsonStore' - -// A generous global cap so a runaway index can't grow the file unbounded; large -// enough for several big channels. When exceeded, the most-recently-written -// source's items are kept (they're placed first by replaceMediaItems). -const MAX_ITEMS = 20000 +// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) so a runaway index +// can't grow the file unbounded; large enough for several big channels. When +// exceeded, the most-recently-written source's items are kept (they're placed +// first by replaceMediaItems). +import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants' // 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 diff --git a/src/main/sync.ts b/src/main/sync.ts index 51ede1d..f7af5c1 100644 --- a/src/main/sync.ts +++ b/src/main/sync.ts @@ -8,6 +8,7 @@ import { listSources, listMediaItems } from './sources' import { indexSource } from './indexer' import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore' +import { FEED_FETCH_TIMEOUT_MS } from './constants' import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc' /** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */ @@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise { // 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. 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}`) return parseRssVideoIds(await res.text()) } diff --git a/src/main/templates.ts b/src/main/templates.ts index 31fbaae..00a1e58 100644 --- a/src/main/templates.ts +++ b/src/main/templates.ts @@ -1,16 +1,15 @@ import type { CommandTemplate } from '@shared/ipc' import { isTemplateLike } from './validation' import { createJsonStore } from './jsonStore' +import { TEMPLATES_MAX } from './constants' // Plain JSON in userData, same shape as history.ts. Atomic writes / corruption // 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 // isTemplateLike in validation.ts); everything else is coerced by sanitize(). // Drop anything that isn't, so a hand-edited templates.json can't inject // malformed entries. (audit S5) -const store = createJsonStore('templates.json', isTemplateLike, MAX_TEMPLATES) +const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX) export function listTemplates(): CommandTemplate[] { // Normalise each surviving entry through sanitize() so name/args are always diff --git a/src/main/ytdlp.ts b/src/main/ytdlp.ts index ef9b23d..a82a123 100644 --- a/src/main/ytdlp.ts +++ b/src/main/ytdlp.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' import { existsSync, mkdirSync, copyFileSync } from 'fs' import { dirname } from 'path' import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries' +import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants' import { getSettings, setSettings } from './settings' import { shouldAutoCheckYtdlp } from './ytdlpPolicy' import { @@ -47,7 +48,7 @@ export function getYtdlpVersion(): Promise { execFile( ytdlpPath, ['--version'], - { windowsHide: true, timeout: 15_000 }, + { windowsHide: true, timeout: VERSION_TIMEOUT_MS }, (err, stdout, stderr) => { if (err) { const msg = (err as { killed?: boolean }).killed @@ -93,7 +94,7 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise { if (err) { const msg = (err as { killed?: boolean }).killed diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a25d778..5681979 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef } from 'react' 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 { LibraryView } from './components/LibraryView' import { HistoryView } from './components/HistoryView' @@ -11,6 +11,7 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette' import { LiveRegion } from './components/LiveRegion' import { getTheme, pageBackground } from './theme' import { useSettings } from './store/settings' +import { useNav } from './store/nav' import { useDownloads } from './store/downloads' // Eagerly load the sources store for its startup side-effects (load persisted // 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 { 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({ provider: { height: '100vh' @@ -46,7 +51,8 @@ function App(): React.JSX.Element { const updateSettings = useSettings((s) => s.update) const showTerminal = useSettings((s) => s.customCommandEnabled) const isDark = useResolvedDark() - const [tab, setTab] = useState('downloads') + const tab = useNav((s) => s.tab) + const setTab = useNav((s) => s.setTab) const [paletteOpen, setPaletteOpen] = useState(false) // Track the element that was focused before the palette opened so we can restore it on close. const prePaletteRef = useRef(null) @@ -92,6 +98,27 @@ function App(): React.JSX.Element { 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 -- // only rebuilds when the theme label needs to change (L32). const paletteActions = useMemo( diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 2ef78c1..c235516 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -17,6 +17,7 @@ import { MusicNote2Regular, ErrorCircleRegular, LinkRegular, + LibraryRegular, DismissRegular, CutRegular, CalendarClockRegular, @@ -62,7 +63,8 @@ export function DownloadBar(): React.JSX.Element { selected, allSelected, suggestion, - suggestionSource + suggestionSource, + channelHint } = bar return ( @@ -80,20 +82,23 @@ export function DownloadBar(): React.JSX.Element { onChange={(_, d) => bar.onUrlChange(d.value)} onKeyDown={(e) => { if (e.key !== 'Enter') return - if (info || probeError || playlist) bar.download() - else void bar.fetchFormats() + // Enter is "go": start the download (or add a fetched playlist). Preview + // 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" contentBefore={} /> - + + {confirmClearLog ? ( @@ -77,7 +81,9 @@ export function DiagnosticsCard(): React.JSX.Element {
{e.title ?? e.url} - {new Date(e.occurredAt).toLocaleString()} + + {new Date(e.occurredAt).toLocaleString()} +
{e.error}
diff --git a/src/renderer/src/components/settings/DownloadsCard.tsx b/src/renderer/src/components/settings/DownloadsCard.tsx index db29976..f323ce0 100644 --- a/src/renderer/src/components/settings/DownloadsCard.tsx +++ b/src/renderer/src/components/settings/DownloadsCard.tsx @@ -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 { type MediaKind } from '@shared/ipc' import { useSettings } from '../../store/settings' diff --git a/src/renderer/src/components/settings/FilenamesCard.tsx b/src/renderer/src/components/settings/FilenamesCard.tsx index f70cda0..ad13eed 100644 --- a/src/renderer/src/components/settings/FilenamesCard.tsx +++ b/src/renderer/src/components/settings/FilenamesCard.tsx @@ -20,7 +20,10 @@ export function FilenamesCard(): React.JSX.Element { 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." > - update({ filenameTemplate: d.value })} /> + update({ filenameTemplate: d.value })} + /> Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')} diff --git a/src/renderer/src/components/settings/NetworkCard.tsx b/src/renderer/src/components/settings/NetworkCard.tsx index 023e9ea..74c1db9 100644 --- a/src/renderer/src/components/settings/NetworkCard.tsx +++ b/src/renderer/src/components/settings/NetworkCard.tsx @@ -39,7 +39,11 @@ export function NetworkCard(): React.JSX.Element { label="Rate limit" hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited." > - update({ rateLimit: d.value })} /> + update({ rateLimit: d.value })} + /> ((set, get) => { // no direct import from sources, so the two stores stay acyclic. on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries)) -// 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. -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) +// The scheduled-download promoter tick lives in App (a useEffect), not here, so +// importing this store in tests/preview doesn't start a stray never-cleared timer +// on module load (L1). See SCHEDULE_TICK_MS / App.tsx. // Wire main → renderer push events. (Output folder + cap live in the settings store.) if (!PREVIEW) { diff --git a/src/renderer/src/store/nav.ts b/src/renderer/src/store/nav.ts new file mode 100644 index 0000000..d37c2e8 --- /dev/null +++ b/src/renderer/src/store/nav.ts @@ -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((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 + } +})) diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index 4c27c3b..d28e92d 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -149,7 +149,7 @@ export const useSources = create((set, get) => ({ reindexSource: async (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) { await new Promise((r) => setTimeout(r, 700)) set({ indexing: { active: false } }) diff --git a/src/renderer/src/useClipboardLink.ts b/src/renderer/src/useClipboardLink.ts index 52456e6..178bcb7 100644 --- a/src/renderer/src/useClipboardLink.ts +++ b/src/renderer/src/useClipboardLink.ts @@ -3,7 +3,12 @@ import { useSettings } from './store/settings' // Pure URL helpers extracted to a standalone module so they can be tested // without pulling in the Zustand store (L40). Re-exported here for existing // consumers (DownloadBar, LibraryView) without an import-path change. -export { looksLikeUrl, looksLikeSingleVideo, firstUrlInText } from './lib/urlHelpers' +export { + looksLikeUrl, + looksLikeSingleVideo, + looksLikeChannelOrPlaylist, + firstUrlInText +} from './lib/urlHelpers' import { looksLikeUrl } from './lib/urlHelpers' /** Where an offered link came from — drives the banner's wording. */ diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index 2689008..089ee5d 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -39,7 +39,7 @@ function build( access: AccessOptions = NO_ACCESS, extraArgs: 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`. */ diff --git a/test/clipboardLink.test.ts b/test/clipboardLink.test.ts index 5effde1..0e2dca1 100644 --- a/test/clipboardLink.test.ts +++ b/test/clipboardLink.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest' // 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', () => { it('extracts the id from a watch URL', () => { @@ -103,3 +108,37 @@ describe('looksLikeSingleVideo', () => { 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) + }) +}) diff --git a/test/real-download.integration.test.ts b/test/real-download.integration.test.ts index 99e0461..967aea0 100644 --- a/test/real-download.integration.test.ts +++ b/test/real-download.integration.test.ts @@ -154,7 +154,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => { embedThumbnail: 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)) const res = runYtdlp(argv) @@ -191,7 +197,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => { sponsorBlockMode: 'remove' 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)) const res = runYtdlp(argv) @@ -219,7 +231,13 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => { audioFormat: 'opus' as const, 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) if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr) 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, 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)) const res = runYtdlp(argv) 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, 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)) const res = runYtdlp(argv) 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 } // %(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) if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr) 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', () => { const archive = join(outDir, 'archive.txt') const mk = (id: string): string[] => - buildArgs( - { id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' }, - join(outDir, 'arch-%(id)s.%(ext)s'), - { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false }, - BIN_DIR, - { ...NO_ACCESS, downloadArchivePath: archive } - ) + buildArgs({ + opts: { + id, + url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + kind: 'audio', + 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')) 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', '--no-mtime' ]) - const argv = buildArgs( + const argv = buildArgs({ opts, - join(outDir, 'extra-%(id)s.%(ext)s'), + outputTemplate: join(outDir, 'extra-%(id)s.%(ext)s'), options, - BIN_DIR, - NO_ACCESS, - extra - ) + binDir: BIN_DIR, + access: NO_ACCESS, + extraArgs: extra + }) const res = runYtdlp(argv) if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr) expect(res.status, 'yt-dlp should exit 0').toBe(0)