# Plan Status: **ready_for_implement** Goal: implement Priority 3 of `ROADMAP.md` — fix downloads failing with a false `This video is not available` error caused by yt-dlp having no JavaScript runtime available in the container. ## Scope (Priority 3) - **T-005 — Install Deno as yt-dlp's JS runtime**: - Add Deno (a single static binary, yt-dlp's documented lightweight JS runtime) to the `Dockerfile` so it's on `PATH` at container run time. - Point yt-dlp at it explicitly via `ydl_opts` (rather than relying on autodetection) so behavior doesn't silently regress if `deno` ever isn't found on `PATH`. - Allow yt-dlp to retrieve its official GitHub-hosted EJS challenge-solver scripts when current YouTube extraction requires them. - Verify server logs stop showing `WARNING: ... No supported JavaScript runtime could be found` during a normal download, and that a video URL exhibiting the `This video is not available` failure now succeeds. - Document the new build-time/runtime dependency and its purpose in `README.md`. ## Acceptance Criteria (from `ROADMAP.md` Priority 3) - `docker build` still completes without errors after adding Deno. - Re-running a download of a video URL that previously failed with `This video is not available` (due to the missing JS runtime) succeeds and delivers the file. - Server logs no longer show `No supported JavaScript runtime could be found` during a normal MP3 or Video download. - Existing Priority 1/2 behavior (single-video-from-playlist, progress bar, filename sanitization) is unaffected. ## Implementation Phases ### Phase 1 — T-005: Add Deno and wire it into yt-dlp **Files to change:** - `Dockerfile` - `app.py` - `README.md` (document the Deno dependency and why it's needed) **Changes in `Dockerfile`:** 1. Install Deno in the build stage. Simplest reliable path for a `python:3.12-slim` (Debian) base without adding curl/unzip as extra layers if avoidable — use the official install script, which only needs `curl` and `unzip`: ```dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg curl unzip \ && curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh \ && rm -rf /var/lib/apt/lists/* ``` This places the `deno` binary at `/usr/local/bin/deno`, already on `PATH` for subsequent `RUN`/`CMD` layers and for the app at runtime. 2. Keep the rest of the Dockerfile (`WORKDIR`, `COPY requirements.txt`, `pip install`, `COPY app.py`, `EXPOSE`, `CMD`) unchanged. **Changes in `app.py`:** 1. In the shared `ydl_opts` construction (around line 317, alongside `outtmpl`/`noplaylist`/`progress_hooks`), explicitly point yt-dlp at the installed runtime instead of relying purely on `PATH` autodetection, so a missing/misconfigured binary fails loudly rather than silently degrading: ```python ydl_opts: dict[str, object] = { "outtmpl": output_template, "noplaylist": True, "progress_hooks": [progress_hook], "js_runtimes": {"deno": {"path": shutil.which("deno") or "deno"}}, "remote_components": ["ejs:github"], } ``` (Exact option name/shape to be confirmed against the installed yt-dlp version's `--js-runtimes` support at implementation time — yt-dlp exposes this as a CLI flag `--js-runtimes RUNTIME[:PATH]`; the implementer should check `yt_dlp.YoutubeDL` / `yt_dlp.options` for the corresponding `ydl_opts` key in the pinned `yt-dlp` version and use that key, falling back to relying on autodetection via `PATH` only if no explicit option key exists in that version, in which case the Dockerfile's `PATH` install alone satisfies the requirement.) 2. Add `remote_components: ["ejs:github"]` to allow the EJS scripts needed by current yt-dlp releases to be fetched from the official yt-dlp GitHub repository. 3. Add `import shutil` if not already imported and used only for this lookup. **Validation:** - `python -m py_compile app.py` - `docker build -t yt-dl .` — must exit 0. - `docker run --rm -d -p 8080:8080 --name yt-dl-test yt-dl`, then `docker exec yt-dl-test deno --version` to confirm the binary is present and runnable in the final image. - Live check: paste the video URL that previously failed (`https://www.youtube.com/watch?v=7MrdyaSlOfI`, without playlist params) through the running container for MP3 or Video mode, and confirm it downloads successfully instead of failing with `This video is not available`. - Inspect `docker logs yt-dl-test` during that download and confirm the `No supported JavaScript runtime could be found` warning is no longer present. - Regression spot-check: re-run one playlist-link download (`&list=...`) and confirm only the single video downloads, progress bar still updates, and the delivered filename still matches the sanitized video title (Priority 1/2 behavior unaffected). - `docker stop yt-dl-test` **Documentation update (`README.md`):** - Add a short note under "Getting Started" / near the Build section stating that the image installs Deno and permits official GitHub-hosted EJS challenge-solver retrieval so yt-dlp can perform JS-based signature/PO-token deciphering required by current YouTube extraction, and that GitHub access is needed for downloads requiring those scripts. ## Validation (Priority 3) ```bash python -m py_compile app.py docker build -t yt-dl . docker run --rm -d -p 8080:8080 --name yt-dl-test yt-dl sleep 3 docker exec yt-dl-test deno --version curl -sf http://localhost:8080/ | grep -q "Download MP3" docker stop yt-dl-test ``` Live download checks (previously-failing video URL now succeeds, log warning gone, playlist-link/progress-bar/filename regressions) are performed manually/by the reviewer against real YouTube URLs, consistent with how prior tasks were validated — no new automated test framework is introduced. --- # Previous Plan (Priority 2 — completed, kept for reference) Goal: implement Priority 2 of `ROADMAP.md` — single-video-only downloads from playlist links, correctly-named output files, and a live progress bar in the browser UI. ## Scope - **T-003 — Single video from playlist links + correct filenames** (no architecture change; still one synchronous `POST /download` request/response): - Pass yt-dlp's `noplaylist: True` so a playlist URL or a `watch?v=...&list=...` URL only ever downloads the one referenced video. - Add a `sanitize_filename(title, fallback_id)` helper: strip characters outside letters (any language)/digits/spaces/`- _ . ( ) , '`, collapse resulting whitespace, trim, and fall back to the video id if nothing usable remains. - Extract `title`/`id` from yt-dlp's result info dict and pass the sanitized name explicitly as `FileResponse(..., filename=...)`, decoupled from whatever yt-dlp happens to name the file on disk. - Fix the real UUID-looking-filename bug: `response.blob()` in the frontend JS strips the `Content-Disposition` header, so `link.download = ''` makes the browser invent a blob-id-like name. Read the filename from the `Content-Disposition` response header in JS and set `link.download` to it explicitly (fallback to a generic name only if the header is somehow missing). - **T-004 — Background job + live progress bar** (architecture change, builds on T-003): - Split `POST /download` into three endpoints: - `POST /download` — validates the URL, creates an in-memory job record (`job_id = uuid4()`, status `pending`), schedules the actual yt-dlp run on a background thread (`asyncio.get_running_loop().run_in_executor`), and returns `{"job_id": ...}` immediately (no file streaming here anymore). - `GET /progress/{job_id}` — returns the job's current `{status, percent, speed, downloaded_bytes, total_bytes, filename, error}` from the in-memory store. `status` is one of `downloading`, `finished`, `error`. - `GET /download/{job_id}/file` — once `status == finished`, streams the prepared file via `FileResponse` (reusing T-003's sanitized filename) with `BackgroundTask` cleanup of the job's temp dir and job record; returns 409/404 if called before completion or after cleanup. - Progress capture: a yt-dlp `progress_hooks` callback updates the job's dict on each tick using `downloaded_bytes`, `total_bytes` (or `total_bytes_estimate`), `speed`, and computed percent; a `postprocessor_hooks` callback (or the `finished` hook state) marks the job `finished` once the final file is ready. - Job store: a plain in-memory `dict[str, JobState]` guarded by a `threading.Lock` (single-process app, matches "no persistent storage" constraint). Entries are removed after the file is served, and a periodic/best-effort cleanup drops stale jobs (e.g. no progress endpoint hit within N minutes) so a browser tab closed mid-download doesn't leak a temp dir forever. - Frontend JS rework: - `downloadFile(mode)` now: POSTs to `/download` to obtain `job_id`, shows the progress bar area, then polls `GET /progress/{job_id}` every 500ms. - On each poll: update a `` bar (or styled div) with percent, and a text line with speed (human-readable, e.g. `1.2 MB/s`) and `downloaded / total` size (human-readable bytes). - On `status == finished`: stop polling, fetch `GET /download/{job_id}/file`, read filename from `Content-Disposition` (per T-003's fix), trigger the save via the existing blob+anchor pattern, then hide the progress bar and clear status text. - On `status == error`: stop polling, show the error message in the existing `#status` div, hide the progress bar. - HTML/CSS: add a progress bar element (native `` plus a small text line for speed/size) directly below the two buttons, hidden by default, shown only while a job is active. ## Acceptance Criteria (from `ROADMAP.md` Priority 2) - Pasting a playlist URL (or a watch URL with a `list=` param) and clicking MP3 or Video downloads only that one video, never the rest of the playlist. - Starting a download shows a progress bar below the buttons that updates with percentage, speed, and downloaded/total size while the server is processing. - When the download finishes, the file is automatically delivered to the browser (save dialog) without an extra click. - The saved file's name matches the video's title (emoji/icons/symbols removed, normal characters kept) instead of a UUID or other opaque id, and still ends in the correct extension. - If the download fails server-side, the UI shows a clear error in place of the progress bar (no server crash, no stuck spinner). ## Implementation Phases ### Phase 1 — T-003: Single video from playlists + correct filenames **Files to change:** - `app.py` - `README.md` (document: playlist links only download the referenced video; filenames follow the video title with icons/emoji stripped) **Changes in `app.py`:** 1. Add `ydl_opts["noplaylist"] = True` for both `mp3` and `video` modes. 2. Add a module-level helper: ```python import re _FILENAME_ALLOWED = re.compile(r"[^\w\s.,'()\-]", re.UNICODE) _WHITESPACE = re.compile(r"\s+") def sanitize_filename(title: str, fallback_id: str) -> str: cleaned = _FILENAME_ALLOWED.sub("", title or "") cleaned = _WHITESPACE.sub(" ", cleaned).strip() return cleaned or fallback_id ``` (`\w` under `re.UNICODE`, the Python 3 default, matches any-language letters/digits/underscore, so non-Latin titles are preserved; emoji/symbols/pictographs fall outside `\w`, whitespace, and the explicit punctuation set and are dropped.) 3. In `fetch_media`, call `downloader.extract_info(url, download=True)` (instead of `.download([url])`) to get the info dict back; read `info.get("title")` and `info.get("id")`, compute `display_name = sanitize_filename(title, id) + Path(media_path).suffix`. 4. Pass `filename=display_name` to the existing `FileResponse(...)` call instead of `media_path.name`. 5. In the frontend `