8 changed files with 411 additions and 20 deletions
@ -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 | |
|||
|
|||
--- |
|||
@ -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. |
|||
@ -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. |
|||
@ -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 | |
|||
@ -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. |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## 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 |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## 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). |
|||
|
|||
@ -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), |
|||
) |
|||
@ -0,0 +1,4 @@ |
|||
fastapi>=0.111.0 |
|||
uvicorn[standard]>=0.29.0 |
|||
yt-dlp>=2024.4.9 |
|||
python-multipart>=0.0.9 |
|||
Loading…
Reference in new issue