diff --git a/ROADMAP-PINCHFLAT.md b/ROADMAP-PINCHFLAT.md index 9eeff5f..2c72b62 100644 --- a/ROADMAP-PINCHFLAT.md +++ b/ROADMAP-PINCHFLAT.md @@ -91,14 +91,14 @@ user indexes many large channels and the JSON files get unwieldy (noted as a Pha Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything yet. This is the prerequisite for every later phase. -- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`, +- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`, `/channel/`, `/c/`, `/user/`) resolves to a new `kind: 'channel'` `ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists → videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"** playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each playlist's video list on demand rather than all up front. -- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`) +- [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`) storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`: ```ts interface Source { @@ -122,11 +122,11 @@ yet. This is the prerequisite for every later phase. filePath?: string } ``` -- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem` +- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem` (keyed by video id); first playlist seen wins the folder assignment (surface this choice in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in no real playlist. -- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new +- [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard. @@ -134,13 +134,13 @@ yet. This is the prerequisite for every later phase. Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`. -- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through +- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through `buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a joined subpath) so each `MediaItem` downloads into `/// - .ext`. The `NNN` index comes from `MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is empty under the per-item `--no-playlist` path that AeroFetch keeps using. -- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal +- [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`). Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates, not the directory segments AeroFetch constructs from channel/playlist names. @@ -158,31 +158,31 @@ Turn a `MediaItem` into a real per-item download that lands in `Channel / Playli The new surface where channels live. Everything below feeds the **existing** download queue. -- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside +- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside Downloads / History / Settings. Lists added Sources. -- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree +- [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error), reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel. Per-playlist select-all, live counts. -- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via +- [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never holds the entire channel at once — the live queue stays small while the Library view is the source of truth for the full list. Completion flips `MediaItem.downloaded = true` and records to history exactly as today. -- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors +- [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors `store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`). -- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and, +- [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and, if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision. ## Phase I — Incremental sync & dedup ✅ COMPLETE Re-running a channel should grab only what's new — the everyday media-manager loop. -- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the +- [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the persisted `MediaItem`s and marks only the new ones; existing records (and their `downloaded` state) are preserved. "X new since last sync" badge. -- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s, +- [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s, backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`, `getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index can't re-download. @@ -195,14 +195,14 @@ Pinchflat's headline feature: a Source you *watch*, that downloads new uploads o AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler + single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns). -- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source. -- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed +- [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source. +- [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed (`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15 videos, so RSS is for *staying current*, not initial indexing (which stays a full Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for playlists / initial scans"). -- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and +- [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and enqueues new items, wired to **Windows Task Scheduler** (reuse the single-instance-lock already added in Phase E so a scheduled run hands off to a running window instead of double-launching). Notify on new downloads via the existing @@ -213,7 +213,7 @@ single-instance Mutex + a headless run mode — see the DashMail/Weather Radar p Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant because organized channel folders are exactly what those servers ingest. -- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`, +- [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`, and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through Phase C custom-command args; a checkbox group in the output settings makes it first-class. diff --git a/ROADMAP.md b/ROADMAP.md index 53cbd60..f08e69a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,7 +166,7 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with **Copy full report** and **Clear log**. -## Phase E — Theming & platform polish +## Phase E — Theming & platform polish ✅ COMPLETE Some items are Android-specific in Seal and adapted to Windows here. @@ -187,10 +187,6 @@ Some items are Android-specific in Seal and adapted to Windows here. auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee, switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar toggle's break-out-of-system behavior all checked visually. -- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred, - since shipping ~40 real translations isn't something to fake; doing this properly means - wiring up an i18n library + extracting every string now, with translation content - arriving incrementally from contributors. - [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32 (non-MSIX) app can't register as an actual Share Target: - **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at @@ -274,49 +270,77 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero (`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in `src/main/buildArgs.ts`), so this is cheaper than it looks. -- [ ] **Trim / cut downloads.** Download only part of a video, or remove segments — by - **timestamp** or by **chapter**, with multiple cuts allowed. → yt-dlp - `--download-sections "*START-END"` (repeatable) + `--force-keyframes-at-cuts`. - Chapters come free from the probe's `yt-dlp -J` `chapters[]` array (`src/main/probe.ts`). - New fields on `DownloadOptions` (or a per-download override), surfaced as a **Trim** - panel in `src/renderer/src/components/DownloadBar.tsx`. *v1 can skip a scrubber/preview - player — timestamp inputs + checkboxes of the probed chapter list is enough. Smoke-test - the exact multi-section output behaviour (one file vs. per-section) the same way - Phases A–C smoke-tested live yt-dlp.* -- [ ] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a +- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). → + `parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp + `--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against + SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through + `store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx` + (single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests; + typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range + only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview + player, still TODO).* +- [x] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a TODO in Phase A above). One toggle on `DownloadOptions` + - `src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. Uses an - `--output chapter:` template for the per-chapter filenames. + `src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp + keeps the full file and writes per-chapter files via its default `chapter:` template. + *Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test + still pending.* - [ ] **Metadata editing before download.** Change title / author / artist pre-download (YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` / - `--ppa "Metadata:-metadata title=…"`; fits the audio path of `DownloadOptions`. Good for - building a clean music library where the source title is messy. + `--replace-in-metadata`; fits the audio path. Good for building a clean music library + where the source title is messy. *Held: the exact set-a-literal recipe is fragile + (`--parse-metadata FROM:TO` splits on a colon that titles often contain; + `--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge + cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the + flag.* -## Phase M — Queue & daily-use UX +## Phase M — Queue & daily-use UX ✅ COMPLETE Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in. Today `src/renderer/src/store/downloads.ts` has cancel + retry only. -- [ ] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue` - (already the default). Pause = kill the child (existing `taskkill /pid /T /F` path) but - keep the partial + mark the item `paused`; resume = relaunch the same item, which - continues. New `paused` state in the queue store. -- [ ] **Reorder / prioritize the queue.** Drag a queued item up. The renderer scheduler - (`pump()` promotes oldest-queued-first) already treats the queued array as the order, so - this is reordering that array. -- [ ] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads +- [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue` + (already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the + `taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so + the close/error handlers stay silent (no error event/log/notification); new + `download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) + + a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting + progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress + row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green; + live resume-continues-the-.part still wants a smoke test.* +- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than + drag: since the queue renders newest-first while `pump()` promotes oldest-first, a + reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a + queued item to the front of the promotion order; an up-arrow button on queued rows + (`QueueItem.tsx`).* +- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads view summing bytes/speed across active items. Feeds straight into Phase O's taskbar - progress bar. -- [ ] **Drag-and-drop a link or `.url` file onto the window.** A window `drop` handler that - accepts `text/uri-list` and Internet-Shortcut files, reusing `extractIncomingUrl()` - from `src/main/deeplink.ts` (already validates http(s)-only). -- [ ] **Retry all failed.** One action that re-enqueues every entry in `src/main/errorlog.ts`. -- [ ] **Duplicate detection.** Warn ("you already have this") when a URL/video-id is already in - history or the active queue before enqueuing. -- [ ] **Per-download scheduling + "save for later."** YTDLnis lets you "schedule by date and - time" and park items. Add `scheduledFor?: number` + a `saved` state to `DownloadItem`; - `pump()` gains a time gate before promotion. Distinct from the existing background - `--sync` schedule (Phase J), which is for *sources*, not one-off items. + progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in + `DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested + (`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach — + we don't track per-item sizes — so "time left" is the longest single ETA.* +- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card + (`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight, + accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url` + Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by + the existing `looksLikeUrl`. A dropped link fills the URL field.* +- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done: + `retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)" + button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading + `errorlog.json` — same effect for the on-screen failures.)* +- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in + the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware + + normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in + `DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed + items); cross-checking `history.json` for older downloads is a possible extension.* +- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional + `scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s + ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native + `datetime-local` input in `DownloadBar.tsx` (future time parks the new download as + scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`; + saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is + for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while + AeroFetch is running** — noted in the UI hint and the code.* ## Phase N — Power-user surface diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 1dd697b..b3e6ce2 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -161,6 +161,30 @@ export function selectExtraArgs(params: { return [] } +/** + * Parse a raw trim string (one or more time ranges, comma- or newline-separated) + * into yt-dlp `--download-sections` specs. Each range is normalised to the + * `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a + * timestamp range from a chapter-title regex). Tokens that don't look like a + * numeric time range are dropped, so malformed input can't reach yt-dlp's argv. + * + * Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00', + * '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`. + */ +export function parseTrimSections(raw: string | undefined): string[] { + if (!raw) return [] + const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?` + const RANGE = new RegExp(`^${TIME}-${TIME}$`) + const out: string[] = [] + for (const piece of raw.split(/[,\n]/)) { + let t = piece.trim() + if (!t) continue + if (t.startsWith('*')) t = t.slice(1).trim() + if (RANGE.test(t)) out.push(`*${t}`) + } + return out +} + // Wrap a single argv token for human-readable display only (Phase C command // preview) — never used to build the real argv that gets spawned. function quoteForDisplay(arg: string): string { @@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string } // SponsorBlock. + let forcedKeyframes = false if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) { const cats = o.sponsorBlockCategories.join(',') if (o.sponsorBlockMode === 'remove') { // Force keyframes at the cut points so segment boundaries are frame-accurate. args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts') + forcedKeyframes = true } else { args.push('--sponsorblock-mark', cats) } } + // Trim — download only the given time ranges (works for video + audio). Force + // keyframes at the cut points for frame-accurate boundaries, unless + // SponsorBlock-remove already requested it (the flag is idempotent, but we + // avoid emitting it twice). + const sections = parseTrimSections(opts.trim) + for (const sec of sections) args.push('--download-sections', sec) + if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts') + if (o.embedChapters) args.push('--embed-chapters') + // Split into one file per chapter. yt-dlp also keeps the full file; the + // per-chapter files use yt-dlp's default `chapter:` output template. + if (o.splitChapters) args.push('--split-chapters') if (o.embedMetadata) args.push('--embed-metadata') // Media-server sidecar files (Phase K) — written next to the output file so diff --git a/src/main/download.ts b/src/main/download.ts index 492fdca..b21575a 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -38,6 +38,7 @@ import { interface ActiveDownload { child: ChildProcess canceled: boolean + paused: boolean } const active = new Map<string, ActiveDownload>() @@ -295,7 +296,7 @@ export function startDownload( return { ok: false, error: (e as Error).message } } - const rec: ActiveDownload = { child, canceled: false } + const rec: ActiveDownload = { child, canceled: false, paused: false } active.set(opts.id, rec) // Title/channel/duration are kept locally so the completion notification and @@ -344,7 +345,8 @@ export function startDownload( if (settled) return settled = true active.delete(opts.id) - if (!rec.canceled) { + // 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) @@ -355,7 +357,9 @@ export function startDownload( if (settled) return settled = true active.delete(opts.id) - if (rec.canceled) return // renderer already showed 'canceled' optimistically + // 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.') @@ -370,15 +374,12 @@ export function startDownload( return { ok: true } } -export function cancelDownload(id: string): void { - const rec = active.get(id) - if (!rec) return - rec.canceled = true +// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve +// taskkill from System32 by absolute path, never the bare name, so a planted +// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3) +function killTree(rec: ActiveDownload): void { const pid = rec.child.pid if (pid != null) { - // Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve - // taskkill from System32 by absolute path, never the bare name, so a planted - // taskkill.exe on PATH / in the CWD can't run in its place. (audit F3) execFile( getSystem32Path('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], @@ -389,3 +390,24 @@ export function cancelDownload(id: string): void { rec.child.kill() } } + +export function cancelDownload(id: string): void { + const rec = active.get(id) + if (!rec) return + rec.canceled = true + killTree(rec) +} + +/** + * Pause a running download: kill the process tree but flag it `paused` (not + * `canceled`) so the close handler stays silent — no error event, no error log, + * no notification. yt-dlp leaves the partial `.part` file in place, so resuming + * is just a fresh startDownload with the same options: yt-dlp's default + * `--continue` picks the partial file back up. + */ +export function pauseDownload(id: string): void { + const rec = active.get(id) + if (!rec) return + rec.paused = true + killTree(rec) +} diff --git a/src/main/index.ts b/src/main/index.ts index 7673b68..271b8f3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp import { getFfmpegVersions } from './ffmpeg' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' -import { startDownload, cancelDownload, previewCommand } from './download' +import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download' import { getSettings, setSettings, ensureMediaDirs } from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' @@ -211,6 +211,7 @@ function registerIpcHandlers(): void { return result }) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) + ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id)) ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads')) diff --git a/src/main/settings.ts b/src/main/settings.ts index 17b08de..8fc8c74 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -111,6 +111,7 @@ function sanitizeOptions(input: unknown): DownloadOptions { sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove', sponsorBlockCategories: cats, embedChapters: bool(o.embedChapters, d.embedChapters), + splitChapters: bool(o.splitChapters, d.splitChapters), embedMetadata: bool(o.embedMetadata, d.embedMetadata), embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), diff --git a/src/preload/index.ts b/src/preload/index.ts index 0ccd75b..e5ec0d1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -69,6 +69,9 @@ const api = { cancelDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadCancel, id), + pauseDownload: (id: string): Promise<void> => + ipcRenderer.invoke(IpcChannels.downloadPause, id), + getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder), chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder), diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index a76f075..b0f6ae8 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useRef } from 'react' import { Input, + Textarea, + Field, Button, Checkbox, Spinner, @@ -20,10 +22,14 @@ import { ErrorCircleRegular, LinkRegular, DismissRegular, - AppsListRegular + AppsListRegular, + CutRegular, + CalendarClockRegular, + WarningRegular } from '@fluentui/react-icons' import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc' import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads' +import { sameVideo } from '../store/queueStats' import { useSettings } from '../store/settings' import { Select } from './Select' import { Hint } from './Hint' @@ -40,6 +46,23 @@ function looksLikeUrl(text: string): boolean { } } +/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */ +function firstUrl(text: string): string | null { + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim() + if (!line || line.startsWith('#')) continue + if (looksLikeUrl(line)) return line + } + return null +} + +/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */ +function parseUrlFile(content: string): string | null { + const m = /^\s*URL\s*=\s*(\S+)/im.exec(content) + const url = m?.[1]?.trim() + return url && looksLikeUrl(url) ? url : null +} + const useStyles = makeStyles({ root: { display: 'flex', @@ -50,6 +73,11 @@ const useStyles = makeStyles({ ...shorthands.borderRadius(tokens.borderRadiusXLarge), boxShadow: tokens.shadow4 }, + // Highlight while a link / .url file is dragged over the card. + rootDragging: { + outline: `2px dashed ${tokens.colorBrandStroke1}`, + outlineOffset: '-2px' + }, urlRow: { display: 'flex', gap: '8px' @@ -212,6 +240,45 @@ const useStyles = makeStyles({ }, plItemMeta: { color: tokens.colorNeutralForeground3 + }, + // --- duplicate warning --- + dupRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 8px 8px 12px', + backgroundColor: tokens.colorStatusWarningBackground1, + color: tokens.colorStatusWarningForeground1, + ...shorthands.borderRadius(tokens.borderRadiusLarge) + }, + // --- trim / schedule panels --- + trimBlock: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + alignItems: 'flex-start' + }, + optButtons: { + display: 'flex', + gap: '8px' + }, + trimPanel: { + alignSelf: 'stretch', + padding: '12px', + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.borderRadius(tokens.borderRadiusLarge), + border: `1px solid ${tokens.colorNeutralStroke2}` + }, + // Native datetime-local input, themed to sit beside the Fluent controls. + dtInput: { + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeBase300, + padding: '6px 10px', + ...shorthands.borderRadius(tokens.borderRadiusMedium), + border: `1px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + colorScheme: 'light dark' } }) @@ -227,6 +294,47 @@ export function DownloadBar(): React.JSX.Element { const [kind, setKind] = useState<MediaKind>('video') const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0]) + // Optional trim: keep only certain time ranges. Raw text, normalised in main. + const [showTrim, setShowTrim] = useState(false) + const [trim, setTrim] = useState('') + + // Optional schedule: a datetime-local value; a future time parks the download. + const [showSchedule, setShowSchedule] = useState(false) + const [scheduleAt, setScheduleAt] = useState('') + + // Drag-and-drop: highlight while a link / .url file hovers the card. + const [dragActive, setDragActive] = useState(false) + + // Duplicate guard: warn before enqueuing a URL already in the active queue. + const [dup, setDup] = useState<string | null>(null) + const confirmDup = useRef(false) + + function onDragOver(e: React.DragEvent): void { + e.preventDefault() + if (!dragActive) setDragActive(true) + } + function onDrop(e: React.DragEvent): void { + e.preventDefault() + setDragActive(false) + const dt = e.dataTransfer + const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '') + if (fromText) { + onUrlChange(fromText) + return + } + // A dropped Windows .url Internet Shortcut — read its URL= line. + const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url')) + if (file) { + file + .text() + .then((content) => { + const u = parseUrlFile(content) + if (u) onUrlChange(u) + }) + .catch(() => {}) + } + } + // Apply the saved default format once, when persisted settings first arrive. const appliedDefaults = useRef(false) useEffect(() => { @@ -320,8 +428,10 @@ export function DownloadBar(): React.JSX.Element { function onUrlChange(next: string): void { setUrl(next) - // Any probed info is stale once the URL changes. + // Any probed info — or a duplicate warning — is stale once the URL changes. if (info || probeError || playlist) clearProbe() + if (dup) setDup(null) + confirmDup.current = false } function onKindChange(next: MediaKind): void { @@ -383,6 +493,23 @@ export function DownloadBar(): React.JSX.Element { const trimmed = url.trim() if (!trimmed) return + // Duplicate guard: if this URL is already in the queue (and the user hasn't + // confirmed via "Download anyway"), warn instead of silently enqueuing a copy. + // Canceled/failed items don't count — re-adding those is a legitimate retry. + if (!confirmDup.current) { + const existing = useDownloads + .getState() + .items.find( + (i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed) + ) + if (existing) { + setDup(existing.title) + return + } + } + confirmDup.current = false + setDup(null) + const meta = info ? { title: info.title, @@ -392,9 +519,14 @@ export function DownloadBar(): React.JSX.Element { } : {} + const trimSpec = trim.trim() || undefined + const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined + if (usingFormats && selectedFormat) { addFromUrl(trimmed, 'video', selectedFormat.label, { ...meta, + trim: trimSpec, + scheduledFor, format: { id: selectedFormat.id, hasAudio: selectedFormat.hasAudio, @@ -402,13 +534,22 @@ export function DownloadBar(): React.JSX.Element { } }) } else { - addFromUrl(trimmed, kind, quality, meta) + addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor }) } setUrl('') + setTrim('') + setShowTrim(false) + setScheduleAt('') + setShowSchedule(false) clearProbe() } + function downloadAnyway(): void { + confirmDup.current = true + download() + } + function addPlaylist(): void { if (!playlist) return const chosen = playlist.entries.filter((e) => selected.has(e.index)) @@ -424,7 +565,12 @@ export function DownloadBar(): React.JSX.Element { } return ( - <div className={styles.root}> + <div + className={mergeClasses(styles.root, dragActive && styles.rootDragging)} + onDragOver={onDragOver} + onDragLeave={() => setDragActive(false)} + onDrop={onDrop} + > <div className={styles.urlRow}> <Input className={styles.url} @@ -474,6 +620,23 @@ export function DownloadBar(): React.JSX.Element { </div> )} + {dup && ( + <div className={styles.dupRow}> + <WarningRegular /> + <Caption1 className={styles.suggestionText}>Already in your queue: “{dup}”.</Caption1> + <Button size="small" appearance="primary" onClick={downloadAnyway}> + Download anyway + </Button> + <Button + size="small" + appearance="subtle" + icon={<DismissRegular />} + onClick={() => setDup(null)} + aria-label="Dismiss" + /> + </div> + )} + {probing && ( <div className={styles.statusRow}> <Spinner size="tiny" /> @@ -550,6 +713,59 @@ export function DownloadBar(): React.JSX.Element { </div> )} + {!playlist && ( + <div className={styles.trimBlock}> + <div className={styles.optButtons}> + <Button + size="small" + appearance="subtle" + icon={<CutRegular />} + onClick={() => setShowTrim((v) => !v)} + > + {trim.trim() ? 'Trim · on' : 'Trim'} + </Button> + <Button + size="small" + appearance="subtle" + icon={<CalendarClockRegular />} + onClick={() => setShowSchedule((v) => !v)} + > + {scheduleAt ? 'Scheduled' : 'Schedule'} + </Button> + </div> + {showTrim && ( + <div className={styles.trimPanel}> + <Field + label="Sections to keep" + hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing." + > + <Textarea + value={trim} + onChange={(_, d) => setTrim(d.value)} + placeholder={'0:30-1:45\n3:00-3:30'} + resize="vertical" + /> + </Field> + </div> + )} + {showSchedule && ( + <div className={styles.trimPanel}> + <Field + label="Start at" + hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running." + > + <input + type="datetime-local" + className={styles.dtInput} + value={scheduleAt} + onChange={(e) => setScheduleAt(e.target.value)} + /> + </Field> + </div> + )} + </div> + )} + <div className={styles.controls}> <div className={styles.control}> <Caption1>Format</Caption1> @@ -606,11 +822,11 @@ export function DownloadBar(): React.JSX.Element { <Button appearance="primary" size="large" - icon={<ArrowDownloadRegular />} + icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />} onClick={download} disabled={!url.trim()} > - Download + {scheduleAt ? 'Schedule' : 'Download'} </Button> )} </div> diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx index d5c4aae..0123a31 100644 --- a/src/renderer/src/components/DownloadOptionsForm.tsx +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -204,6 +204,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme label={value.embedChapters ? 'On' : 'Off'} /> </Field> + <Field + label="Split by chapters" + hint="Also save each chapter as its own file. The full-length file is kept too." + > + <Switch + checked={value.splitChapters} + onChange={(_, d) => setOpt('splitChapters', d.checked)} + label={value.splitChapters ? 'On' : 'Off'} + /> + </Field> <Field label="Embed metadata" hint="Title, artist, date and similar tags."> <Switch checked={value.embedMetadata} diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index cb88ba4..e35334f 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -1,12 +1,16 @@ import { Subtitle2, Body1, + Caption1, Button, + ProgressBar, makeStyles, - tokens + tokens, + shorthands } from '@fluentui/react-components' -import { ArrowDownloadRegular } from '@fluentui/react-icons' +import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons' import { useDownloads } from '../store/downloads' +import { summarizeQueue } from '../store/queueStats' import { DownloadBar } from './DownloadBar' import { QueueItem } from './QueueItem' import { VirtualList } from './VirtualList' @@ -26,6 +30,27 @@ const useStyles = makeStyles({ alignItems: 'center', justifyContent: 'space-between' }, + headerActions: { + display: 'flex', + gap: '8px' + }, + summary: { + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '10px 14px', + backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.borderRadius(tokens.borderRadiusLarge), + border: `1px solid ${tokens.colorNeutralStroke2}` + }, + summaryBar: { + flexGrow: 1 + }, + summaryText: { + flexShrink: 0, + color: tokens.colorNeutralForeground3, + whiteSpace: 'nowrap' + }, listScroll: { flexGrow: 1, // min-height:0 lets this flex child shrink below its content height so it, @@ -47,7 +72,9 @@ export function DownloadsView(): React.JSX.Element { const styles = useStyles() const items = useDownloads((s) => s.items) const clearFinished = useDownloads((s) => s.clearFinished) + const retryAll = useDownloads((s) => s.retryAll) + const summary = summarizeQueue(items) const hasFinished = items.some( (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' ) @@ -56,13 +83,37 @@ export function DownloadsView(): React.JSX.Element { <div className={styles.root}> <DownloadBar /> + {summary.active && ( + <div className={styles.summary}> + <ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" /> + <Caption1 className={styles.summaryText}> + {summary.downloading} downloading + {summary.queued ? `, ${summary.queued} queued` : ''} + {summary.speedLabel ? ` • ${summary.speedLabel}` : ''} + {summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''} + </Caption1> + </div> + )} + <div className={styles.queueHeader}> <Subtitle2>Queue ({items.length})</Subtitle2> - {hasFinished && ( - <Button size="small" appearance="subtle" onClick={clearFinished}> - Clear finished - </Button> - )} + <div className={styles.headerActions}> + {summary.failed > 0 && ( + <Button + size="small" + appearance="subtle" + icon={<ArrowClockwiseRegular />} + onClick={retryAll} + > + Retry all failed ({summary.failed}) + </Button> + )} + {hasFinished && ( + <Button size="small" appearance="subtle" onClick={clearFinished}> + Clear finished + </Button> + )} + </div> </div> {items.length === 0 ? ( diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 0929d72..42ab3ea 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -57,6 +57,8 @@ const STATUS_LABEL: Record<ItemStatus, string> = { pending: 'Pending', queued: 'Queued', downloading: 'Downloading', + paused: 'Paused', + saved: 'Saved', completed: 'Downloaded', error: 'Failed', canceled: 'Canceled' diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx index 89eb1e0..cdf2297 100644 --- a/src/renderer/src/components/QueueItem.tsx +++ b/src/renderer/src/components/QueueItem.tsx @@ -16,6 +16,11 @@ import { OpenRegular, FolderRegular, ArrowClockwiseRegular, + ArrowDownloadRegular, + ArrowUpRegular, + BookmarkRegular, + PauseRegular, + PlayRegular, CheckmarkCircleFilled, ErrorCircleFilled, EyeOffRegular @@ -91,6 +96,8 @@ const useStyles = makeStyles({ const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = { queued: { label: 'Queued', color: 'subtle' }, downloading: { label: 'Downloading', color: 'brand' }, + paused: { label: 'Paused', color: 'warning' }, + saved: { label: 'Saved', color: 'subtle' }, completed: { label: 'Completed', color: 'success' }, error: { label: 'Failed', color: 'danger' }, canceled: { label: 'Canceled', color: 'warning' } @@ -100,6 +107,14 @@ function pct(progress: number): string { return `${Math.round(progress * 100)}%` } +function fmtSchedule(ms: number): string { + try { + return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + } catch { + return '' + } +} + export const QueueItem = memo(function QueueItem({ item }: { @@ -107,6 +122,11 @@ export const QueueItem = memo(function QueueItem({ }): React.JSX.Element { const styles = useStyles() const cancel = useDownloads((s) => s.cancel) + const pause = useDownloads((s) => s.pause) + const resume = useDownloads((s) => s.resume) + const prioritize = useDownloads((s) => s.prioritize) + const saveForLater = useDownloads((s) => s.saveForLater) + const queueNow = useDownloads((s) => s.queueNow) const remove = useDownloads((s) => s.remove) const retry = useDownloads((s) => s.retry) const openFile = useDownloads((s) => s.openFile) @@ -177,12 +197,79 @@ export const QueueItem = memo(function QueueItem({ </div> )} + {item.status === 'paused' && ( + <div className={styles.progressRow}> + <ProgressBar className={styles.progressBar} value={item.progress} thickness="large" /> + <Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1> + </div> + )} + + {item.status === 'saved' && ( + <Caption1 className={styles.stats}> + {item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'} + </Caption1> + )} + {item.status === 'error' && item.error && ( <Caption1 className={styles.error}>{item.error}</Caption1> )} </div> <div className={styles.actions}> + {item.status === 'downloading' && ( + <Hint label="Pause" placement="top" align="end"> + <Button + appearance="subtle" + icon={<PauseRegular />} + onClick={() => pause(item.id)} + aria-label="Pause" + /> + </Hint> + )} + + {item.status === 'queued' && ( + <> + <Hint label="Download next" placement="top" align="end"> + <Button + appearance="subtle" + icon={<ArrowUpRegular />} + onClick={() => prioritize(item.id)} + aria-label="Download next" + /> + </Hint> + <Hint label="Save for later" placement="top" align="end"> + <Button + appearance="subtle" + icon={<BookmarkRegular />} + onClick={() => saveForLater(item.id)} + aria-label="Save for later" + /> + </Hint> + </> + )} + + {item.status === 'paused' && ( + <Hint label="Resume" placement="top" align="end"> + <Button + appearance="subtle" + icon={<PlayRegular />} + onClick={() => resume(item.id)} + aria-label="Resume" + /> + </Hint> + )} + + {item.status === 'saved' && ( + <Hint label="Add to queue" placement="top" align="end"> + <Button + appearance="subtle" + icon={<ArrowDownloadRegular />} + onClick={() => queueNow(item.id)} + aria-label="Add to queue" + /> + </Hint> + )} + {active && ( <Hint label="Cancel" placement="top" align="end"> <Button diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 06dfe8d..6d8dfee 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -113,6 +113,7 @@ if (import.meta.env.DEV && !window.api) { }, startDownload: async () => ({ ok: true }), cancelDownload: async () => {}, + pauseDownload: async () => {}, getDefaultFolder: async () => 'C:\\Users\\you\\Downloads', chooseFolder: async () => null, openPath: async () => '', diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index afc8784..de168d4 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -6,7 +6,14 @@ import { useSources } from './sources' export type MediaKind = 'video' | 'audio' -export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled' +export type DownloadStatus = + | 'queued' + | 'downloading' + | 'paused' + | 'saved' + | 'completed' + | 'error' + | 'canceled' /** An exact probed format chosen for a download (omitted for the preset path). */ export interface ChosenFormat { @@ -39,6 +46,10 @@ export interface DownloadItem { options?: DownloadOptions /** per-download custom-command override (omitted = use the persisted default template) */ extraArgs?: string + /** raw trim spec — time ranges to keep (parsed to --download-sections in main) */ + trim?: string + /** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */ + scheduledFor?: number /** private download — completion is never recorded to history */ incognito?: boolean /** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */ @@ -63,6 +74,9 @@ export interface AddOptions { thumbnail?: string options?: DownloadOptions extraArgs?: string + trim?: string + /** when set and in the future, the item starts 'saved' and auto-queues at this time */ + scheduledFor?: number incognito?: boolean collection?: CollectionContext mediaItemId?: string @@ -90,6 +104,20 @@ interface DownloadState { */ addMany: (entries: AddEntry[]) => void retry: (id: string) => void + /** re-queue every failed item at once (Phase M) */ + retryAll: () => void + /** stop a running download but keep its partial file for a later resume (Phase M) */ + pause: (id: string) => void + /** re-queue a paused download; yt-dlp continues its partial file on relaunch */ + resume: (id: string) => void + /** move a queued item to the front of the promotion order ("download next", Phase M) */ + prioritize: (id: string) => void + /** park a queued item indefinitely as 'saved' (Phase M) */ + saveForLater: (id: string) => void + /** un-park a 'saved' item back into the queue now (clears any schedule) */ + queueNow: (id: string) => void + /** internal: promote any 'saved' item whose scheduledFor time has arrived */ + promoteDueScheduled: () => void cancel: (id: string) => void remove: (id: string) => void clearFinished: () => void @@ -117,6 +145,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti opts?.title || opts?.channel || opts?.durationLabel ? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel } : undefined + // A future scheduled time parks the item as 'saved' until its moment arrives; + // a past/absent time starts it queued as usual. + const scheduledFor = + opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined return { id: newId(), url, @@ -126,12 +158,14 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti thumbnail: opts?.thumbnail, kind, quality, - status: 'queued', + status: scheduledFor ? 'saved' : 'queued', progress: 0, formatId: opts?.format?.id, formatHasAudio: opts?.format?.hasAudio, options: opts?.options, extraArgs: opts?.extraArgs, + trim: opts?.trim, + scheduledFor, incognito: opts?.incognito, collection: opts?.collection, mediaItemId: opts?.mediaItemId, @@ -297,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => { formatHasAudio: item.formatHasAudio, options: item.options, extraArgs: item.extraArgs, + trim: item.trim, meta: item.probedMeta, collection: item.collection }) @@ -357,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => { pump() }, + retryAll: () => { + set((s) => ({ + items: s.items.map((i) => + i.status === 'error' + ? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined } + : i + ) + })) + pump() + }, + + pause: (id) => { + const cur = get().items.find((i) => i.id === id) + if (!cur) return + // Only a running download has a live process to stop; a queued item is + // just parked in place. Either way it becomes 'paused' (pump ignores it). + if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {}) + set((s) => ({ + items: s.items.map((i) => + i.id === id && (i.status === 'downloading' || i.status === 'queued') + ? { ...i, status: 'paused', speed: undefined, eta: undefined } + : i + ) + })) + pump() // pausing a running item frees a slot + }, + + resume: (id) => { + // Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and + // launchItem re-spawns yt-dlp, which continues the partial file by default. + set((s) => ({ + items: s.items.map((i) => + i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i + ) + })) + pump() + }, + + prioritize: (id) => { + // Reposition the item so pump() — which promotes the highest-index queued + // item first (oldest-first over a newest-first array) — picks it next. + set((s) => { + const idx = s.items.findIndex((i) => i.id === id) + if (idx < 0 || s.items[idx].status !== 'queued') return {} + const items = s.items.slice() + const [it] = items.splice(idx, 1) + let after = -1 + for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k + items.splice(after + 1, 0, it) + return { items } + }) + pump() + }, + + saveForLater: (id) => { + set((s) => ({ + items: s.items.map((i) => + i.id === id && i.status === 'queued' + ? { ...i, status: 'saved', scheduledFor: undefined } + : i + ) + })) + }, + + queueNow: (id) => { + set((s) => ({ + items: s.items.map((i) => + i.id === id && i.status === 'saved' + ? { ...i, status: 'queued', scheduledFor: undefined } + : i + ) + })) + pump() + }, + + promoteDueScheduled: () => { + const now = Date.now() + set((s) => ({ + items: s.items.map((i) => + i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now + ? { ...i, status: 'queued', scheduledFor: undefined } + : i + ) + })) + pump() + }, + cancel: (id) => { const cur = get().items.find((i) => i.id === id) if (!cur) return @@ -379,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => { clearFinished: () => set((s) => ({ - items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued') + items: s.items.filter( + (i) => + i.status === 'downloading' || + i.status === 'queued' || + i.status === 'paused' || + i.status === 'saved' + ) })), openFile: (id) => { @@ -461,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => { } }) +// 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) + // Wire main → renderer push events. (Output folder + cap live in the settings store.) if (!PREVIEW) { window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev)) diff --git a/src/renderer/src/store/queueStats.ts b/src/renderer/src/store/queueStats.ts new file mode 100644 index 0000000..f12f077 --- /dev/null +++ b/src/renderer/src/store/queueStats.ts @@ -0,0 +1,139 @@ +/** + * Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live + * queue, and same-video detection for the duplicate guard. Kept free of any store + * / window dependency (only a type-only import of DownloadItem) so it can be + * unit-tested without constructing the Zustand store or its preview ticker. + */ +import type { DownloadItem } from './downloads' + +// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant +// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024 +// difference is immaterial for a live display estimate. +function parseSpeed(s?: string): number { + if (!s) return 0 + const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s) + if (!m) return 0 + const n = parseFloat(m[1]) + if (!isFinite(n)) return 0 + const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 } + return n * (mult[m[2].toUpperCase()] ?? 1) +} + +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec <= 0) return '' + const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'] + let v = bytesPerSec + let i = 0 + while (v >= 1000 && i < units.length - 1) { + v /= 1000 + i++ + } + return `${v.toFixed(1)} ${units[i]}` +} + +// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds. +function parseEtaSeconds(s?: string): number { + if (!s) return 0 + const parts = s.split(':').map((p) => Number(p)) + if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0 + return parts.reduce((acc, p) => acc * 60 + p, 0) +} + +function formatEta(totalSeconds: number): string { + if (totalSeconds <= 0) return '' + const s = Math.round(totalSeconds) + const m = Math.floor(s / 60) + return `${m}:${String(s % 60).padStart(2, '0')}` +} + +export interface QueueSummary { + downloading: number + queued: number + failed: number + /** true when something is downloading or waiting */ + active: boolean + /** 0..1, averaged over downloading + queued items */ + progress: number + /** combined download speed, formatted; '' when nothing is downloading */ + speedLabel: string + /** rough time remaining (the longest active ETA), formatted; '' when unknown */ + etaLabel: string +} + +/** + * Aggregate the live queue into a one-line summary for the Downloads header. + * Combined speed is summed across downloading items; the "time left" is the + * longest single ETA — a reasonable proxy for when the current batch finishes + * (we don't track remaining bytes, so a true combined ETA isn't available). + * Queued items count toward the progress average as 0%, so the bar reflects + * "how much of the visible work is done", not just the active downloads. + */ +export function summarizeQueue(items: DownloadItem[]): QueueSummary { + let downloading = 0 + let queued = 0 + let failed = 0 + let progressSum = 0 + let progressCount = 0 + let speed = 0 + let maxEta = 0 + for (const i of items) { + if (i.status === 'downloading') { + downloading++ + progressSum += i.progress + progressCount++ + speed += parseSpeed(i.speed) + maxEta = Math.max(maxEta, parseEtaSeconds(i.eta)) + } else if (i.status === 'queued') { + queued++ + progressCount++ + } else if (i.status === 'error') { + failed++ + } + } + return { + downloading, + queued, + failed, + active: downloading > 0 || queued > 0, + progress: progressCount > 0 ? progressSum / progressCount : 0, + speedLabel: formatSpeed(speed), + etaLabel: formatEta(maxEta) + } +} + +// --- Duplicate detection ---------------------------------------------------- + +function youtubeId(url: string): string | null { + try { + const u = new URL(url) + const host = u.hostname.replace(/^www\./, '') + if (host === 'youtu.be') return u.pathname.slice(1) || null + if (host.endsWith('youtube.com')) return u.searchParams.get('v') + } catch { + /* not a URL */ + } + return null +} + +function normalizeUrl(url: string): string { + try { + const u = new URL(url) + const host = u.hostname.replace(/^www\./, '').toLowerCase() + return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}` + } catch { + return url.trim() + } +} + +/** + * True when two URLs point at the same video: a matching YouTube id when both + * are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.` + * and a trailing slash stripped). Powers the "already in your queue" guard. + */ +export function sameVideo(a: string, b: string): boolean { + if (a === b) return true + const ya = youtubeId(a) + const yb = youtubeId(b) + if (ya && yb) return ya === yb + return normalizeUrl(a) === normalizeUrl(b) +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 0306d96..2a32559 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -19,6 +19,7 @@ export const IpcChannels = { probe: 'media:probe', downloadStart: 'download:start', downloadCancel: 'download:cancel', + downloadPause: 'download:pause', defaultFolder: 'download:default-folder', chooseFolder: 'download:choose-folder', openPath: 'shell:open-path', @@ -158,6 +159,8 @@ export interface DownloadOptions { sponsorBlockCategories: SponsorBlockCategory[] /** embed chapter markers */ embedChapters: boolean + /** split the download into one file per chapter (--split-chapters) */ + splitChapters: boolean /** embed metadata (title/artist/etc.) */ embedMetadata: boolean /** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */ @@ -183,6 +186,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = { sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'], embedChapters: false, + splitChapters: false, embedMetadata: true, embedThumbnail: true, cropThumbnail: false, @@ -389,6 +393,13 @@ export interface StartDownloadOptions { * download even when the settings default is enabled. */ extraArgs?: string + /** + * Optional trim: keep only these time ranges. Raw user text — ranges like + * '1:30-2:00', comma- or newline-separated — normalised to yt-dlp + * `--download-sections` specs in buildArgs (parseTrimSections). Empty or + * undefined downloads the whole media. + */ + trim?: string /** * Metadata the renderer already fetched when probing the URL (title/channel/ * duration). When present, main uses it directly instead of spawning a second diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index 876a627..24cca72 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { buildArgs, parseExtraArgs, + parseTrimSections, selectExtraArgs, formatCommandLine, sanitizeDirSegment, @@ -460,6 +461,71 @@ describe('sidecar files', () => { }) }) +describe('chapters', () => { + it('emits --split-chapters when splitChapters is on', () => { + expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters') + }) + + it('does not emit --split-chapters by default', () => { + expect(build(opts(), dlo())).not.toContain('--split-chapters') + }) + + it('emits --split-chapters independently of --embed-chapters', () => { + const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false })) + expect(argv).toContain('--split-chapters') + expect(argv).not.toContain('--embed-chapters') + }) +}) + +describe('parseTrimSections', () => { + it('normalises plain time ranges to *START-END specs', () => { + expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00']) + expect(parseTrimSections('90-120')).toEqual(['*90-120']) + expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20']) + }) + + it('splits on commas and newlines and trims whitespace', () => { + expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([ + '*0:30-1:00', + '*2:00-2:30', + '*3:00-3:30' + ]) + }) + + it('keeps an existing leading * but does not double it', () => { + expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00']) + }) + + it('drops malformed tokens so garbage never reaches the argv', () => { + expect(parseTrimSections('not-a-range')).toEqual([]) + expect(parseTrimSections('1:30')).toEqual([]) // no end + expect(parseTrimSections('')).toEqual([]) + expect(parseTrimSections(undefined)).toEqual([]) + }) +}) + +describe('trim (--download-sections)', () => { + it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => { + const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo()) + expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true) + expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true) + expect(argv).toContain('--force-keyframes-at-cuts') + }) + + it('emits nothing when there is no trim', () => { + const argv = build(opts(), dlo()) + expect(argv).not.toContain('--download-sections') + }) + + it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => { + const argv = build( + opts({ trim: '0:30-1:00' }), + dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] }) + ) + expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1) + }) +}) + // --- Collection folder paths (Phase G, media-manager) ----------------------- describe('sanitizeDirSegment', () => { diff --git a/test/queueStats.test.ts b/test/queueStats.test.ts new file mode 100644 index 0000000..50f9de7 --- /dev/null +++ b/test/queueStats.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats' +import type { DownloadItem } from '../src/renderer/src/store/downloads' + +// Minimal item factory — only the fields summarizeQueue reads. +function item(overrides: Partial<DownloadItem>): DownloadItem { + return { + id: Math.random().toString(36).slice(2), + url: 'https://youtube.com/watch?v=x', + title: 'x', + kind: 'video', + quality: '1080p', + status: 'queued', + progress: 0, + ...overrides + } +} + +describe('summarizeQueue', () => { + it('counts statuses and flags active', () => { + const s = summarizeQueue([ + item({ status: 'downloading', progress: 0.5 }), + item({ status: 'queued' }), + item({ status: 'error' }), + item({ status: 'completed', progress: 1 }), + item({ status: 'canceled' }) + ]) + expect(s.downloading).toBe(1) + expect(s.queued).toBe(1) + expect(s.failed).toBe(1) + expect(s.active).toBe(true) + }) + + it('is inactive and zeroed when nothing is downloading or queued', () => { + const s = summarizeQueue([item({ status: 'completed', progress: 1 })]) + expect(s.active).toBe(false) + expect(s.progress).toBe(0) + expect(s.speedLabel).toBe('') + expect(s.etaLabel).toBe('') + }) + + it('averages progress over downloading + queued (queued counts as 0%)', () => { + // one at 80%, one queued at 0% → 40% + const s = summarizeQueue([ + item({ status: 'downloading', progress: 0.8 }), + item({ status: 'queued' }) + ]) + expect(s.progress).toBeCloseTo(0.4, 5) + }) + + it('sums download speeds across MB/s and MiB/s strings', () => { + const s = summarizeQueue([ + item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }), + item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' }) + ]) + // 4.0 MB/s + ~2.0 MB/s ≈ 6.0 MB/s + expect(s.speedLabel).toBe('6.0 MB/s') + }) + + it('reports the longest active ETA as the time left', () => { + const s = summarizeQueue([ + item({ status: 'downloading', progress: 0.5, eta: '0:30' }), + item({ status: 'downloading', progress: 0.2, eta: '2:10' }) + ]) + expect(s.etaLabel).toBe('2:10') + }) +}) + +describe('sameVideo', () => { + it('matches identical URLs', () => { + expect(sameVideo('https://x.com/a', 'https://x.com/a')).toBe(true) + }) + + it('matches YouTube links by video id across watch/youtu.be/extra params', () => { + expect( + sameVideo( + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtu.be/dQw4w9WgXcQ' + ) + ).toBe(true) + expect( + sameVideo( + 'https://youtube.com/watch?v=dQw4w9WgXcQ&list=PL123', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ) + ).toBe(true) + }) + + it('does not match different videos', () => { + expect( + sameVideo( + 'https://youtube.com/watch?v=aaaaaaaaaaa', + 'https://youtube.com/watch?v=bbbbbbbbbbb' + ) + ).toBe(false) + }) + + it('normalises www. and trailing slash for non-YouTube URLs', () => { + expect(sameVideo('https://www.vimeo.com/123/', 'https://vimeo.com/123')).toBe(true) + }) +})