This commit is contained in:
imagede
2026-03-29 19:07:11 +02:00
commit 25bf7db167
7 changed files with 798 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
app/__pycache__

26
Dockerfile Normal file
View File

@@ -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"]

62
README.md Normal file
View File

@@ -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.

183
app/main.py Normal file
View File

@@ -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")

3
app/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
yt-dlp>=2024.11.4

509
app/static/index.html Normal file
View File

@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yt-dlp Downloader</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f12;
--surface: #18181f;
--surface-2: #22222c;
--border: #2e2e3a;
--accent: #e53935;
--accent-hover: #ef5350;
--text: #f0f0f5;
--text-muted: #8888a0;
--success: #43a047;
--error: #e53935;
--radius: 10px;
--font: 'Inter', 'Segoe UI', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px 80px;
}
header {
text-align: center;
margin-bottom: 40px;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.logo svg { width: 36px; height: 36px; }
h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text);
}
.subtitle {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 4px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
width: 100%;
max-width: 600px;
}
.url-row {
display: flex;
gap: 10px;
}
input[type="text"] {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 14px;
color: var(--text);
font-size: 0.95rem;
font-family: var(--font);
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus { border-color: var(--accent); }
input[type="text"]::placeholder { color: var(--text-muted); }
.btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 11px 20px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) { background: var(--accent-hover); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.secondary {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
}
.btn.secondary:hover:not(:disabled) { background: var(--border); }
.options-row {
display: flex;
gap: 10px;
margin-top: 14px;
}
.option-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
label.opt-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
color: var(--text);
font-size: 0.9rem;
font-family: var(--font);
outline: none;
cursor: pointer;
transition: border-color 0.15s;
width: 100%;
}
select:focus { border-color: var(--accent); }
/* Preview */
#preview {
margin-top: 20px;
display: none;
gap: 14px;
align-items: flex-start;
}
#preview.visible { display: flex; }
#preview-thumb {
width: 100px;
height: 60px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
flex-shrink: 0;
}
.preview-info { flex: 1; min-width: 0; }
.preview-title {
font-size: 0.95rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-meta {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
/* Jobs */
#jobs {
width: 100%;
max-width: 600px;
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.job {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.job-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.job-title {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.job-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 3px 9px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.badge-pending, .badge-running, .badge-processing {
background: #1a2a3a;
color: #60aaff;
}
.badge-done { background: #1a2e1a; color: #66bb6a; }
.badge-error { background: #2e1a1a; color: #ef5350; }
.job-progress-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.job-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.badge-done ~ .job-progress-bar .job-progress-fill { background: var(--success); }
.job-meta {
font-size: 0.78rem;
color: var(--text-muted);
display: flex;
gap: 14px;
}
.job-error {
font-size: 0.82rem;
color: var(--error);
}
.job-actions { display: flex; gap: 8px; }
/* Divider */
.divider {
width: 100%;
max-width: 600px;
border: none;
border-top: 1px solid var(--border);
margin: 8px 0;
}
/* Spinner */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 480px) {
.url-row { flex-direction: column; }
.options-row { flex-direction: column; }
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg aria-label="yt-dlp downloader" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="36" rx="8" fill="#e53935"/>
<path d="M10 13l8 5-8 5V13z" fill="white"/>
<path d="M20 13h6M20 18h6M20 23h6" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
<h1>yt-dlp Downloader</h1>
</div>
<p class="subtitle">Download videos &amp; audio from YouTube and 1000+ sites</p>
</header>
<div class="card">
<div class="url-row">
<input type="text" id="url-input" placeholder="Paste a YouTube (or other) URL…" autocomplete="off" />
<button class="btn secondary" id="fetch-btn" onclick="fetchInfo()">Preview</button>
</div>
<div id="preview">
<img id="preview-thumb" src="" alt="" />
<div class="preview-info">
<div class="preview-title" id="preview-title"></div>
<div class="preview-meta" id="preview-meta"></div>
</div>
</div>
<div class="options-row">
<div class="option-group">
<label class="opt-label" for="format-select">Format</label>
<select id="format-select" onchange="onFormatChange()">
<option value="video">Video (MP4)</option>
<option value="audio">Audio (MP3)</option>
</select>
</div>
<div class="option-group" id="quality-group">
<label class="opt-label" for="quality-select">Quality</label>
<select id="quality-select">
<option value="best">Best available</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
</select>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn" id="dl-btn" onclick="startDownload()" style="width:100%">Download</button>
</div>
</div>
<div id="jobs"></div>
<script>
const activePolls = {};
function fmtDuration(sec) {
if (!sec) return '';
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function fmtViews(n) {
if (!n) return '';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M views';
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K views';
return n + ' views';
}
function onFormatChange() {
const fmt = document.getElementById('format-select').value;
document.getElementById('quality-group').style.opacity = fmt === 'audio' ? '0.4' : '1';
document.getElementById('quality-select').disabled = fmt === 'audio';
}
async function fetchInfo() {
const url = document.getElementById('url-input').value.trim();
if (!url) return;
const btn = document.getElementById('fetch-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Loading';
const preview = document.getElementById('preview');
preview.classList.remove('visible');
try {
const res = await fetch('/api/info?url=' + encodeURIComponent(url));
if (!res.ok) throw new Error((await res.json()).detail || 'Error');
const info = await res.json();
document.getElementById('preview-thumb').src = info.thumbnail || '';
document.getElementById('preview-title').textContent = info.title || 'Unknown';
document.getElementById('preview-meta').textContent =
[info.uploader, fmtDuration(info.duration), fmtViews(info.view_count)].filter(Boolean).join(' · ');
preview.classList.add('visible');
} catch (e) {
alert('Could not fetch info: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Preview';
}
}
async function startDownload() {
const url = document.getElementById('url-input').value.trim();
if (!url) { alert('Please enter a URL first.'); return; }
const format = document.getElementById('format-select').value;
const quality = document.getElementById('quality-select').value;
const btn = document.getElementById('dl-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Starting…';
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format, quality }),
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error');
const job = await res.json();
addJobCard(job.id, url, format);
pollJob(job.id);
} catch (e) {
alert('Failed to start download: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Download';
}
}
function addJobCard(id, url, format) {
const container = document.getElementById('jobs');
const card = document.createElement('div');
card.className = 'job';
card.id = 'job-' + id;
const label = new URL(url).hostname.replace('www.', '');
card.innerHTML = `
<div class="job-header">
<div class="job-title" id="title-${id}">${label} · ${format === 'audio' ? 'MP3' : 'MP4'}</div>
<span class="job-badge badge-pending" id="badge-${id}">Pending</span>
</div>
<div class="job-progress-bar">
<div class="job-progress-fill" id="fill-${id}" style="width:0%"></div>
</div>
<div class="job-meta" id="meta-${id}">Queued…</div>
<div class="job-error" id="err-${id}" style="display:none"></div>
<div class="job-actions" id="actions-${id}"></div>
`;
container.prepend(card);
}
function pollJob(id) {
if (activePolls[id]) return;
activePolls[id] = setInterval(async () => {
try {
const res = await fetch('/api/status/' + id);
const job = await res.json();
updateJobCard(job);
if (job.status === 'done' || job.status === 'error') {
clearInterval(activePolls[id]);
delete activePolls[id];
}
} catch (e) {
// silently retry
}
}, 800);
}
function updateJobCard(job) {
const title = document.getElementById('title-' + job.id);
const badge = document.getElementById('badge-' + job.id);
const fill = document.getElementById('fill-' + job.id);
const meta = document.getElementById('meta-' + job.id);
const err = document.getElementById('err-' + job.id);
const actions = document.getElementById('actions-' + job.id);
if (!badge) return;
if (job.title) title.textContent = job.title;
const statusLabels = { pending: 'Pending', running: 'Downloading', processing: 'Processing', done: 'Done', error: 'Error' };
badge.textContent = statusLabels[job.status] || job.status;
badge.className = 'job-badge badge-' + job.status;
fill.style.width = job.progress + '%';
if (job.status === 'running') {
const parts = [];
if (job.progress > 0) parts.push(job.progress.toFixed(1) + '%');
if (job.speed) parts.push(job.speed);
if (job.eta) parts.push('ETA ' + job.eta);
meta.textContent = parts.join(' · ') || 'Downloading…';
} else if (job.status === 'processing') {
meta.textContent = 'Post-processing (merging / converting)…';
} else if (job.status === 'done') {
meta.textContent = 'Complete · ' + job.filename.split('_').slice(1).join('_');
if (!actions.querySelector('a')) {
const a = document.createElement('a');
a.href = '/api/file/' + job.id;
a.className = 'btn';
a.style.fontSize = '0.82rem';
a.style.padding = '7px 14px';
a.textContent = '⬇ Download File';
a.download = '';
actions.appendChild(a);
}
} else if (job.status === 'error') {
meta.textContent = '';
err.style.display = 'block';
err.textContent = '✕ ' + (job.error || 'Unknown error');
}
}
// Allow pressing Enter in the URL field
document.getElementById('url-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') startDownload();
});
</script>
</body>
</html>

14
docker-compose.yml Normal file
View File

@@ -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