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:
@@ -161,6 +161,30 @@ export function selectExtraArgs(params: {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
|
||||
* into yt-dlp `--download-sections` specs. Each range is normalised to the
|
||||
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
|
||||
* timestamp range from a chapter-title regex). Tokens that don't look like a
|
||||
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
|
||||
*
|
||||
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
|
||||
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
|
||||
*/
|
||||
export function parseTrimSections(raw: string | undefined): string[] {
|
||||
if (!raw) return []
|
||||
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
||||
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||
const out: string[] = []
|
||||
for (const piece of raw.split(/[,\n]/)) {
|
||||
let t = piece.trim()
|
||||
if (!t) continue
|
||||
if (t.startsWith('*')) t = t.slice(1).trim()
|
||||
if (RANGE.test(t)) out.push(`*${t}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Wrap a single argv token for human-readable display only (Phase C command
|
||||
// preview) — never used to build the real argv that gets spawned.
|
||||
function quoteForDisplay(arg: string): string {
|
||||
@@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
let forcedKeyframes = false
|
||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||
const cats = o.sponsorBlockCategories.join(',')
|
||||
if (o.sponsorBlockMode === 'remove') {
|
||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||
forcedKeyframes = true
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
// Trim — download only the given time ranges (works for video + audio). Force
|
||||
// keyframes at the cut points for frame-accurate boundaries, unless
|
||||
// SponsorBlock-remove already requested it (the flag is idempotent, but we
|
||||
// avoid emitting it twice).
|
||||
const sections = parseTrimSections(opts.trim)
|
||||
for (const sec of sections) args.push('--download-sections', sec)
|
||||
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||
if (o.splitChapters) args.push('--split-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||
|
||||
+32
-10
@@ -38,6 +38,7 @@ import {
|
||||
interface ActiveDownload {
|
||||
child: ChildProcess
|
||||
canceled: boolean
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
@@ -295,7 +296,7 @@ export function startDownload(
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
const rec: ActiveDownload = { child, canceled: false }
|
||||
const rec: ActiveDownload = { child, canceled: false, paused: false }
|
||||
active.set(opts.id, rec)
|
||||
|
||||
// Title/channel/duration are kept locally so the completion notification and
|
||||
@@ -344,7 +345,8 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (!rec.canceled) {
|
||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||
if (!rec.canceled && !rec.paused) {
|
||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||
logFailure(opts, resolvedTitle, err.message)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||
@@ -355,7 +357,9 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
if (rec.canceled || rec.paused) return
|
||||
if (code === 0) {
|
||||
send(wc, { type: 'done', id: opts.id, filePath })
|
||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||
@@ -370,15 +374,12 @@ export function startDownload(
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||
function killTree(rec: ActiveDownload): void {
|
||||
const pid = rec.child.pid
|
||||
if (pid != null) {
|
||||
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||
execFile(
|
||||
getSystem32Path('taskkill.exe'),
|
||||
['/pid', String(pid), '/T', '/F'],
|
||||
@@ -389,3 +390,24 @@ export function cancelDownload(id: string): void {
|
||||
rec.child.kill()
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a running download: kill the process tree but flag it `paused` (not
|
||||
* `canceled`) so the close handler stays silent — no error event, no error log,
|
||||
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
|
||||
* is just a fresh startDownload with the same options: yt-dlp's default
|
||||
* `--continue` picks the partial file back up.
|
||||
*/
|
||||
export function pauseDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.paused = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
||||
import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
@@ -211,6 +211,7 @@ function registerIpcHandlers(): void {
|
||||
return result
|
||||
})
|
||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||
sponsorBlockCategories: cats,
|
||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||
|
||||
Reference in New Issue
Block a user