diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md new file mode 100644 index 0000000..ec73d60 --- /dev/null +++ b/.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 | + +--- diff --git a/.ai/PLAN.md b/.ai/PLAN.md new file mode 100644 index 0000000..440ce78 --- /dev/null +++ b/.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 `
` 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 → `` 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. diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md new file mode 100644 index 0000000..a950a69 --- /dev/null +++ b/.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. diff --git a/.ai/TASKS.md b/.ai/TASKS.md new file mode 100644 index 0000000..038656d --- /dev/null +++ b/.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 | diff --git a/.ai/config.json b/.ai/config.json index fbffcfe..02ca32e 100644 --- a/.ai/config.json +++ b/.ai/config.json @@ -7,7 +7,7 @@ }, "implement": { "agent": "codex", - "model": "gpt-5.4" + "model": "gpt-5.6-terra" }, "review": { "agent": "claude", diff --git a/ROADMAP.md b/ROADMAP.md index 9ba667b..b8a1802 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,29 +1,27 @@ # ROADMAP -Goal: define and deliver the scope for this cycle. - -Delete any unused example sections below. Only the Goal and one concrete priority are required. +Goal: deliver a containerised YouTube downloader web app — one URL input, two download buttons (MP3 / Video), always best available quality. ## Priority 1 -Objective: replace with objective. - -- Replace with planned outcome. - -## Examples - -These example sections are optional illustrations, not required structure. - - -## Priority 2 +Objective: Containerised YouTube downloader with a minimal browser UI. -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 - -## Priority 3 +- `docker build` completes without errors. +- `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). diff --git a/app.py b/app.py new file mode 100644 index 0000000..ca5c9bd --- /dev/null +++ b/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 = """ + + + + + YouTube Downloader + + + +
+

YouTube Downloader

+ +
+ + +
+
+
+ + + +""" + +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), + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..de7889e --- /dev/null +++ b/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