Calling yt-dlp from PHP is a few lines of proc_open(). Keeping it useful on a private host is an operations problem: JavaScript challenges need a runtime in the image, authenticated sites need carefully handled cookies, output needs a filesystem boundary, and someone must own the terms-of-service risk of each source.
This post describes that operational shape in Tools Center, with public source for reference. It is not a guide to bypassing DRM or site restrictions. The operator decides which sites and content they are entitled to fetch.
It also explains why the post-response job pattern exists: a media download and ffmpeg merge are exactly the kind of work that should outlive a browser request.
The container needs more than yt-dlp
Modern extractor behavior can require a JavaScript runtime. For YouTube EJS challenges, yt-dlp documents Deno as its default runtime. The image installs Deno alongside ffmpeg and yt-dlp[default]:
| |
Without a supported JavaScript runtime, an extractor failure can look like a generic yt-dlp regression. Keeping the runtime explicit and versioned in the Dockerfile makes the dependency visible.
ffmpeg belongs in the same image because format selection can produce separate video and audio streams, and audio-only output may need extraction or conversion. A health check that only runs yt-dlp --version therefore proves less than it appears to.
Build an argument array, not a shell command
The runner constructs an argument list and passes it to proc_open():
| |
An array avoids shell interpolation for the URL and filenames. --no-playlist prevents one pasted item from unexpectedly becoming hundreds. The bounded title and --restrict-filenames keep stored names predictable.
Site-wide extra arguments are parsed through a controlled configuration path. Invalid settings fall back to no extras rather than breaking every download.
Treat stdout as untrusted
The runner asks yt-dlp to print the final path after moves and merges. It does not assume that path is safe:
| |
The process starts in the temp directory, but working directory is not confinement. The realpath() check ensures the final file actually sits below the expected root before the application imports it. The stored basename is sanitized once more before it becomes application data.
The complete implementation is in YtDlpRunner.php.
Keep cookies in SQLite, materialize for the process
yt-dlp expects a Netscape-format cookie file. Keeping that file only in a mutable container complicates rebuilds and backups. Tools Center stores the text in SQLite and materializes it at the configured path when yt-dlp or the CLI needs it.
The admin path normalizes line endings, rejects empty or oversized input, and checks that the content resembles Netscape cookies before saving:
| |
These cookies are site-wide, not per-user. That is a deliberate fit for a private operator-managed host. It also concentrates risk: the admin owns rotation, storage permissions, backups, and removal when a session should no longer exist.
See YtDlpCookieStore.php.
Curated sources and a generic escape hatch
The application has first-class source classes for common services. Those classes choose sensible format controls and identify audio-only sources. A separate “force generic yt-dlp” option handles a URL that the curated registry does not claim.
A generated ytdlp-known-hosts.json list supplies a UI hint when the hostname looks like one yt-dlp supports. It does not silently route every matching URL through the generic runner. That distinction matters:
- Curated sources are an application promise with tailored options.
- The known-host list is guidance.
- Forced generic mode is an explicit user decision.
This keeps the common paths understandable without pretending every extractor has the same behavior.
Why this belongs behind a job row
A run may download multiple streams, invoke ffmpeg, and then upload the result elsewhere. The browser should receive its redirect before any of that begins. The series anchor covers how the app finishes its PHP response and continues processing the durable job.
When the destination is Dropbox or Google Drive, the processor can then pass the result into per-user cloud OAuth rather than sharing one host-wide refresh token.
The operator checklist
- Install and pin a supported JavaScript runtime, ffmpeg, and
yt-dlp[default]. - Build process arguments as an array; do not concatenate an untrusted URL into a shell command.
- Constrain the output template, resolve the final path, and verify it remains below the temp root.
- Store cookies as sensitive admin state and materialize the file only where the process expects it.
- Distinguish curated integrations from a generic, explicitly selected fallback.
- Keep long work behind durable job state and outside the client-facing response.
The CLI invocation was the easy part. The useful system is everything around it.