Dateien nach „/“ hochladen

This commit is contained in:
hannes 2026-04-29 20:42:39 +00:00
commit fe1ea4fe3e
4 changed files with 463 additions and 0 deletions

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# YT → MP3 Downloader
Flask-App zum Herunterladen von YouTube-Videos als MP3 mit ID3-Tags.
## Voraussetzungen
- Python 3.8+
- ffmpeg (muss im PATH sein)
### ffmpeg installieren
**Ubuntu/Debian:**
```bash
sudo apt install ffmpeg
```
**macOS:**
```bash
brew install ffmpeg
```
**Windows:**
Download von https://ffmpeg.org/download.html, dann zum PATH hinzufügen.
## Setup
```bash
# Abhängigkeiten installieren
pip install -r requirements.txt
# App starten
python app.py
```
Dann im Browser öffnen: http://localhost:5000
## Funktionsweise
1. YouTube-URL eingeben
2. Interpret, Titel und Album ausfüllen
3. „Download starten" klicken
4. Die MP3 wird im Ordner `downloads/` gespeichert
## Projektstruktur
```
ytdl-app/
├── app.py # Flask-Backend
├── requirements.txt # Python-Abhängigkeiten
├── downloads/ # Gespeicherte MP3-Dateien
└── templates/
└── index.html # Web-Interface
```

101
app.py Normal file
View file

@ -0,0 +1,101 @@
from flask import Flask, render_template, request, jsonify
import yt_dlp
import os
import threading
app = Flask(__name__)
DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), "downloads")
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
download_status = {}
def do_download(task_id, url, artist, title, album):
try:
download_status[task_id] = {"status": "running", "message": "Download läuft..."}
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": os.path.join(DOWNLOAD_DIR, "%(title)s.%(ext)s"),
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "192",
},
{
"key": "FFmpegMetadata",
"add_metadata": True,
},
],
# Scoped to FFmpegMetadata so tags are set correctly
"postprocessor_args": {
"ffmpegmetadata": [
"-metadata", f"artist={artist}",
"-metadata", f"title={title}",
"-metadata", f"album={album}",
]
},
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# extract_info kann eine Playlist zurückgeben dann erstes Entry nehmen
if info is None:
raise RuntimeError("yt-dlp hat keine Informationen zurückgegeben. Bitte URL prüfen.")
if "entries" in info:
info = info["entries"][0]
filename = ydl.prepare_filename(info)
mp3_name = os.path.splitext(os.path.basename(filename))[0] + ".mp3"
download_status[task_id] = {
"status": "done",
"message": f"Fertig! Gespeichert als: {mp3_name}",
"filename": mp3_name,
}
except Exception as e:
download_status[task_id] = {
"status": "error",
"message": f"Fehler: {str(e)}",
}
@app.route("/")
def index():
return render_template("index.html")
@app.route("/download", methods=["POST"])
def download():
data = request.get_json()
url = data.get("url", "").strip()
artist = data.get("artist", "").strip()
title = data.get("title", "").strip()
album = data.get("album", "").strip()
if not url:
return jsonify({"error": "URL fehlt"}), 400
import uuid
task_id = str(uuid.uuid4())
thread = threading.Thread(target=do_download, args=(task_id, url, artist, title, album))
thread.daemon = True
thread.start()
return jsonify({"task_id": task_id})
@app.route("/status/<task_id>")
def status(task_id):
result = download_status.get(task_id, {"status": "unknown", "message": "Unbekannte Task-ID"})
return jsonify(result)
if __name__ == "__main__":
app.run(debug=True, port=5000)

307
index.html Normal file
View file

@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YT → MP3</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Bebas+Neue&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--surface: #141414;
--border: #2a2a2a;
--accent: #e8ff47;
--accent-dim: #b8cc2a;
--text: #e8e8e8;
--muted: #555;
--error: #ff4747;
--success: #47ff8a;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Space Mono', monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background-image:
repeating-linear-gradient(0deg, transparent, transparent 39px, #1a1a1a 39px, #1a1a1a 40px),
repeating-linear-gradient(90deg, transparent, transparent 39px, #1a1a1a 39px, #1a1a1a 40px);
}
.card {
width: 100%;
max-width: 560px;
background: var(--surface);
border: 1px solid var(--border);
padding: 0;
position: relative;
}
.card::before {
content: '';
position: absolute;
top: -3px; left: -3px;
right: -3px; bottom: -3px;
border: 1px solid var(--accent);
pointer-events: none;
opacity: 0.15;
}
.header {
padding: 2rem 2rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 1rem;
}
.header h1 {
font-family: 'Bebas Neue', sans-serif;
font-size: 3.5rem;
letter-spacing: 0.05em;
color: var(--accent);
line-height: 1;
}
.header .sub {
font-size: 0.7rem;
color: var(--muted);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.form-body {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
label {
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
input {
background: #0d0d0d;
border: 1px solid var(--border);
color: var(--text);
font-family: 'Space Mono', monospace;
font-size: 0.85rem;
padding: 0.75rem 1rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
input::placeholder {
color: #333;
}
.divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
button[type="submit"] {
background: var(--accent);
color: #0d0d0d;
border: none;
font-family: 'Bebas Neue', sans-serif;
font-size: 1.4rem;
letter-spacing: 0.12em;
padding: 1rem;
cursor: pointer;
width: 100%;
transition: background 0.15s, transform 0.1s;
position: relative;
overflow: hidden;
}
button[type="submit"]:hover { background: #d4eb30; }
button[type="submit"]:active { transform: scale(0.99); }
button[type="submit"]:disabled {
background: #3a3a1a;
color: #666;
cursor: not-allowed;
}
.status-box {
margin: 0 2rem 2rem;
padding: 1rem;
border: 1px solid var(--border);
font-size: 0.78rem;
display: none;
align-items: center;
gap: 0.75rem;
line-height: 1.5;
}
.status-box.show { display: flex; }
.status-box.running { border-color: var(--accent); color: var(--accent); }
.status-box.done { border-color: var(--success); color: var(--success); }
.status-box.error { border-color: var(--error); color: var(--error); }
.spinner {
width: 18px; height: 18px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
flex-shrink: 0;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.icon { font-size: 1.2rem; flex-shrink: 0; }
.footer-bar {
padding: 0.75rem 2rem;
border-top: 1px solid var(--border);
font-size: 0.6rem;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>YT→MP3</h1>
<span class="sub">YouTube Downloader</span>
</div>
<div class="form-body">
<div class="field">
<label>URL</label>
<input type="url" id="url" placeholder="https://youtube.com/watch?v=..." autocomplete="off"/>
</div>
<div class="divider"></div>
<div class="field">
<label>Interpret</label>
<input type="text" id="artist" placeholder="z.B. Die Ärzte"/>
</div>
<div class="field">
<label>Titel</label>
<input type="text" id="title" placeholder="z.B. Männer sind Schweine"/>
</div>
<div class="field">
<label>Album</label>
<input type="text" id="album" placeholder="z.B. 13"/>
</div>
<button type="submit" id="btn" onclick="startDownload()">
DOWNLOAD STARTEN
</button>
</div>
<div class="status-box" id="status-box">
<span id="status-icon" class="icon"></span>
<span id="status-msg"></span>
</div>
<div class="footer-bar">Downloads → ./downloads/</div>
</div>
<script>
let pollInterval = null;
async function startDownload() {
const url = document.getElementById('url').value.trim();
const artist = document.getElementById('artist').value.trim();
const title = document.getElementById('title').value.trim();
const album = document.getElementById('album').value.trim();
if (!url) { showStatus('error', '⚠', 'Bitte eine URL eingeben.'); return; }
const btn = document.getElementById('btn');
btn.disabled = true;
showStatus('running', null, 'Verbinde mit YouTube...');
try {
const res = await fetch('/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, artist, title, album })
});
const data = await res.json();
if (data.error) { showStatus('error', '✕', data.error); btn.disabled = false; return; }
pollStatus(data.task_id);
} catch (e) {
showStatus('error', '✕', 'Verbindungsfehler: ' + e.message);
btn.disabled = false;
}
}
function pollStatus(taskId) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const res = await fetch('/status/' + taskId);
const data = await res.json();
if (data.status === 'running') {
showStatus('running', null, data.message);
} else if (data.status === 'done') {
clearInterval(pollInterval);
showStatus('done', '✓', data.message);
document.getElementById('btn').disabled = false;
} else if (data.status === 'error') {
clearInterval(pollInterval);
showStatus('error', '✕', data.message);
document.getElementById('btn').disabled = false;
}
} catch (e) {
clearInterval(pollInterval);
showStatus('error', '✕', 'Polling-Fehler');
document.getElementById('btn').disabled = false;
}
}, 1500);
}
function showStatus(type, icon, msg) {
const box = document.getElementById('status-box');
const iconEl = document.getElementById('status-icon');
const msgEl = document.getElementById('status-msg');
box.className = 'status-box show ' + type;
msgEl.textContent = msg;
if (type === 'running') {
iconEl.innerHTML = '<div class="spinner"></div>';
iconEl.className = 'icon';
} else {
iconEl.textContent = icon || '';
iconEl.className = 'icon';
}
}
</script>
</body>
</html>

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
flask>=3.0.0
yt-dlp>=2024.1.0