feat: gate download options on real-time yt-dlp video analysis
Options (quality, subtitles) are now derived from a yt-dlp -J probe of the submitted URL instead of a static list, so users can't pick combinations the source video doesn't actually support. Submission is blocked until the probe succeeds. - New /api/probe endpoint + src/lib/ytdlp-probe.ts, with its own rate limiter (rate-limit.ts refactored into a createRateLimiter factory). - New options: subtitle language selection, clip start/end trim, MP3 audio quality. - Fixed buildYtdlpArgs: format=mp3 never triggered audio extraction (-x/--audio-format), and quality=best never applied --merge-output-format, so the chosen container had no real effect. - Cross-platform yt-dlp invocation: the bundled bin/yt-dlp is a Python zipapp relying on a shebang, which Windows' spawn() can't run directly. buildYtdlpCommand() runs it through `python` on Windows and execs it directly on Linux/o2switch, where the shebang works natively. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,53 @@
|
||||
import { isRateLimited } from '../rate-limit'
|
||||
import { createRateLimiter } from '../rate-limit'
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
describe('isRateLimited', () => {
|
||||
describe('createRateLimiter', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
const limiter = createRateLimiter(5, 3_600_000)
|
||||
const ip = '1.2.3.4'
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(isRateLimited(ip)).toBe(false)
|
||||
expect(limiter.isLimited(ip)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks the 6th request within the window', () => {
|
||||
const limiter = createRateLimiter(5, 3_600_000)
|
||||
const ip = '10.0.0.1'
|
||||
for (let i = 0; i < 5; i++) isRateLimited(ip)
|
||||
expect(isRateLimited(ip)).toBe(true)
|
||||
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
|
||||
expect(limiter.isLimited(ip)).toBe(true)
|
||||
})
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
const limiter = createRateLimiter(5, 3_600_000)
|
||||
const ip = '10.0.0.2'
|
||||
for (let i = 0; i < 5; i++) isRateLimited(ip)
|
||||
expect(isRateLimited(ip)).toBe(true)
|
||||
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
|
||||
expect(limiter.isLimited(ip)).toBe(true)
|
||||
jest.advanceTimersByTime(3_600_001)
|
||||
expect(isRateLimited(ip)).toBe(false)
|
||||
expect(limiter.isLimited(ip)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks different IPs independently', () => {
|
||||
const limiter = createRateLimiter(5, 3_600_000)
|
||||
const ipA = '192.168.1.1'
|
||||
const ipB = '192.168.1.2'
|
||||
for (let i = 0; i < 5; i++) isRateLimited(ipA)
|
||||
expect(isRateLimited(ipA)).toBe(true)
|
||||
expect(isRateLimited(ipB)).toBe(false)
|
||||
for (let i = 0; i < 5; i++) limiter.isLimited(ipA)
|
||||
expect(limiter.isLimited(ipA)).toBe(true)
|
||||
expect(limiter.isLimited(ipB)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks separate limiter instances independently', () => {
|
||||
const limiterA = createRateLimiter(1, 3_600_000)
|
||||
const limiterB = createRateLimiter(1, 3_600_000)
|
||||
const ip = '5.5.5.5'
|
||||
expect(limiterA.isLimited(ip)).toBe(false)
|
||||
expect(limiterA.isLimited(ip)).toBe(true)
|
||||
expect(limiterB.isLimited(ip)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const mockExistsSync = jest.fn()
|
||||
jest.mock('fs', () => ({ existsSync: (...args: unknown[]) => mockExistsSync(...args) }))
|
||||
|
||||
import { buildYtdlpCommand } from '../ytdlp'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
}
|
||||
|
||||
describe('buildYtdlpCommand', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
mockExistsSync.mockReset()
|
||||
})
|
||||
|
||||
it('runs the bundled binary directly on Linux', () => {
|
||||
setPlatform('linux')
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||
expect(command).toContain('yt-dlp')
|
||||
expect(args).toEqual(['-J', 'https://x.test'])
|
||||
})
|
||||
|
||||
it('runs the bundled binary through python on Windows', () => {
|
||||
setPlatform('win32')
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||
expect(command).toBe('python')
|
||||
expect(args[0]).toContain('yt-dlp')
|
||||
expect(args.slice(1)).toEqual(['-J', 'https://x.test'])
|
||||
})
|
||||
|
||||
it('runs a system-wide yt-dlp directly on Windows when no bundled binary exists', () => {
|
||||
setPlatform('win32')
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||
expect(command).toBe('yt-dlp')
|
||||
expect(args).toEqual(['-J', 'https://x.test'])
|
||||
})
|
||||
|
||||
it('runs a system-wide yt-dlp directly on Linux when no bundled binary exists', () => {
|
||||
setPlatform('linux')
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||
expect(command).toBe('yt-dlp')
|
||||
expect(args).toEqual(['-J', 'https://x.test'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { parseProbeOutput, buildProbeArgs, ProbeError } from '../ytdlp-probe'
|
||||
|
||||
function json(obj: unknown): string {
|
||||
return JSON.stringify(obj)
|
||||
}
|
||||
|
||||
describe('buildProbeArgs', () => {
|
||||
it('dumps JSON without downloading and disables playlists', () => {
|
||||
const args = buildProbeArgs('https://youtube.com/watch?v=abc')
|
||||
expect(args).toContain('-J')
|
||||
expect(args).toContain('--skip-download')
|
||||
expect(args).toContain('--no-playlist')
|
||||
expect(args[args.length - 1]).toBe('https://youtube.com/watch?v=abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseProbeOutput', () => {
|
||||
it('throws a ProbeError on invalid JSON', () => {
|
||||
expect(() => parseProbeOutput('not json')).toThrow(ProbeError)
|
||||
})
|
||||
|
||||
it('detects a video with multiple qualities and subtitles', () => {
|
||||
const result = parseProbeOutput(json({
|
||||
title: 'Some video',
|
||||
duration: 125,
|
||||
formats: [
|
||||
{ height: 360, vcodec: 'avc1' },
|
||||
{ height: 720, vcodec: 'avc1' },
|
||||
{ height: 1080, vcodec: 'avc1' },
|
||||
{ vcodec: 'none', acodec: 'opus' },
|
||||
],
|
||||
subtitles: { en: [{}] },
|
||||
automatic_captions: { fr: [{}], es: [{}] },
|
||||
}))
|
||||
|
||||
expect(result.isAudioOnly).toBe(false)
|
||||
expect(result.durationSeconds).toBe(125)
|
||||
expect(result.availableQualities).toEqual(['best', '1080p', '720p', '480p', '360p'])
|
||||
expect(result.subtitleLangs).toEqual(['fr', 'en', 'es'])
|
||||
})
|
||||
|
||||
it('caps available qualities to the max height found', () => {
|
||||
const result = parseProbeOutput(json({
|
||||
formats: [
|
||||
{ height: 360, vcodec: 'avc1' },
|
||||
{ height: 480, vcodec: 'avc1' },
|
||||
],
|
||||
}))
|
||||
expect(result.availableQualities).toEqual(['best', '480p', '360p'])
|
||||
})
|
||||
|
||||
it('detects an audio-only source and hides quality options', () => {
|
||||
const result = parseProbeOutput(json({
|
||||
title: 'Some track',
|
||||
duration: 200,
|
||||
formats: [
|
||||
{ vcodec: 'none', acodec: 'mp3' },
|
||||
{ vcodec: 'none', acodec: 'opus' },
|
||||
],
|
||||
}))
|
||||
expect(result.isAudioOnly).toBe(true)
|
||||
expect(result.availableQualities).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty subtitle list when none are available', () => {
|
||||
const result = parseProbeOutput(json({
|
||||
formats: [{ height: 720, vcodec: 'avc1' }],
|
||||
}))
|
||||
expect(result.subtitleLangs).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults duration to null when missing', () => {
|
||||
const result = parseProbeOutput(json({ formats: [] }))
|
||||
expect(result.durationSeconds).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,10 @@ const base = {
|
||||
format: 'mp4',
|
||||
quality: 'best',
|
||||
subtitles: false,
|
||||
subtitleLangs: null,
|
||||
clipStart: null,
|
||||
clipEnd: null,
|
||||
audioQuality: null,
|
||||
extraArgs: null,
|
||||
}
|
||||
|
||||
@@ -22,23 +26,89 @@ describe('buildYtdlpArgs', () => {
|
||||
expect(args[idx + 1]).toContain('%(ext)s')
|
||||
})
|
||||
|
||||
it('adds format filter when quality is not "best"', () => {
|
||||
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
|
||||
expect(args).toContain('-f')
|
||||
it('always sets --merge-output-format to the chosen video format', () => {
|
||||
const args = buildYtdlpArgs(base)
|
||||
const idx = args.indexOf('--merge-output-format')
|
||||
expect(idx).toBeGreaterThan(-1)
|
||||
expect(args[idx + 1]).toBe('mp4')
|
||||
})
|
||||
|
||||
it('does not add -f flag when quality is "best"', () => {
|
||||
expect(buildYtdlpArgs(base)).not.toContain('-f')
|
||||
it('adds a height filter to -f when quality is not "best"', () => {
|
||||
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
|
||||
const idx = args.indexOf('-f')
|
||||
expect(idx).toBeGreaterThan(-1)
|
||||
expect(args[idx + 1]).toContain('height<=?1080')
|
||||
})
|
||||
|
||||
it('does not add a height filter when quality is "best"', () => {
|
||||
const args = buildYtdlpArgs(base)
|
||||
const idx = args.indexOf('-f')
|
||||
expect(args[idx + 1]).not.toContain('height<=?')
|
||||
})
|
||||
|
||||
it('extracts audio with -x and --audio-format when format is mp3', () => {
|
||||
const args = buildYtdlpArgs({ ...base, format: 'mp3' })
|
||||
expect(args).toContain('-x')
|
||||
const idx = args.indexOf('--audio-format')
|
||||
expect(args[idx + 1]).toBe('mp3')
|
||||
expect(args).not.toContain('--merge-output-format')
|
||||
})
|
||||
|
||||
it('sets --audio-quality to 0 for best mp3 quality', () => {
|
||||
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: 'best' })
|
||||
const idx = args.indexOf('--audio-quality')
|
||||
expect(args[idx + 1]).toBe('0')
|
||||
})
|
||||
|
||||
it('sets --audio-quality to a bitrate when a specific mp3 quality is given', () => {
|
||||
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: '192' })
|
||||
const idx = args.indexOf('--audio-quality')
|
||||
expect(args[idx + 1]).toBe('192K')
|
||||
})
|
||||
|
||||
it('adds subtitle flags when subtitles is true', () => {
|
||||
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
||||
expect(args).toContain('--write-sub')
|
||||
expect(args).toContain('--write-subs')
|
||||
expect(args).toContain('--write-auto-subs')
|
||||
expect(args).toContain('--sub-lang')
|
||||
})
|
||||
|
||||
it('does not add subtitle flags when subtitles is false', () => {
|
||||
expect(buildYtdlpArgs(base)).not.toContain('--write-sub')
|
||||
expect(buildYtdlpArgs(base)).not.toContain('--write-subs')
|
||||
})
|
||||
|
||||
it('defaults subtitle languages to fr,en when none are given', () => {
|
||||
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
||||
const idx = args.indexOf('--sub-lang')
|
||||
expect(args[idx + 1]).toBe('fr,en')
|
||||
})
|
||||
|
||||
it('uses the given subtitle languages when provided', () => {
|
||||
const args = buildYtdlpArgs({ ...base, subtitles: true, subtitleLangs: ['es', 'it'] })
|
||||
const idx = args.indexOf('--sub-lang')
|
||||
expect(args[idx + 1]).toBe('es,it')
|
||||
})
|
||||
|
||||
it('adds --download-sections when a clip range is given', () => {
|
||||
const args = buildYtdlpArgs({ ...base, clipStart: 10, clipEnd: 30 })
|
||||
const idx = args.indexOf('--download-sections')
|
||||
expect(args[idx + 1]).toBe('*10-30')
|
||||
})
|
||||
|
||||
it('defaults clip start to 0 when only clipEnd is given', () => {
|
||||
const args = buildYtdlpArgs({ ...base, clipEnd: 30 })
|
||||
const idx = args.indexOf('--download-sections')
|
||||
expect(args[idx + 1]).toBe('*0-30')
|
||||
})
|
||||
|
||||
it('leaves the clip end open when only clipStart is given', () => {
|
||||
const args = buildYtdlpArgs({ ...base, clipStart: 10 })
|
||||
const idx = args.indexOf('--download-sections')
|
||||
expect(args[idx + 1]).toBe('*10-')
|
||||
})
|
||||
|
||||
it('does not add --download-sections when no clip range is given', () => {
|
||||
expect(buildYtdlpArgs(base)).not.toContain('--download-sections')
|
||||
})
|
||||
|
||||
it('appends extra args from a JSON array string', () => {
|
||||
|
||||
+27
-12
@@ -1,19 +1,34 @@
|
||||
import { config } from '../../config/app.config'
|
||||
|
||||
type Entry = { count: number; resetAt: number }
|
||||
const store = new Map<string, Entry>()
|
||||
|
||||
export function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const entry = store.get(ip)
|
||||
export function createRateLimiter(max: number, windowMs: number) {
|
||||
const store = new Map<string, Entry>()
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS })
|
||||
return false
|
||||
return {
|
||||
isLimited(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const entry = store.get(ip)
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
store.set(ip, { count: 1, resetAt: now + windowMs })
|
||||
return false
|
||||
}
|
||||
|
||||
if (entry.count >= max) return true
|
||||
|
||||
entry.count++
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
if (entry.count >= config.RATE_LIMIT_MAX) return true
|
||||
|
||||
entry.count++
|
||||
return false
|
||||
}
|
||||
|
||||
export const downloadRateLimiter = createRateLimiter(
|
||||
config.RATE_LIMIT_MAX,
|
||||
config.RATE_LIMIT_WINDOW_MS
|
||||
)
|
||||
|
||||
export const probeRateLimiter = createRateLimiter(
|
||||
config.PROBE_RATE_LIMIT_MAX,
|
||||
config.PROBE_RATE_LIMIT_WINDOW_MS
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
export function getClientIp(req: NextRequest): string {
|
||||
return req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const FORMATS = ['mp4', 'mp3', 'webm', 'mkv'] as const
|
||||
export const QUALITIES = ['best', '1080p', '720p', '480p', '360p'] as const
|
||||
export const AUDIO_QUALITIES = ['best', '192', '128'] as const
|
||||
@@ -0,0 +1,125 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { config } from '../../config/app.config'
|
||||
import { buildYtdlpCommand } from './ytdlp'
|
||||
import { QUALITIES } from './ytdlp-options'
|
||||
|
||||
export type ProbeCapabilities = {
|
||||
title: string
|
||||
durationSeconds: number | null
|
||||
isAudioOnly: boolean
|
||||
availableQualities: string[]
|
||||
subtitleLangs: string[]
|
||||
}
|
||||
|
||||
type YtdlpFormat = {
|
||||
height?: number | null
|
||||
vcodec?: string | null
|
||||
}
|
||||
|
||||
type YtdlpProbeJson = {
|
||||
title?: string
|
||||
duration?: number | null
|
||||
formats?: YtdlpFormat[]
|
||||
subtitles?: Record<string, unknown>
|
||||
automatic_captions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class ProbeError extends Error {}
|
||||
|
||||
export function buildProbeArgs(url: string): string[] {
|
||||
return ['--no-warnings', '--skip-download', '--no-playlist', '-J', url]
|
||||
}
|
||||
|
||||
const LANG_PRIORITY = ['fr', 'en']
|
||||
|
||||
export function parseProbeOutput(stdout: string): ProbeCapabilities {
|
||||
let json: YtdlpProbeJson
|
||||
try {
|
||||
json = JSON.parse(stdout)
|
||||
} catch {
|
||||
throw new ProbeError('yt-dlp returned invalid JSON')
|
||||
}
|
||||
|
||||
const formats = json.formats ?? []
|
||||
const videoFormats = formats.filter((f) => f.vcodec && f.vcodec !== 'none')
|
||||
const isAudioOnly = formats.length > 0 && videoFormats.length === 0
|
||||
|
||||
const heights = videoFormats
|
||||
.map((f) => f.height)
|
||||
.filter((h): h is number => typeof h === 'number')
|
||||
const maxHeight = heights.length ? Math.max(...heights) : null
|
||||
|
||||
const availableQualities = isAudioOnly
|
||||
? []
|
||||
: QUALITIES.filter(
|
||||
(q) => q === 'best' || (maxHeight != null && Number(q.replace('p', '')) <= maxHeight)
|
||||
)
|
||||
|
||||
const langs = new Set<string>([
|
||||
...Object.keys(json.subtitles ?? {}),
|
||||
...Object.keys(json.automatic_captions ?? {}),
|
||||
])
|
||||
const subtitleLangs = [
|
||||
...LANG_PRIORITY.filter((l) => langs.has(l)),
|
||||
...[...langs].filter((l) => !LANG_PRIORITY.includes(l)).sort(),
|
||||
]
|
||||
|
||||
return {
|
||||
title: json.title ?? '',
|
||||
durationSeconds: typeof json.duration === 'number' ? json.duration : null,
|
||||
isAudioOnly,
|
||||
availableQualities,
|
||||
subtitleLangs,
|
||||
}
|
||||
}
|
||||
|
||||
export function runYtdlpProbe(
|
||||
url: string,
|
||||
timeoutMs: number = config.PROBE_TIMEOUT_MS
|
||||
): Promise<ProbeCapabilities> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { command, args } = buildYtdlpCommand(buildProbeArgs(url))
|
||||
const proc = spawn(command, args)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
proc.kill('SIGKILL')
|
||||
reject(new ProbeError('yt-dlp probe timed out'))
|
||||
}, timeoutMs)
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(new ProbeError(err.message))
|
||||
})
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new ProbeError(stderr || `yt-dlp exited with code ${code}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseProbeOutput(stdout))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
+52
-5
@@ -8,11 +8,49 @@ export type YtdlpParams = {
|
||||
format: string
|
||||
quality: string
|
||||
subtitles: boolean
|
||||
subtitleLangs: string[] | null
|
||||
clipStart: number | null
|
||||
clipEnd: number | null
|
||||
audioQuality: string | null
|
||||
extraArgs: string | null
|
||||
}
|
||||
|
||||
export function resolveYtdlpBin(): string {
|
||||
const local = path.join(config.BIN_DIR, 'yt-dlp')
|
||||
return existsSync(local) ? local : 'yt-dlp'
|
||||
}
|
||||
|
||||
export type YtdlpCommand = { command: string; args: string[] }
|
||||
|
||||
// The bundled bin/yt-dlp is a Python zipapp relying on a `#!/usr/bin/env python3`
|
||||
// shebang: Linux (o2switch prod) execs it natively, but Windows' spawn() has no
|
||||
// shebang support and fails with ENOENT, so it must be run through `python` there.
|
||||
// A system-wide `yt-dlp` (PATH fallback) already has a proper platform launcher
|
||||
// on both OSes and is always run directly.
|
||||
export function buildYtdlpCommand(args: string[]): YtdlpCommand {
|
||||
const bin = resolveYtdlpBin()
|
||||
const isBundledScript = bin !== 'yt-dlp'
|
||||
|
||||
if (process.platform === 'win32' && isBundledScript) {
|
||||
return { command: 'python', args: [bin, ...args] }
|
||||
}
|
||||
|
||||
return { command: bin, args }
|
||||
}
|
||||
|
||||
export function buildYtdlpArgs(params: YtdlpParams): string[] {
|
||||
const { url, uuid, format, quality, subtitles, extraArgs } = params
|
||||
const {
|
||||
url,
|
||||
uuid,
|
||||
format,
|
||||
quality,
|
||||
subtitles,
|
||||
subtitleLangs,
|
||||
clipStart,
|
||||
clipEnd,
|
||||
audioQuality,
|
||||
extraArgs,
|
||||
} = params
|
||||
const outputTemplate = path.join(config.STORAGE_PATH, `${uuid}.%(ext)s`)
|
||||
|
||||
const args: string[] = ['--no-playlist', '-o', outputTemplate]
|
||||
@@ -22,13 +60,22 @@ export function buildYtdlpArgs(params: YtdlpParams): string[] {
|
||||
args.push('--ffmpeg-location', ffmpegBin)
|
||||
}
|
||||
|
||||
if (quality !== 'best') {
|
||||
const height = quality.replace('p', '')
|
||||
args.push('-f', `${format}[height<=?${height}]+bestaudio/best[height<=?${height}]`)
|
||||
if (format === 'mp3') {
|
||||
args.push('-x', '--audio-format', 'mp3')
|
||||
args.push('--audio-quality', audioQuality && audioQuality !== 'best' ? `${audioQuality}K` : '0')
|
||||
} else {
|
||||
const heightFilter = quality !== 'best' ? `[height<=?${quality.replace('p', '')}]` : ''
|
||||
args.push('-f', `bestvideo${heightFilter}+bestaudio/best${heightFilter}`)
|
||||
args.push('--merge-output-format', format)
|
||||
}
|
||||
|
||||
if (subtitles) {
|
||||
args.push('--write-sub', '--sub-lang', 'fr,en')
|
||||
const langs = subtitleLangs?.length ? subtitleLangs.join(',') : 'fr,en'
|
||||
args.push('--write-subs', '--write-auto-subs', '--sub-lang', langs)
|
||||
}
|
||||
|
||||
if (clipStart != null || clipEnd != null) {
|
||||
args.push('--download-sections', `*${clipStart ?? 0}-${clipEnd ?? ''}`)
|
||||
}
|
||||
|
||||
if (extraArgs) {
|
||||
|
||||
Reference in New Issue
Block a user