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
urlstringpage url, required
playlistboolresolve the playlist when the url has a video and a list
rawbooluntouched 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.

urlstringpage or direct media url
qualitystringbest ยท audio ยท max height like 1080
codecstringany ยท h264 ยท hevc ยท vp9 ยท av1, preference with fallback
containerstringmp4 ยท webm ยท mkv ยท mov
audio_onlyboolextract audio
audio_formatstringbest ยท mp3 ยท m4a ยท aac ยท opus ยท vorbis ยท flac ยท wav ยท alac
formatstringraw yt-dlp -f, overrides quality/codec
sortstringraw yt-dlp -S
start, endstringtrim, seconds or [hh:]mm:ss
subtitlesstring[]languages to embed, e.g. ["en","pt.*"]
embed_*boolembed_metadata ยท embed_thumbnail ยท embed_chapters
sponsorblock_removestring[]sponsor, selfpromo, intro, ...
playlist_itemint1-based item when the url is a playlist
argsstring[]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"}}.

try it