Browse Source

feat(downloader): add browser-based YouTube downloads

main
Domagoj Zecevic 2 weeks ago
parent
commit
5148bad8fb
  1. 53
      .ai/HANDOFF.md
  2. 120
      .ai/PLAN.md
  3. 39
      .ai/REVIEW.md
  4. 24
      .ai/TASKS.md
  5. 2
      .ai/config.json
  6. 36
      ROADMAP.md
  7. 153
      app.py
  8. 4
      requirements.txt

53
.ai/HANDOFF.md

@ -0,0 +1,53 @@
# HANDOFF
Append-only role handoff log. Each role adds one entry when its step is complete.
### T-001,T-002 — plan — 2026-09-05T00:00:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Wrote implementation plan for containerised YouTube downloader (FastAPI + yt-dlp + ffmpeg); created TASKS.md with T-001 (app code) and T-002 (Dockerfile + README); both tasks moved to ready_for_implement |
| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Next Role | implement |
---
### T-001 — implement — 2026-09-07T08:34:50Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Implemented the FastAPI downloader backend and inline browser interface. |
| Files Changed | app.py, requirements.txt, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `/tmp/youtube-downloader-venv/bin/python -m py_compile app.py`; mocked FastAPI route checks passed; `git diff --check` passed |
| Commit | Pending reviewer approval |
| Next Role | review |
---
### T-001 — review — 2026-09-07T00:00:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed FastAPI downloader backend against `.ai/PLAN.md`; re-ran mocked route checks (home page, valid download, invalid/empty/missing-field errors, yt-dlp failure, unicode filename) — all passed. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-001 — implement — 2026-09-07T08:48:25Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Squashed the reviewed downloader implementation into the task commit. |
| Files Changed | app.py, requirements.txt, ROADMAP.md, .ai/config.json, .ai/PLAN.md, .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | Reviewer verification passed; `git diff --check` passed |
| Commit | Created by this handoff's task commit |
| Next Role | none |
---

120
.ai/PLAN.md

@ -0,0 +1,120 @@
# Plan
Status: **ready_for_implement**
Goal: implement a containerised YouTube downloader web app per `ROADMAP.md`.
## Scope
- Single Docker container running a FastAPI server (Python 3.12-slim + ffmpeg + yt-dlp).
- Minimal browser UI: one URL text field + **Download MP3** and **Download Video** buttons.
- MP3 download: yt-dlp extracts best audio, ffmpeg re-encodes to MP3 at highest quality (`-q:a 0`), file streamed back to browser.
- Video download: yt-dlp fetches `bestvideo+bestaudio`, merges to MP4 via ffmpeg, file streamed back to browser.
- Invalid/empty URL returns a JSON error; the UI displays it without crashing.
- No persistent storage — temp dir per request, cleaned up after response.
## Acceptance Criteria
- `docker build -t yt-dl .` exits 0.
- `docker run --rm -p 8080:8080 yt-dl` starts the server and `curl http://localhost:8080/` returns 200.
- A valid YouTube URL + MP3 button delivers a `.mp3` file to the browser.
- A valid YouTube URL + Video button delivers a `.mp4` file to the browser.
- An empty URL or garbage URL returns an error visible in the UI.
## Implementation Phases
### Phase 1 — T-001: FastAPI backend + HTML/JS frontend
**Files to create:**
- `app.py` — entire server + inline HTML
- `requirements.txt`
**`app.py` structure:**
1. **HTML constant** (`HTML`) — inline single-page UI:
- Text input for URL, placeholder "Paste YouTube URL…"
- Two buttons: `Download MP3` / `Download Video`
- Status `<div>` for errors/progress text
- JS `downloadFile(mode)`:
- Reads URL from input, validates non-empty client-side
- `fetch('/download', { method:'POST', body: FormData{url, mode} })`
- On success (content-type not application/json): creates object URL → `<a download>` click → revoke URL
- On error or JSON response: parse JSON and show `.message` in status div
2. **FastAPI app** (`app`):
- `GET /` — returns `HTMLResponse(HTML)`
- `POST /download``url: str = Form(...)`, `mode: Literal["mp3","video"] = Form(...)`
- Validate URL non-empty and starts with `http`; if not, raise `HTTPException(400)`
- Create `tempfile.TemporaryDirectory()`
- Build `ydl_opts`:
- MP3: `format='bestaudio/best'`, postprocessor `FFmpegExtractAudio` with `preferredcodec='mp3'`, `preferredquality='0'`
- Video: `format='bestvideo+bestaudio/best'`, `merge_output_format='mp4'`
- `outtmpl=tmpdir/%(title)s.%(ext)s`
- Run `yt_dlp.YoutubeDL(ydl_opts).download([url])` inside `asyncio.get_event_loop().run_in_executor(None, ...)` to avoid blocking the event loop
- Find the downloaded file in tmpdir (glob the single file present)
- Return `FileResponse(path, filename=..., background=BackgroundTask(cleanup_tmpdir))` — cleanup runs after response is sent
- Wrap yt-dlp call in try/except; on any exception raise `HTTPException(500, detail=str(e))`
3. **`requirements.txt`**:
```
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
yt-dlp>=2024.4.9
python-multipart>=0.0.9
```
**Key decisions:**
- Inline HTML keeps the project a two-file app (plus Dockerfile) — no template engine, no static asset serving needed.
- `run_in_executor` keeps uvicorn's event loop unblocked while yt-dlp runs (which can take 30–120 s).
- `FileResponse` with `BackgroundTask` cleanup is the standard FastAPI pattern for temp file responses.
- yt-dlp is called via its Python API (not subprocess) to avoid shell escaping issues with URLs.
---
### Phase 2 — T-002: Dockerfile + README
**Files to create/update:**
- `Dockerfile`
- `README.md` (update with build/run instructions)
**`Dockerfile`**:
```dockerfile
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
```
**`README.md`** must document:
- Prerequisites: Docker
- Build: `docker build -t yt-dl .`
- Run: `docker run --rm -p 8080:8080 yt-dl`
- Usage: open `http://localhost:8080` in a browser
- Note on video size / download time expectations
## Validation
```bash
# Build
docker build -t yt-dl .
# Smoke test — server starts and home page returns 200
docker run --rm -d -p 8080:8080 --name yt-dl-test yt-dl
sleep 3
curl -sf http://localhost:8080/ | grep -q "Download MP3"
docker stop yt-dl-test
```
No unit-test framework is introduced; the app is small enough that the docker smoke test is the validation gate. The reviewer will perform a live download test.

39
.ai/REVIEW.md

@ -0,0 +1,39 @@
# REVIEW
Append-only review log. Each task section is appended or updated in place; prior task history is preserved.
---
## T-001 — FastAPI backend (`app.py`) + inline HTML/JS frontend (`requirements.txt`)
**Verdict:** PASS_WITH_NOTES
### Findings
1. **[minor]** `app.py:147` — On any failure inside `download()` (including yt-dlp errors), `str(exc)` is returned verbatim to the browser as the `message` field. yt-dlp exceptions can include local filesystem paths (the temp dir) or verbose internal detail. Not a required fix for this local/self-hosted tool, but consider mapping to a generic message (e.g. "The video could not be downloaded.") and logging the raw exception server-side instead, before this is exposed beyond local/dev use.
- Required fix: No.
No blocker or major findings.
### Verification
**Steps performed:**
- Re-read `.ai/PLAN.md` (Phase 1 spec for T-001) and diffed it against `app.py` / `requirements.txt`.
- `python -m py_compile app.py` — succeeded.
- Installed `fastapi`, `uvicorn`, `httpx`, `python-multipart`, `yt-dlp` into a scratch venv and exercised the app with `starlette.testclient.TestClient`, mocking `yt_dlp.YoutubeDL` to avoid live network calls:
- `GET /` → 200, body contains "Download MP3" and "Download Video".
- `POST /download` with a garbage (non-`http`) URL → 400 JSON `{"message": ...}`.
- `POST /download` with an empty URL → 400 JSON.
- `POST /download` missing the `mode` field (triggers `RequestValidationError` handler) → 400 JSON with `.message`.
- `POST /download` with a valid URL, mode `mp3`, mocked yt-dlp writing a fake file → 200, correct bytes streamed, `Content-Disposition: attachment; filename="..."`, temp dir cleaned up via `BackgroundTask`.
- `POST /download` where mocked yt-dlp raises `RuntimeError` → 500 JSON `{"message": "..."}` (no server crash), temp dir cleaned up on the error path.
- Filename containing non-ASCII characters (e.g. "café mix 🎵.mp3") → correctly RFC 5987-encoded as `filename*=utf-8''...` by Starlette; no crash or mojibake.
**Findings from verification:** All acceptance criteria for T-001 hold:
- `GET /` returns 200 with HTML containing "Download MP3".
- `POST /download` with a valid URL + mode streams a file.
- Invalid URL returns 400 JSON with `.message`.
**Risks:**
- No live network test against real YouTube was performed (would require external network access and a real video URL); mocked yt-dlp calls confirm the FastAPI plumbing (routing, form validation, error handling, file streaming, temp-dir cleanup) is correct, but do not confirm yt-dlp/ffmpeg behavior against a live video. This risk carries into the Docker-level validation planned for T-002 (`docker build` + smoke test), where a live download should be attempted at least once.
- Large/long-running downloads have no timeout or size cap — acceptable for v1 per `ROADMAP.md` (out of scope), but worth revisiting if this moves beyond local/dev use.

24
.ai/TASKS.md

@ -0,0 +1,24 @@
# TASKS
Use this board to coordinate handoff between planner, implementer, and reviewer.
Status values:
- `in_planning`
- `ready_for_implement`
- `in_implementation`
- `ready_for_review`
- `in_review`
- `ready_to_commit`
- `changes_requested`
- `done`
Command expectations:
- planner moves tasks into `in_planning` and `ready_for_implement`
- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit`
- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested`
- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
| --- | --- | --- | --- | --- | --- |
| T-001 | FastAPI backend (`app.py`) + inline HTML/JS frontend (`requirements.txt`) | done | `GET /` returns 200 with HTML containing "Download MP3"; `POST /download` with valid URL + mode streams a file; invalid URL returns 400 JSON with `.message` | Reviewer re-ran mocked FastAPI route checks (GET /, valid download, invalid/empty/missing-field errors, yt-dlp failure, unicode filename) — all passed; see `.ai/REVIEW.md` | none |
| T-002 | Dockerfile + README.md (build/run docs) | ready_for_implement | `docker build -t yt-dl .` exits 0; `docker run --rm -p 8080:8080 yt-dl` starts server; `curl http://localhost:8080/` returns HTML with download buttons | n/a | implement |

2
.ai/config.json

@ -7,7 +7,7 @@
}, },
"implement": { "implement": {
"agent": "codex", "agent": "codex",
"model": "gpt-5.4" "model": "gpt-5.6-terra"
}, },
"review": { "review": {
"agent": "claude", "agent": "claude",

36
ROADMAP.md

@ -1,29 +1,27 @@
# ROADMAP # ROADMAP
Goal: define and deliver the scope for this cycle. Goal: deliver a containerised YouTube downloader web app — one URL input, two download buttons (MP3 / Video), always best available quality.
Delete any unused example sections below. Only the Goal and one concrete priority are required.
## Priority 1 ## Priority 1
Objective: replace with objective. Objective: Containerised YouTube downloader with a minimal browser UI.
- Replace with planned outcome.
## Examples
These example sections are optional illustrations, not required structure.
<!-- Example: remove or replace this section -->
## Priority 2
Objective: optional second objective. - User pastes a YouTube URL into a single input field.
- Clicking **Download MP3** triggers a server-side yt-dlp run that extracts audio at the highest available bitrate (converted to MP3 via ffmpeg) and streams the file back to the browser for download.
- Clicking **Download Video** triggers a server-side yt-dlp run that fetches the best video+audio quality and streams the file back to the browser for download.
- The entire app runs in a single Docker container (`docker build` / `docker run -p 8080:8080 yt-dl`) with no external dependencies beyond Docker.
- No login, no database, no persistent storage required — stateless request/response.
- Replace with optional planned outcome. ## Acceptance Criteria
<!-- Example: remove or replace this section --> - `docker build` completes without errors.
## Priority 3 - `docker run -p 8080:8080 yt-dl` starts the server.
- Pasting a valid YouTube URL and clicking **Download MP3** delivers an `.mp3` file to the browser.
- Pasting a valid YouTube URL and clicking **Download Video** delivers a best-quality video file (`.mp4` / `.mkv`) to the browser.
- Invalid or empty URLs show a clear error message in the UI (no server crash).
Objective: optional third objective. ## Out of Scope
- Replace with optional planned outcome. - Playlist batch downloads.
- User accounts / history.
- Progress bars / live download status (nice-to-have but not required for v1).

153
app.py

@ -0,0 +1,153 @@
"""FastAPI application serving a small YouTube download interface."""
import asyncio
import shutil
import tempfile
from pathlib import Path
from typing import Literal
import yt_dlp
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from starlette.background import BackgroundTask
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YouTube Downloader</title>
<style>
body { align-items: center; background: #101114; color: #f4f4f5; display: flex;
font-family: system-ui, sans-serif; justify-content: center; min-height: 100vh; margin: 0; }
main { max-width: 36rem; padding: 2rem; width: 100%; }
input { box-sizing: border-box; font-size: 1rem; padding: .8rem; width: 100%; }
button { cursor: pointer; font-size: 1rem; margin: 1rem .5rem 0 0; padding: .75rem 1rem; }
#status { color: #fca5a5; min-height: 1.5rem; padding-top: 1rem; }
</style>
</head>
<body>
<main>
<h1>YouTube Downloader</h1>
<input id="url" type="url" placeholder="Paste YouTube URL…" autocomplete="off">
<div>
<button type="button" onclick="downloadFile('mp3')">Download MP3</button>
<button type="button" onclick="downloadFile('video')">Download Video</button>
</div>
<div id="status" role="status"></div>
</main>
<script>
async function downloadFile(mode) {
const url = document.getElementById('url').value.trim();
const status = document.getElementById('status');
if (!url) {
status.textContent = 'Please paste a YouTube URL.';
return;
}
status.textContent = 'Preparing your download…';
const data = new FormData();
data.append('url', url);
data.append('mode', mode);
try {
const response = await fetch('/download', { method: 'POST', body: data });
const contentType = response.headers.get('content-type') || '';
if (!response.ok || contentType.includes('application/json')) {
const error = await response.json();
throw new Error(error.message || 'The download could not be prepared.');
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
status.textContent = '';
} catch (error) {
status.textContent = error.message || 'The download could not be prepared.';
}
}
</script>
</body>
</html>
"""
app = FastAPI()
def cleanup_tmpdir(path: str) -> None:
"""Remove a request's temporary download directory after streaming."""
shutil.rmtree(path, ignore_errors=True)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
"""Expose errors in the format expected by the browser client."""
message = exc.detail if isinstance(exc.detail, str) else "The request could not be processed."
return JSONResponse(status_code=exc.status_code, content={"message": message})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_: Request, __: RequestValidationError) -> JSONResponse:
"""Return malformed form submissions as a client-readable error."""
return JSONResponse(status_code=400, content={"message": "A URL and download mode are required."})
@app.get("/", response_class=HTMLResponse)
async def home() -> str:
"""Serve the single-page downloader interface."""
return HTML
@app.post("/download")
async def download(
url: str = Form(...), mode: Literal["mp3", "video"] = Form(...)
) -> FileResponse:
"""Download and stream a requested audio or video file."""
if not url.strip() or not url.lower().startswith("http"):
raise HTTPException(status_code=400, detail="Please provide a valid URL starting with http.")
temp_dir = tempfile.mkdtemp(prefix="youtube-download-")
output_template = str(Path(temp_dir) / "%(title)s.%(ext)s")
ydl_opts: dict[str, object] = {"outtmpl": output_template}
if mode == "mp3":
ydl_opts.update(
{
"format": "bestaudio/best",
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "0",
}
],
}
)
else:
ydl_opts.update({"format": "bestvideo+bestaudio/best", "merge_output_format": "mp4"})
def fetch_media() -> None:
with yt_dlp.YoutubeDL(ydl_opts) as downloader:
downloader.download([url])
try:
await asyncio.get_running_loop().run_in_executor(None, fetch_media)
downloads = [path for path in Path(temp_dir).iterdir() if path.is_file()]
if len(downloads) != 1:
raise RuntimeError("The downloaded file could not be identified.")
media_path = downloads[0]
except Exception as exc:
cleanup_tmpdir(temp_dir)
raise HTTPException(status_code=500, detail=str(exc)) from exc
return FileResponse(
media_path,
filename=media_path.name,
background=BackgroundTask(cleanup_tmpdir, temp_dir),
)

4
requirements.txt

@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
yt-dlp>=2024.4.9
python-multipart>=0.0.9
Loading…
Cancel
Save