Add Phase B: cookies, aria2c, proxy/rate-limit, restrict-filenames & archive
Implements the remaining Phase B (Access & networking) items from ROADMAP.md: cookie sources (browser cookie-store import, or a built-in sign-in window that exports a Netscape cookie file via a persisted Electron session partition), the aria2c external downloader, proxy/rate-limit, restrict-filenames, and a download-archive to skip repeats. Wires download.ts to the buildArgs() module added in the previous commit (it wasn't hooked up yet) and renames its options param NetworkOptions -> AccessOptions to reflect the wider scope now that cookies and filename/archive flags joined proxy/rate-limit/aria2c. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+26
-146
@@ -2,18 +2,18 @@ import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { app, type WebContents } from 'electron'
|
||||
import { getYtdlpPath, getBinDir } from './binaries'
|
||||
import { getSettings } from './settings'
|
||||
import { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
|
||||
import { getSettings, getDownloadArchivePath } from './settings'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { buildArgs } from './buildArgs'
|
||||
import {
|
||||
IpcChannels,
|
||||
BEST_FORMAT_ID,
|
||||
type StartDownloadOptions,
|
||||
type StartDownloadResult,
|
||||
type DownloadEvent,
|
||||
type DownloadMeta,
|
||||
type DownloadProgress,
|
||||
type DownloadOptions
|
||||
type DownloadProgress
|
||||
} from '@shared/ipc'
|
||||
|
||||
interface ActiveDownload {
|
||||
@@ -23,146 +23,6 @@ interface ActiveDownload {
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
|
||||
// --- Quality → yt-dlp selector mapping --------------------------------------
|
||||
|
||||
function videoFormat(quality: string): string {
|
||||
switch (quality) {
|
||||
case '1080p':
|
||||
return 'bv*[height<=1080]+ba/b[height<=1080]'
|
||||
case '720p':
|
||||
return 'bv*[height<=720]+ba/b[height<=720]'
|
||||
case '480p':
|
||||
return 'bv*[height<=480]+ba/b[height<=480]'
|
||||
case '360p':
|
||||
return 'bv*[height<=360]+ba/b[height<=360]'
|
||||
default:
|
||||
return 'bv*+ba/b' // Best available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The video format selector. A specific probed format_id wins; otherwise fall
|
||||
* back to the height-based preset. Video-only formats get +bestaudio so the
|
||||
* merge produces a file with sound (with a video-only fallback if no audio).
|
||||
*/
|
||||
function videoSelector(opts: StartDownloadOptions): string {
|
||||
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||
return opts.formatHasAudio
|
||||
? opts.formatId
|
||||
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
}
|
||||
return videoFormat(opts.quality)
|
||||
}
|
||||
|
||||
function audioQuality(quality: string): string {
|
||||
switch (quality) {
|
||||
case '320 kbps':
|
||||
return '320K'
|
||||
case '192 kbps':
|
||||
return '192K'
|
||||
case '128 kbps':
|
||||
return '128K'
|
||||
default:
|
||||
return '0' // Best
|
||||
}
|
||||
}
|
||||
|
||||
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
||||
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
||||
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
||||
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||
// print on the same stdout stream.
|
||||
const PROGRESS_TEMPLATE =
|
||||
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||
'%(progress.speed)s|%(progress.eta)s'
|
||||
|
||||
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
|
||||
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
|
||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||
const CROP_SQUARE_PPA =
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||
|
||||
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
||||
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
// Subtitles — video only; audio extraction has nowhere to embed them.
|
||||
if (opts.kind === 'video' && o.embedSubtitles) {
|
||||
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
|
||||
if (o.autoSubtitles) args.push('--write-auto-subs')
|
||||
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
|
||||
args.push('--convert-subs', 'srt')
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
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')
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildArgs(
|
||||
opts: StartDownloadOptions,
|
||||
outputTemplate: string,
|
||||
o: DownloadOptions
|
||||
): string[] {
|
||||
const args = [
|
||||
'--newline',
|
||||
'--no-color',
|
||||
'--no-playlist',
|
||||
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||
// forces the progress template to emit anyway.
|
||||
'--progress',
|
||||
'--ffmpeg-location',
|
||||
getBinDir(),
|
||||
'-o',
|
||||
outputTemplate,
|
||||
'--progress-template',
|
||||
PROGRESS_TEMPLATE,
|
||||
// Print the final path after post-processing/move so we can open it later.
|
||||
'--print',
|
||||
'after_move:path|%(filepath)s',
|
||||
'--no-simulate'
|
||||
]
|
||||
|
||||
args.push(...postProcessArgs(opts, o))
|
||||
|
||||
if (opts.kind === 'audio') {
|
||||
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
||||
if (o.embedThumbnail) {
|
||||
args.push('--embed-thumbnail')
|
||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||
}
|
||||
} else {
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||
// pick toward the chosen codec without overriding the requested quality.
|
||||
if (o.preferredVideoCodec !== 'any') {
|
||||
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
||||
args.push('-S', `res,fps,vcodec:${token}`)
|
||||
}
|
||||
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
|
||||
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
||||
}
|
||||
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
args.push('--', opts.url)
|
||||
return args
|
||||
}
|
||||
|
||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||
|
||||
function num(s?: string): number | undefined {
|
||||
@@ -290,10 +150,30 @@ export function startDownload(
|
||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||
// Per-download override wins; otherwise use the persisted defaults.
|
||||
const options = opts.options ?? settings.downloadOptions
|
||||
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
||||
// into resources/bin — the toggle shouldn't turn into a hard error.
|
||||
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
|
||||
// Same idea: 'login' cookies only apply once the sign-in window has actually
|
||||
// exported a file; otherwise the download proceeds cookie-less rather than failing.
|
||||
const cookiesFile =
|
||||
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
|
||||
? getCookiesFilePath()
|
||||
: undefined
|
||||
const access = {
|
||||
proxy: settings.proxy,
|
||||
rateLimit: settings.rateLimit,
|
||||
aria2cPath,
|
||||
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||
cookiesFile,
|
||||
restrictFilenames: settings.restrictFilenames,
|
||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
|
||||
}
|
||||
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true })
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
|
||||
windowsHide: true
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user