forked from pablostanley/yoinks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathytdlp.ts
More file actions
340 lines (304 loc) · 11.1 KB
/
Copy pathytdlp.ts
File metadata and controls
340 lines (304 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import {spawn, type ChildProcess} from 'node:child_process'
import {createWriteStream} from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {Readable} from 'node:stream'
import {pipeline} from 'node:stream/promises'
import {formatBytes} from './format.js'
const YOINKS_DIR = path.join(os.homedir(), '.yoinks', 'bin')
const RELEASE_BASE = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
function ytDlpAssetName(): string {
if (process.platform === 'win32') return 'yt-dlp.exe'
if (process.platform === 'darwin') return 'yt-dlp_macos'
return process.arch === 'arm64' ? 'yt-dlp_linux_aarch64' : 'yt-dlp_linux'
}
// async on purpose: a spawnSync here blocks the event loop, which freezes
// ink mid-frame — the user hits enter and sees nothing until it returns
function commandWorks(cmd: string, args: string[]): Promise<boolean> {
return new Promise(resolve => {
let child
try {
child = spawn(cmd, args, {stdio: 'ignore', timeout: 10_000})
} catch {
resolve(false)
return
}
child.on('error', () => resolve(false))
child.on('close', code => resolve(code === 0))
})
}
/**
* Resolve a usable yt-dlp binary: system install first, then a previously
* downloaded copy, then download the standalone binary from GitHub releases.
*/
export async function ensureYtDlp(onStatus: (message: string) => void, signal?: AbortSignal): Promise<string> {
if (await commandWorks('yt-dlp', ['--version'])) return 'yt-dlp'
const local = path.join(YOINKS_DIR, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp')
if (await commandWorks(local, ['--version'])) return local
onStatus('first run: fetching yt-dlp…')
await fs.mkdir(YOINKS_DIR, {recursive: true})
const url = `${RELEASE_BASE}/${ytDlpAssetName()}`
const response = await fetch(url, {signal})
if (!response.ok || !response.body) {
throw new Error(`Could not download yt-dlp (${response.status}). Check your connection and try again.`)
}
const tmp = `${local}.download`
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(tmp), {signal})
await fs.chmod(tmp, 0o755)
await fs.rename(tmp, local)
return local
}
/**
* Find ffmpeg for stream merging / mp3 extraction: system install first,
* ffmpeg-static as fallback. Returns undefined if neither exists — yt-dlp
* still works for single-file formats without it.
*/
export async function findFfmpeg(): Promise<string | undefined> {
if (await commandWorks('ffmpeg', ['-version'])) return undefined // on PATH, yt-dlp finds it itself
try {
const mod = await import('ffmpeg-static')
const ffmpegPath = (mod.default ?? mod) as unknown as string | null
if (ffmpegPath && (await commandWorks(ffmpegPath, ['-version']))) return ffmpegPath
} catch {
// ffmpeg-static not installed or unsupported platform
}
return undefined
}
export type VideoInfo = {
title: string
uploader?: string
duration?: number
webpage_url?: string
extractor_key?: string
formats?: RawFormat[]
}
type RawFormat = {
format_id: string
ext?: string
vcodec?: string
acodec?: string
height?: number
width?: number
abr?: number
tbr?: number
filesize?: number
filesize_approx?: number
}
export type ProbeResult = {
info: VideoInfo
/** Raw -J output saved to disk so downloads can skip re-extraction via --load-info-json. */
infoJsonPath: string
}
export async function probe(ytdlp: string, url: string, signal?: AbortSignal): Promise<ProbeResult> {
const stdout = await new Promise<string>((resolve, reject) => {
const child = spawn(ytdlp, ['-J', '--no-playlist', '--no-warnings', url], {signal})
let out = ''
let stderr = ''
child.stdout.on('data', chunk => (out += chunk))
child.stderr.on('data', chunk => (stderr += chunk))
child.on('error', reject)
child.on('close', code => {
if (code !== 0) {
reject(new Error(cleanYtDlpError(stderr) || `yt-dlp exited with code ${code}`))
} else {
resolve(out)
}
})
})
let info: VideoInfo
try {
info = JSON.parse(stdout) as VideoInfo
} catch {
throw new Error('Could not parse video info from yt-dlp.')
}
const infoJsonPath = path.join(os.tmpdir(), `yoinks-info-${process.pid}-${Date.now()}.json`)
await fs.writeFile(infoJsonPath, stdout)
return {info, infoJsonPath}
}
export type DownloadChoice = {
label: string
kind: 'video' | 'audio'
args: string[]
}
const MAX_VIDEO_CHOICES = 8
export function buildChoices(info: VideoInfo): DownloadChoice[] {
const formats = info.formats ?? []
const choices: DownloadChoice[] = []
const audioOnly = formats.filter(f => f.acodec && f.acodec !== 'none' && (!f.vcodec || f.vcodec === 'none'))
const bestAudio = [...audioOnly].sort((a, b) => (b.abr ?? b.tbr ?? 0) - (a.abr ?? a.tbr ?? 0))[0]
const audioSize = bestAudio?.filesize ?? bestAudio?.filesize_approx
const videos = formats.filter(f => f.vcodec && f.vcodec !== 'none' && f.height)
const heights = [...new Set(videos.map(f => f.height as number))].sort((a, b) => b - a)
for (const height of heights.slice(0, MAX_VIDEO_CHOICES)) {
const candidates = videos.filter(f => f.height === height)
const best = [...candidates].sort((a, b) => scoreVideo(b) - scoreVideo(a))[0]
const muxed = best.acodec && best.acodec !== 'none'
const size = (best.filesize ?? best.filesize_approx ?? 0) + (muxed ? 0 : audioSize ?? 0)
const sizeLabel = size > 0 ? ` · ~${formatBytes(size)}` : ''
choices.push({
kind: 'video',
label: `${height}p · mp4${sizeLabel}`,
args: [
'-f',
`bv*[height=${height}]+ba/b[height=${height}]/bv*[height<=${height}]+ba/b`,
'--merge-output-format',
'mp4',
],
})
}
if (choices.length === 0) {
choices.push({
kind: 'video',
label: 'best available · mp4',
args: ['-f', 'bv*+ba/b', '--merge-output-format', 'mp4'],
})
}
const audioSizeLabel = audioSize ? ` · ~${formatBytes(audioSize)}` : ''
choices.push({
kind: 'audio',
label: `audio only · mp3${audioSizeLabel}`,
args: ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'],
})
return choices
}
function scoreVideo(f: RawFormat): number {
let score = f.tbr ?? 0
if (f.ext === 'mp4') score += 10_000
if (f.vcodec?.startsWith('avc')) score += 5_000
return score
}
export type DownloadProgress = {
downloadedBytes: number
totalBytes?: number
speed?: number
eta?: number
part: number
/** How many files this download resolves to (video+audio merges are 2). */
totalParts: number
}
export type DownloadHandlers = {
onProgress: (progress: DownloadProgress) => void
onProcessing: () => void
}
const PROGRESS_PREFIX = 'YOINK|'
const PROGRESS_TEMPLATE = `${PROGRESS_PREFIX}%(progress.downloaded_bytes)s|%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|%(progress.speed)s|%(progress.eta)s`
let activeChild: ChildProcess | undefined
process.on('exit', () => activeChild?.kill('SIGTERM'))
export function download(
opts: {
ytdlp: string
ffmpegLocation?: string
url: string
/** When set, reuse the probe's metadata instead of re-extracting — starts much faster. */
infoJsonPath?: string
choice: DownloadChoice
outDir: string
},
handlers: DownloadHandlers,
signal?: AbortSignal,
): Promise<string> {
const args = [
...(opts.infoJsonPath ? ['--load-info-json', opts.infoJsonPath] : [opts.url]),
...opts.choice.args,
'--no-playlist',
'--no-warnings',
'--newline',
// --print implies --quiet, which suppresses progress bars and the
// [Merger]/[ExtractAudio] lines we detect the processing phase from
'--no-quiet',
'--progress',
'--progress-template',
`download:${PROGRESS_TEMPLATE}`,
'--print',
'after_move:filepath',
'--no-simulate',
'-o',
path.join(opts.outDir, '%(title).60s.%(ext)s'),
]
if (opts.ffmpegLocation) args.push('--ffmpeg-location', opts.ffmpegLocation)
return new Promise((resolve, reject) => {
const child = spawn(opts.ytdlp, args, {signal})
activeChild = child
let stderr = ''
let filepath = ''
let part = 0
let totalParts = 1
let lastDownloaded = 0
let buffer = ''
// every file yt-dlp writes this run, so a cancel can clean up after itself
const destinations: string[] = []
child.stdout.on('data', (chunk: Buffer) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
if (line.startsWith(PROGRESS_PREFIX)) {
const [downloaded, total, totalEstimate, speed, eta] = line.slice(PROGRESS_PREFIX.length).split('|')
const downloadedBytes = toNumber(downloaded) ?? 0
if (downloadedBytes < lastDownloaded) part++
lastDownloaded = downloadedBytes
handlers.onProgress({
downloadedBytes,
totalBytes: toNumber(total) ?? toNumber(totalEstimate),
speed: toNumber(speed),
eta: toNumber(eta),
part,
totalParts,
})
} else if (line.includes('Downloading 1 format(s):')) {
// "[info] xxx: Downloading 1 format(s): 395+251" — each id is one file
totalParts = (line.split('format(s):')[1] ?? '').trim().split('+').length
} else if (line.includes('[Merger]') || line.includes('[ExtractAudio]')) {
const merging = /^\[Merger\] Merging formats into "(.+)"$/.exec(line)?.[1]
const extracting = /^\[ExtractAudio\] Destination: (.+)$/.exec(line)?.[1]
const target = merging ?? extracting
if (target) destinations.push(target)
handlers.onProcessing()
} else if (line.startsWith('[download] Destination: ')) {
destinations.push(line.slice('[download] Destination: '.length))
} else if (path.isAbsolute(line)) {
filepath = line
}
}
})
child.stderr.on('data', chunk => (stderr += chunk))
child.on('error', reject)
child.on('close', code => {
activeChild = undefined
if (signal?.aborted) {
// cancelled on purpose — don't leave half-written files behind
void removePartials(destinations)
reject(new Error('Download cancelled.'))
return
}
if (code === 0 && filepath) {
resolve(filepath)
} else {
reject(new Error(cleanYtDlpError(stderr) || `Download failed (yt-dlp exit code ${code}).`))
}
})
})
}
function removePartials(destinations: string[]): Promise<unknown> {
return Promise.allSettled(
destinations
.flatMap(dest => [dest, `${dest}.part`, `${dest}.ytdl`])
.map(file => fs.rm(file, {force: true})),
)
}
function toNumber(value: string | undefined): number | undefined {
if (!value || value === 'NA' || value === 'None') return undefined
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : undefined
}
function cleanYtDlpError(stderr: string): string {
const lines = stderr
.split('\n')
.map(l => l.trim())
.filter(l => l.startsWith('ERROR:'))
const last = lines.at(-1)
return last ? last.replace(/^ERROR:\s*(\[[^\]]+\]\s*)?/, '') : ''
}