From 25bf7db1674ab6fca972eaa530dc7f57f1a8e149 Mon Sep 17 00:00:00 2001 From: imagede Date: Sun, 29 Mar 2026 19:07:11 +0200 Subject: [PATCH] init --- .gitignore | 1 + Dockerfile | 26 +++ README.md | 62 +++++ app/main.py | 183 +++++++++++++++ app/requirements.txt | 3 + app/static/index.html | 509 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 14 ++ 7 files changed, 798 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 app/requirements.txt create mode 100644 app/static/index.html create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..872339c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +app/__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0ddca2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Install ffmpeg (required for merging video+audio streams and MP3 conversion) +RUN apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code and static files +COPY app/ . + +# Downloads are stored here (mount a host volume to persist them) +RUN mkdir -p /downloads + +# Setup Cronjob +RUN apt-get update && apt-get install -y cron && \ + echo "0 4 * * * pip install --upgrade yt-dlp >> /var/log/yt-dlp-upgrade.log 2>&1" | crontab - + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..069f759 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# yt-dlp Web Downloader + +A minimal self-hosted web UI for downloading videos and audio via [yt-dlp](https://github.com/yt-dlp/yt-dlp). + +## Features + +- Download videos as **MP4** at any quality (best / 1080p / 720p / 480p / 360p) +- Download audio as **MP3** (192kbps, extracted via ffmpeg) +- Live progress bar with speed & ETA +- Direct in-browser file download button +- Works with YouTube, SoundCloud, Twitter/X, Twitch, and 1000+ other sites + +## Quick Start + +```bash +# Clone / copy this directory, then: +docker compose up -d --build +``` + +Open **http://localhost:8080** in your browser. + +Downloaded files are saved to `./downloads/` on the host. + +## Usage + +1. Paste a URL into the input box +2. Click **Preview** to see the video title & thumbnail (optional) +3. Choose **Format** (Video or Audio) and **Quality** +4. Click **Download** — a progress card appears below +5. When done, click **⬇ Download File** to save it locally + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `TZ` | `Europe/Berlin` | Container timezone | + +To change the host port, edit `docker-compose.yml`: +```yaml +ports: + - "9090:8080" # → opens on port 9090 instead +``` + +## Updating yt-dlp + +yt-dlp releases frequently. Rebuild the image to get the latest version: + +```bash +docker compose build --no-cache && docker compose up -d +``` + +Or, to update inside a running container without rebuilding: + +```bash +docker exec ytdlp-web pip install --upgrade yt-dlp +``` + +## Notes + +- Files are prefixed with a random job ID internally; the download button strips the prefix for a clean filename. +- Downloads are kept in `/downloads` inside the container. Mount a host path to persist them across container restarts. +- No authentication is included by default. If exposed to the internet, put it behind a reverse proxy (e.g. nginx or Caddy) with basic auth. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..be4eca4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,183 @@ +import asyncio +import json +import os +import re +import uuid +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +import yt_dlp + +DOWNLOAD_DIR = Path(os.environ.get("DOWNLOAD_DIR", "/downloads")) +DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + +app = FastAPI(title="yt-dlp Web Downloader") + +# In-memory job store +jobs: dict[str, dict] = {} + + +class DownloadRequest(BaseModel): + url: str + format: str = "video" # "video" | "audio" + quality: str = "best" # "best" | "1080" | "720" | "480" | "360" + + +class JobStatus(BaseModel): + id: str + status: str # "pending" | "running" | "done" | "error" + progress: float = 0.0 + speed: str = "" + eta: str = "" + filename: str = "" + error: str = "" + title: str = "" + + +def make_progress_hook(job_id: str): + def hook(d): + job = jobs.get(job_id) + if not job: + return + if d["status"] == "downloading": + raw = d.get("_percent_str", "0%").strip().replace("%", "") + try: + job["progress"] = float(raw) + except ValueError: + job["progress"] = 0.0 + job["speed"] = d.get("_speed_str", "").strip() + job["eta"] = d.get("_eta_str", "").strip() + job["status"] = "running" + elif d["status"] == "finished": + job["progress"] = 100.0 + job["status"] = "processing" + return hook + + +def build_ydl_opts(req: DownloadRequest, job_id: str, out_template: str) -> dict: + opts = { + "outtmpl": out_template, + "progress_hooks": [make_progress_hook(job_id)], + "quiet": True, + "no_warnings": True, + "noplaylist": True, + } + + if req.format == "audio": + opts["format"] = "bestaudio/best" + opts["postprocessors"] = [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + } + ] + else: + # Video quality mapping + quality_map = { + "best": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best", + "1080": "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=1080]+bestaudio/best[height<=1080]", + "720": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=720]+bestaudio/best[height<=720]", + "480": "bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=480]+bestaudio/best[height<=480]", + "360": "bestvideo[height<=360][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=360]+bestaudio/best[height<=360]", + } + opts["format"] = quality_map.get(req.quality, quality_map["best"]) + opts["merge_output_format"] = "mp4" + + return opts + + +def run_download(job_id: str, req: DownloadRequest): + job = jobs[job_id] + try: + out_template = str(DOWNLOAD_DIR / f"{job_id}_%(title)s.%(ext)s") + opts = build_ydl_opts(req, job_id, out_template) + + # First fetch info to get title + with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl_info: + info = ydl_info.extract_info(req.url, download=False) + job["title"] = info.get("title", "Unknown") + + with yt_dlp.YoutubeDL(opts) as ydl: + ydl.download([req.url]) + + # Find the output file + found = list(DOWNLOAD_DIR.glob(f"{job_id}_*")) + if found: + job["filename"] = found[0].name + job["status"] = "done" + job["progress"] = 100.0 + else: + job["status"] = "error" + job["error"] = "Output file not found after download." + except Exception as e: + job["status"] = "error" + job["error"] = str(e) + + +@app.post("/api/download", response_model=JobStatus) +async def start_download(req: DownloadRequest): + job_id = str(uuid.uuid4()) + jobs[job_id] = { + "id": job_id, + "status": "pending", + "progress": 0.0, + "speed": "", + "eta": "", + "filename": "", + "error": "", + "title": "", + } + loop = asyncio.get_event_loop() + loop.run_in_executor(None, run_download, job_id, req) + return JobStatus(id=job_id, status="pending") + + +@app.get("/api/status/{job_id}", response_model=JobStatus) +async def get_status(job_id: str): + job = jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return JobStatus(**job) + + +@app.get("/api/file/{job_id}") +async def download_file(job_id: str): + job = jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if job["status"] != "done": + raise HTTPException(status_code=400, detail="File not ready") + file_path = DOWNLOAD_DIR / job["filename"] + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File missing on disk") + return FileResponse( + path=str(file_path), + filename=job["filename"].split("_", 1)[-1] if "_" in job["filename"] else job["filename"], + media_type="application/octet-stream", + ) + + +@app.get("/api/info") +async def get_info(url: str): + try: + with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl: + info = ydl.extract_info(url, download=False) + return { + "title": info.get("title", ""), + "thumbnail": info.get("thumbnail", ""), + "duration": info.get("duration", 0), + "uploader": info.get("uploader", ""), + "view_count": info.get("view_count", 0), + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# Serve static frontend — resolve relative to this file so it works both locally and in Docker +STATIC_DIR = Path(__file__).parent / "static" +app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..f33ee5f --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +yt-dlp>=2024.11.4 diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..40f042b --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,509 @@ + + + + + + yt-dlp Downloader + + + + +
+ +

Download videos & audio from YouTube and 1000+ sites

+
+ +
+
+ + +
+ +
+ +
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c49b5c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + ytdlp: + build: . + container_name: ytdlp-web + ports: + - "8080:8080" + volumes: + # All downloaded files land in ./downloads on the host + - ./downloads:/downloads + restart: unless-stopped + environment: + # Optional: set to a writable path if you want a custom cookies file + # YT_DLP_COOKIES: /downloads/cookies.txt + TZ: Europe/Berlin