yt
small http api over yt-dlp. metadata, download jobs with live progress over sse, any of the ~1800 sites yt-dlp supports.
quick start
# one shot
curl -OJ '/v1/download?url=https://youtu.be/jNQXAC9IVRw&quality=1080'
# job + live progress
curl -X POST /v1/jobs -H 'content-type: application/json' \
-d '{"url":"https://youtu.be/jNQXAC9IVRw","codec":"h264","container":"mp4"}'
curl -N /v1/jobs/{id}/events
curl -OJ /v1/jobs/{id}/file
routes
GET/v1/info?url=metadata, formats, playlist entries
| url | string | page url, required |
| playlist | bool | resolve the playlist when the url has a video and a list |
| raw | bool | untouched yt-dlp -J output |
GET/v1/extractorssupported sites
{"count": 1800, "extractors": ["youtube", ...]}
POST/v1/jobsstart a download โ 202 job
json body (or form params). everything but url is optional.
| url | string | page or direct media url |
| quality | string | best ยท audio ยท max height like 1080 |
| codec | string | any ยท h264 ยท hevc ยท vp9 ยท av1, preference with fallback |
| container | string | mp4 ยท webm ยท mkv ยท mov |
| audio_only | bool | extract audio |
| audio_format | string | best ยท mp3 ยท m4a ยท aac ยท opus ยท vorbis ยท flac ยท wav ยท alac |
| format | string | raw yt-dlp -f, overrides quality/codec |
| sort | string | raw yt-dlp -S |
| start, end | string | trim, seconds or [hh:]mm:ss |
| subtitles | string[] | languages to embed, e.g. ["en","pt.*"] |
| embed_* | bool | embed_metadata ยท embed_thumbnail ยท embed_chapters |
| sponsorblock_remove | string[] | sponsor, selfpromo, intro, ... |
| playlist_item | int | 1-based item when the url is a playlist |
| args | string[] | extra raw yt-dlp args; filesystem/exec/output flags are rejected |
GET/v1/jobs/{id}/eventsprogress over sse (preferred)
event: job with the full job on every change, event: end when finished, : ping every 15s.
const es = new EventSource(`/v1/jobs/${id}/events`)
es.addEventListener('job', (e) => {
const job = JSON.parse(e.data)
// job.status: queued | running | done | failed | canceled
// job.stage: resolving | downloading | processing
// job.progress: { percent, downloaded_bytes, total_bytes, speed, eta, part, parts }
})
es.addEventListener('end', () => es.close())
GET/v1/jobs/{id}poll the job
{
"id": "8bf05386f9952365df",
"status": "running",
"stage": "downloading",
"media": { "title": "Me at the zoo", "extractor": "Youtube", "duration": 19, ... },
"progress": { "percent": 55.6, "downloaded_bytes": 74865810, "total_bytes": 134589086, "speed": 40995599, "eta": 1, "part": 1, "parts": 2 },
"file": { "name": "Me at the zoo [jNQXAC9IVRw].mp4", "size": 744412, "content_type": "video/mp4" },
"error": { "code": "ytdlp_error", "message": "..." },
"links": { "self": "...", "events": "...", "file": "..." }
}
GET/v1/jobs/{id}/filethe result, range requests ok
409 while not done, ?inline=1 for inline disposition. files expire after an hour.
DELETE/v1/jobs/{id}cancel or delete
204. kills yt-dlp if running and removes the file.
GET/v1/download?url=sync: wait and stream the file
same options as query params. lists comma-separated, raw args as repeated arg. closing the connection cancels the job.
GET/healthzstatus + yt-dlp version
{"ok": true, "yt_dlp": "2026.08.19"}
errors are always {"error": {"code", "message"}}.