init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
app/__pycache__
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
62
README.md
Normal 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
183
app/main.py
Normal 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
3
app/requirements.txt
Normal 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
509
app/static/index.html
Normal 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 & 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
14
docker-compose.yml
Normal 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
|
||||
Reference in New Issue
Block a user