feat: queue UX (Phase M) + media editing (split-chapters, trim)
Phase L (media editing): - Split by chapters (--split-chapters) as a DownloadOptions toggle - Trim/cut by time range: parseTrimSections -> --download-sections "*A-B" + --force-keyframes-at-cuts (deduped vs SponsorBlock), per-download Trim panel Phase M (queue & daily-use UX), all 7: - Pause/resume: main-side killTree + paused flag (silent close), download:pause IPC, store pause/resume + paused status, QueueItem controls - Reorder via "Download next" (prioritize) - Aggregate progress strip (pure summarizeQueue) + combined speed/ETA - Drag-and-drop links / .url files onto the download card - Retry all failed - Duplicate detection (sameVideo) with "Download anyway" guard - Per-download scheduling (datetime-local) + save-for-later (saved status, 15s promotion ticker); session-only (queue not persisted) New pure modules unit-tested (queueStats); buildArgs trim/split tested. typecheck + test green (178 passed). Roadmap updated: Phase M COMPLETE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+17
-17
@@ -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
|
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
|
||||||
yet. This is the prerequisite for every later phase.
|
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/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
|
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
|
||||||
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
|
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
|
||||||
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
|
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
|
||||||
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
|
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 from `…/videos` for videos that belong to no playlist. Lazy-probe each
|
||||||
playlist's video list on demand rather than all up front.
|
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`:
|
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
|
||||||
```ts
|
```ts
|
||||||
interface Source {
|
interface Source {
|
||||||
@@ -122,11 +122,11 @@ yet. This is the prerequisite for every later phase.
|
|||||||
filePath?: string
|
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
|
(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
|
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
|
||||||
no real playlist.
|
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
|
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
|
||||||
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
|
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`.
|
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
|
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
|
||||||
joined subpath) so each `MediaItem` downloads into
|
joined subpath) so each `MediaItem` downloads into
|
||||||
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
|
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
|
||||||
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
|
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
|
||||||
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
|
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`).
|
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
|
||||||
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
|
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
|
||||||
not the directory segments AeroFetch constructs from channel/playlist names.
|
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.
|
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.
|
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),
|
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
|
||||||
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
|
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
|
||||||
Per-playlist select-all, live counts.
|
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
|
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
|
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
|
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
|
||||||
records to history exactly as today.
|
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
|
`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`).
|
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.
|
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
|
||||||
|
|
||||||
## Phase I — Incremental sync & dedup ✅ COMPLETE
|
## Phase I — Incremental sync & dedup ✅ COMPLETE
|
||||||
|
|
||||||
Re-running a channel should grab only what's new — the everyday media-manager loop.
|
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
|
persisted `MediaItem`s and marks only the new ones; existing records (and their
|
||||||
`downloaded` state) are preserved. "X new since last sync" badge.
|
`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`,
|
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
|
||||||
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
||||||
can't re-download.
|
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 +
|
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).
|
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.
|
- [x] **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] **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
|
(`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
|
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
|
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
|
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
|
||||||
playlists / initial scans").
|
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
|
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
|
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
|
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
|
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.
|
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
|
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.
|
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
|
||||||
|
|
||||||
|
|||||||
+63
-39
@@ -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
|
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
|
||||||
**Copy full report** and **Clear log**.
|
**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.
|
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,
|
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
|
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
|
||||||
toggle's break-out-of-system behavior all checked visually.
|
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
|
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
||||||
(non-MSIX) app can't register as an actual Share Target:
|
(non-MSIX) app can't register as an actual Share Target:
|
||||||
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
|
- **`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
|
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
|
||||||
`src/main/buildArgs.ts`), so this is cheaper than it looks.
|
`src/main/buildArgs.ts`), so this is cheaper than it looks.
|
||||||
|
|
||||||
- [ ] **Trim / cut downloads.** Download only part of a video, or remove segments — by
|
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
|
||||||
**timestamp** or by **chapter**, with multiple cuts allowed. → yt-dlp
|
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
|
||||||
`--download-sections "*START-END"` (repeatable) + `--force-keyframes-at-cuts`.
|
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
|
||||||
Chapters come free from the probe's `yt-dlp -J` `chapters[]` array (`src/main/probe.ts`).
|
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
|
||||||
New fields on `DownloadOptions` (or a per-download override), surfaced as a **Trim**
|
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
|
||||||
panel in `src/renderer/src/components/DownloadBar.tsx`. *v1 can skip a scrubber/preview
|
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
|
||||||
player — timestamp inputs + checkboxes of the probed chapter list is enough. Smoke-test
|
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
|
||||||
the exact multi-section output behaviour (one file vs. per-section) the same way
|
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
|
||||||
Phases A–C smoke-tested live yt-dlp.*
|
player, still TODO).*
|
||||||
- [ ] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a
|
- [x] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a
|
||||||
TODO in Phase A above). One toggle on `DownloadOptions` +
|
TODO in Phase A above). One toggle on `DownloadOptions` +
|
||||||
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. Uses an
|
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
|
||||||
`--output chapter:` template for the per-chapter filenames.
|
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
|
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
|
||||||
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
|
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
|
||||||
`--ppa "Metadata:-metadata title=…"`; fits the audio path of `DownloadOptions`. Good for
|
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
|
||||||
building a clean music library where the source title is messy.
|
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.
|
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.
|
Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
|
||||||
|
|
||||||
- [ ] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
|
- [x] **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
|
(already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
|
||||||
keep the partial + mark the item `paused`; resume = relaunch the same item, which
|
`taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
|
||||||
continues. New `paused` state in the queue store.
|
the close/error handlers stay silent (no error event/log/notification); new
|
||||||
- [ ] **Reorder / prioritize the queue.** Drag a queued item up. The renderer scheduler
|
`download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
|
||||||
(`pump()` promotes oldest-queued-first) already treats the queued array as the order, so
|
a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
|
||||||
this is reordering that array.
|
progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
|
||||||
- [ ] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
|
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
|
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
|
||||||
progress bar.
|
progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
|
||||||
- [ ] **Drag-and-drop a link or `.url` file onto the window.** A window `drop` handler that
|
`DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
|
||||||
accepts `text/uri-list` and Internet-Shortcut files, reusing `extractIncomingUrl()`
|
(`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
|
||||||
from `src/main/deeplink.ts` (already validates http(s)-only).
|
we don't track per-item sizes — so "time left" is the longest single ETA.*
|
||||||
- [ ] **Retry all failed.** One action that re-enqueues every entry in `src/main/errorlog.ts`.
|
- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
|
||||||
- [ ] **Duplicate detection.** Warn ("you already have this") when a URL/video-id is already in
|
(`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
|
||||||
history or the active queue before enqueuing.
|
accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
|
||||||
- [ ] **Per-download scheduling + "save for later."** YTDLnis lets you "schedule by date and
|
Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
|
||||||
time" and park items. Add `scheduledFor?: number` + a `saved` state to `DownloadItem`;
|
the existing `looksLikeUrl`. A dropped link fills the URL field.*
|
||||||
`pump()` gains a time gate before promotion. Distinct from the existing background
|
- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
|
||||||
`--sync` schedule (Phase J), which is for *sources*, not one-off items.
|
`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
|
## Phase N — Power-user surface
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,30 @@ export function selectExtraArgs(params: {
|
|||||||
return []
|
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
|
// Wrap a single argv token for human-readable display only (Phase C command
|
||||||
// preview) — never used to build the real argv that gets spawned.
|
// preview) — never used to build the real argv that gets spawned.
|
||||||
function quoteForDisplay(arg: string): string {
|
function quoteForDisplay(arg: string): string {
|
||||||
@@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SponsorBlock.
|
// SponsorBlock.
|
||||||
|
let forcedKeyframes = false
|
||||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||||
const cats = o.sponsorBlockCategories.join(',')
|
const cats = o.sponsorBlockCategories.join(',')
|
||||||
if (o.sponsorBlockMode === 'remove') {
|
if (o.sponsorBlockMode === 'remove') {
|
||||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||||
|
forcedKeyframes = true
|
||||||
} else {
|
} else {
|
||||||
args.push('--sponsorblock-mark', cats)
|
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')
|
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')
|
if (o.embedMetadata) args.push('--embed-metadata')
|
||||||
|
|
||||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||||
|
|||||||
+32
-10
@@ -38,6 +38,7 @@ import {
|
|||||||
interface ActiveDownload {
|
interface ActiveDownload {
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
canceled: boolean
|
canceled: boolean
|
||||||
|
paused: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = new Map<string, ActiveDownload>()
|
const active = new Map<string, ActiveDownload>()
|
||||||
@@ -295,7 +296,7 @@ export function startDownload(
|
|||||||
return { ok: false, error: (e as Error).message }
|
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)
|
active.set(opts.id, rec)
|
||||||
|
|
||||||
// Title/channel/duration are kept locally so the completion notification and
|
// Title/channel/duration are kept locally so the completion notification and
|
||||||
@@ -344,7 +345,8 @@ export function startDownload(
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
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 })
|
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||||
logFailure(opts, resolvedTitle, err.message)
|
logFailure(opts, resolvedTitle, err.message)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||||
@@ -355,7 +357,9 @@ export function startDownload(
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
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) {
|
if (code === 0) {
|
||||||
send(wc, { type: 'done', id: opts.id, filePath })
|
send(wc, { type: 'done', id: opts.id, filePath })
|
||||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||||
@@ -370,15 +374,12 @@ export function startDownload(
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelDownload(id: string): void {
|
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||||
const rec = active.get(id)
|
|
||||||
if (!rec) return
|
|
||||||
rec.canceled = true
|
|
||||||
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 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)
|
// 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) {
|
||||||
execFile(
|
execFile(
|
||||||
getSystem32Path('taskkill.exe'),
|
getSystem32Path('taskkill.exe'),
|
||||||
['/pid', String(pid), '/T', '/F'],
|
['/pid', String(pid), '/T', '/F'],
|
||||||
@@ -389,3 +390,24 @@ export function cancelDownload(id: string): void {
|
|||||||
rec.child.kill()
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
|||||||
import { getFfmpegVersions } from './ffmpeg'
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
@@ -211,6 +211,7 @@ function registerIpcHandlers(): void {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
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'))
|
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
|||||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||||
sponsorBlockCategories: cats,
|
sponsorBlockCategories: cats,
|
||||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||||
|
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ const api = {
|
|||||||
cancelDownload: (id: string): Promise<void> =>
|
cancelDownload: (id: string): Promise<void> =>
|
||||||
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
||||||
|
|
||||||
|
pauseDownload: (id: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||||
|
|
||||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
||||||
|
|
||||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Field,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -20,10 +22,14 @@ import {
|
|||||||
ErrorCircleRegular,
|
ErrorCircleRegular,
|
||||||
LinkRegular,
|
LinkRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
AppsListRegular
|
AppsListRegular,
|
||||||
|
CutRegular,
|
||||||
|
CalendarClockRegular,
|
||||||
|
WarningRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||||
|
import { sameVideo } from '../store/queueStats'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { Hint } from './Hint'
|
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({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
|
|||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||||
boxShadow: tokens.shadow4
|
boxShadow: tokens.shadow4
|
||||||
},
|
},
|
||||||
|
// Highlight while a link / .url file is dragged over the card.
|
||||||
|
rootDragging: {
|
||||||
|
outline: `2px dashed ${tokens.colorBrandStroke1}`,
|
||||||
|
outlineOffset: '-2px'
|
||||||
|
},
|
||||||
urlRow: {
|
urlRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '8px'
|
gap: '8px'
|
||||||
@@ -212,6 +240,45 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
plItemMeta: {
|
plItemMeta: {
|
||||||
color: tokens.colorNeutralForeground3
|
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 [kind, setKind] = useState<MediaKind>('video')
|
||||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
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.
|
// Apply the saved default format once, when persisted settings first arrive.
|
||||||
const appliedDefaults = useRef(false)
|
const appliedDefaults = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -320,8 +428,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
|
|
||||||
function onUrlChange(next: string): void {
|
function onUrlChange(next: string): void {
|
||||||
setUrl(next)
|
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 (info || probeError || playlist) clearProbe()
|
||||||
|
if (dup) setDup(null)
|
||||||
|
confirmDup.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKindChange(next: MediaKind): void {
|
function onKindChange(next: MediaKind): void {
|
||||||
@@ -383,6 +493,23 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (!trimmed) return
|
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
|
const meta = info
|
||||||
? {
|
? {
|
||||||
title: info.title,
|
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) {
|
if (usingFormats && selectedFormat) {
|
||||||
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||||
...meta,
|
...meta,
|
||||||
|
trim: trimSpec,
|
||||||
|
scheduledFor,
|
||||||
format: {
|
format: {
|
||||||
id: selectedFormat.id,
|
id: selectedFormat.id,
|
||||||
hasAudio: selectedFormat.hasAudio,
|
hasAudio: selectedFormat.hasAudio,
|
||||||
@@ -402,13 +534,22 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addFromUrl(trimmed, kind, quality, meta)
|
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
|
setTrim('')
|
||||||
|
setShowTrim(false)
|
||||||
|
setScheduleAt('')
|
||||||
|
setShowSchedule(false)
|
||||||
clearProbe()
|
clearProbe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadAnyway(): void {
|
||||||
|
confirmDup.current = true
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
|
||||||
function addPlaylist(): void {
|
function addPlaylist(): void {
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||||
@@ -424,7 +565,12 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div
|
||||||
|
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={() => setDragActive(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
<div className={styles.urlRow}>
|
<div className={styles.urlRow}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.url}
|
className={styles.url}
|
||||||
@@ -474,6 +620,23 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</div>
|
</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 && (
|
{probing && (
|
||||||
<div className={styles.statusRow}>
|
<div className={styles.statusRow}>
|
||||||
<Spinner size="tiny" />
|
<Spinner size="tiny" />
|
||||||
@@ -550,6 +713,59 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</div>
|
</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.controls}>
|
||||||
<div className={styles.control}>
|
<div className={styles.control}>
|
||||||
<Caption1>Format</Caption1>
|
<Caption1>Format</Caption1>
|
||||||
@@ -606,11 +822,11 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<ArrowDownloadRegular />}
|
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
disabled={!url.trim()}
|
disabled={!url.trim()}
|
||||||
>
|
>
|
||||||
Download
|
{scheduleAt ? 'Schedule' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,6 +204,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
label={value.embedChapters ? 'On' : 'Off'}
|
label={value.embedChapters ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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.">
|
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedMetadata}
|
checked={value.embedMetadata}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
Subtitle2,
|
Subtitle2,
|
||||||
Body1,
|
Body1,
|
||||||
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens
|
tokens,
|
||||||
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { ArrowDownloadRegular } from '@fluentui/react-icons'
|
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads } from '../store/downloads'
|
||||||
|
import { summarizeQueue } from '../store/queueStats'
|
||||||
import { DownloadBar } from './DownloadBar'
|
import { DownloadBar } from './DownloadBar'
|
||||||
import { QueueItem } from './QueueItem'
|
import { QueueItem } from './QueueItem'
|
||||||
import { VirtualList } from './VirtualList'
|
import { VirtualList } from './VirtualList'
|
||||||
@@ -26,6 +30,27 @@ const useStyles = makeStyles({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
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: {
|
listScroll: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
// min-height:0 lets this flex child shrink below its content height so it,
|
// 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 styles = useStyles()
|
||||||
const items = useDownloads((s) => s.items)
|
const items = useDownloads((s) => s.items)
|
||||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||||
|
const retryAll = useDownloads((s) => s.retryAll)
|
||||||
|
|
||||||
|
const summary = summarizeQueue(items)
|
||||||
const hasFinished = items.some(
|
const hasFinished = items.some(
|
||||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||||
)
|
)
|
||||||
@@ -56,14 +83,38 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<DownloadBar />
|
<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}>
|
<div className={styles.queueHeader}>
|
||||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
{summary.failed > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowClockwiseRegular />}
|
||||||
|
onClick={retryAll}
|
||||||
|
>
|
||||||
|
Retry all failed ({summary.failed})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasFinished && (
|
{hasFinished && (
|
||||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||||
Clear finished
|
Clear finished
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const STATUS_LABEL: Record<ItemStatus, string> = {
|
|||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
downloading: 'Downloading',
|
downloading: 'Downloading',
|
||||||
|
paused: 'Paused',
|
||||||
|
saved: 'Saved',
|
||||||
completed: 'Downloaded',
|
completed: 'Downloaded',
|
||||||
error: 'Failed',
|
error: 'Failed',
|
||||||
canceled: 'Canceled'
|
canceled: 'Canceled'
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
OpenRegular,
|
OpenRegular,
|
||||||
FolderRegular,
|
FolderRegular,
|
||||||
ArrowClockwiseRegular,
|
ArrowClockwiseRegular,
|
||||||
|
ArrowDownloadRegular,
|
||||||
|
ArrowUpRegular,
|
||||||
|
BookmarkRegular,
|
||||||
|
PauseRegular,
|
||||||
|
PlayRegular,
|
||||||
CheckmarkCircleFilled,
|
CheckmarkCircleFilled,
|
||||||
ErrorCircleFilled,
|
ErrorCircleFilled,
|
||||||
EyeOffRegular
|
EyeOffRegular
|
||||||
@@ -91,6 +96,8 @@ const useStyles = makeStyles({
|
|||||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||||
queued: { label: 'Queued', color: 'subtle' },
|
queued: { label: 'Queued', color: 'subtle' },
|
||||||
downloading: { label: 'Downloading', color: 'brand' },
|
downloading: { label: 'Downloading', color: 'brand' },
|
||||||
|
paused: { label: 'Paused', color: 'warning' },
|
||||||
|
saved: { label: 'Saved', color: 'subtle' },
|
||||||
completed: { label: 'Completed', color: 'success' },
|
completed: { label: 'Completed', color: 'success' },
|
||||||
error: { label: 'Failed', color: 'danger' },
|
error: { label: 'Failed', color: 'danger' },
|
||||||
canceled: { label: 'Canceled', color: 'warning' }
|
canceled: { label: 'Canceled', color: 'warning' }
|
||||||
@@ -100,6 +107,14 @@ function pct(progress: number): string {
|
|||||||
return `${Math.round(progress * 100)}%`
|
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({
|
export const QueueItem = memo(function QueueItem({
|
||||||
item
|
item
|
||||||
}: {
|
}: {
|
||||||
@@ -107,6 +122,11 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const cancel = useDownloads((s) => s.cancel)
|
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 remove = useDownloads((s) => s.remove)
|
||||||
const retry = useDownloads((s) => s.retry)
|
const retry = useDownloads((s) => s.retry)
|
||||||
const openFile = useDownloads((s) => s.openFile)
|
const openFile = useDownloads((s) => s.openFile)
|
||||||
@@ -177,12 +197,79 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
</div>
|
</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 && (
|
{item.status === 'error' && item.error && (
|
||||||
<Caption1 className={styles.error}>{item.error}</Caption1>
|
<Caption1 className={styles.error}>{item.error}</Caption1>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<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 && (
|
{active && (
|
||||||
<Hint label="Cancel" placement="top" align="end">
|
<Hint label="Cancel" placement="top" align="end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
},
|
},
|
||||||
startDownload: async () => ({ ok: true }),
|
startDownload: async () => ({ ok: true }),
|
||||||
cancelDownload: async () => {},
|
cancelDownload: async () => {},
|
||||||
|
pauseDownload: async () => {},
|
||||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||||
chooseFolder: async () => null,
|
chooseFolder: async () => null,
|
||||||
openPath: async () => '',
|
openPath: async () => '',
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { useSources } from './sources'
|
|||||||
|
|
||||||
export type MediaKind = 'video' | 'audio'
|
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). */
|
/** An exact probed format chosen for a download (omitted for the preset path). */
|
||||||
export interface ChosenFormat {
|
export interface ChosenFormat {
|
||||||
@@ -39,6 +46,10 @@ export interface DownloadItem {
|
|||||||
options?: DownloadOptions
|
options?: DownloadOptions
|
||||||
/** per-download custom-command override (omitted = use the persisted default template) */
|
/** per-download custom-command override (omitted = use the persisted default template) */
|
||||||
extraArgs?: string
|
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 */
|
/** private download — completion is never recorded to history */
|
||||||
incognito?: boolean
|
incognito?: boolean
|
||||||
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
||||||
@@ -63,6 +74,9 @@ export interface AddOptions {
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
options?: DownloadOptions
|
options?: DownloadOptions
|
||||||
extraArgs?: string
|
extraArgs?: string
|
||||||
|
trim?: string
|
||||||
|
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
|
||||||
|
scheduledFor?: number
|
||||||
incognito?: boolean
|
incognito?: boolean
|
||||||
collection?: CollectionContext
|
collection?: CollectionContext
|
||||||
mediaItemId?: string
|
mediaItemId?: string
|
||||||
@@ -90,6 +104,20 @@ interface DownloadState {
|
|||||||
*/
|
*/
|
||||||
addMany: (entries: AddEntry[]) => void
|
addMany: (entries: AddEntry[]) => void
|
||||||
retry: (id: string) => 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
|
cancel: (id: string) => void
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
clearFinished: () => void
|
clearFinished: () => void
|
||||||
@@ -117,6 +145,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
|
|||||||
opts?.title || opts?.channel || opts?.durationLabel
|
opts?.title || opts?.channel || opts?.durationLabel
|
||||||
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
||||||
: undefined
|
: 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 {
|
return {
|
||||||
id: newId(),
|
id: newId(),
|
||||||
url,
|
url,
|
||||||
@@ -126,12 +158,14 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
|
|||||||
thumbnail: opts?.thumbnail,
|
thumbnail: opts?.thumbnail,
|
||||||
kind,
|
kind,
|
||||||
quality,
|
quality,
|
||||||
status: 'queued',
|
status: scheduledFor ? 'saved' : 'queued',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
formatId: opts?.format?.id,
|
formatId: opts?.format?.id,
|
||||||
formatHasAudio: opts?.format?.hasAudio,
|
formatHasAudio: opts?.format?.hasAudio,
|
||||||
options: opts?.options,
|
options: opts?.options,
|
||||||
extraArgs: opts?.extraArgs,
|
extraArgs: opts?.extraArgs,
|
||||||
|
trim: opts?.trim,
|
||||||
|
scheduledFor,
|
||||||
incognito: opts?.incognito,
|
incognito: opts?.incognito,
|
||||||
collection: opts?.collection,
|
collection: opts?.collection,
|
||||||
mediaItemId: opts?.mediaItemId,
|
mediaItemId: opts?.mediaItemId,
|
||||||
@@ -297,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
formatHasAudio: item.formatHasAudio,
|
formatHasAudio: item.formatHasAudio,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
extraArgs: item.extraArgs,
|
extraArgs: item.extraArgs,
|
||||||
|
trim: item.trim,
|
||||||
meta: item.probedMeta,
|
meta: item.probedMeta,
|
||||||
collection: item.collection
|
collection: item.collection
|
||||||
})
|
})
|
||||||
@@ -357,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
pump()
|
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) => {
|
cancel: (id) => {
|
||||||
const cur = get().items.find((i) => i.id === id)
|
const cur = get().items.find((i) => i.id === id)
|
||||||
if (!cur) return
|
if (!cur) return
|
||||||
@@ -379,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
|
|
||||||
clearFinished: () =>
|
clearFinished: () =>
|
||||||
set((s) => ({
|
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) => {
|
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.)
|
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
||||||
if (!PREVIEW) {
|
if (!PREVIEW) {
|
||||||
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
|
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export const IpcChannels = {
|
|||||||
probe: 'media:probe',
|
probe: 'media:probe',
|
||||||
downloadStart: 'download:start',
|
downloadStart: 'download:start',
|
||||||
downloadCancel: 'download:cancel',
|
downloadCancel: 'download:cancel',
|
||||||
|
downloadPause: 'download:pause',
|
||||||
defaultFolder: 'download:default-folder',
|
defaultFolder: 'download:default-folder',
|
||||||
chooseFolder: 'download:choose-folder',
|
chooseFolder: 'download:choose-folder',
|
||||||
openPath: 'shell:open-path',
|
openPath: 'shell:open-path',
|
||||||
@@ -158,6 +159,8 @@ export interface DownloadOptions {
|
|||||||
sponsorBlockCategories: SponsorBlockCategory[]
|
sponsorBlockCategories: SponsorBlockCategory[]
|
||||||
/** embed chapter markers */
|
/** embed chapter markers */
|
||||||
embedChapters: boolean
|
embedChapters: boolean
|
||||||
|
/** split the download into one file per chapter (--split-chapters) */
|
||||||
|
splitChapters: boolean
|
||||||
/** embed metadata (title/artist/etc.) */
|
/** embed metadata (title/artist/etc.) */
|
||||||
embedMetadata: boolean
|
embedMetadata: boolean
|
||||||
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
||||||
@@ -183,6 +186,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
|||||||
sponsorBlockMode: 'remove',
|
sponsorBlockMode: 'remove',
|
||||||
sponsorBlockCategories: ['sponsor'],
|
sponsorBlockCategories: ['sponsor'],
|
||||||
embedChapters: false,
|
embedChapters: false,
|
||||||
|
splitChapters: false,
|
||||||
embedMetadata: true,
|
embedMetadata: true,
|
||||||
embedThumbnail: true,
|
embedThumbnail: true,
|
||||||
cropThumbnail: false,
|
cropThumbnail: false,
|
||||||
@@ -389,6 +393,13 @@ export interface StartDownloadOptions {
|
|||||||
* download even when the settings default is enabled.
|
* download even when the settings default is enabled.
|
||||||
*/
|
*/
|
||||||
extraArgs?: string
|
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/
|
* Metadata the renderer already fetched when probing the URL (title/channel/
|
||||||
* duration). When present, main uses it directly instead of spawning a second
|
* duration). When present, main uses it directly instead of spawning a second
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
buildArgs,
|
buildArgs,
|
||||||
parseExtraArgs,
|
parseExtraArgs,
|
||||||
|
parseTrimSections,
|
||||||
selectExtraArgs,
|
selectExtraArgs,
|
||||||
formatCommandLine,
|
formatCommandLine,
|
||||||
sanitizeDirSegment,
|
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) -----------------------
|
// --- Collection folder paths (Phase G, media-manager) -----------------------
|
||||||
|
|
||||||
describe('sanitizeDirSegment', () => {
|
describe('sanitizeDirSegment', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user