migration from old repo

This commit is contained in:
2025-09-24 10:51:42 +02:00
parent c534ec7f83
commit 93a77d99b6
8 changed files with 2052 additions and 0 deletions

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# .env file for SoundCloud S3 Uploader
AWS_ACCESS_KEY_ID="DEINE_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="DEIN_SECRET_ACCESS_KEY"
AWS_S3_BUCKET_NAME="dein-s3-bucket-name"
# Cloudflare-R2: wnam, enam, weur, eeur, apac, oc, auto
# AWS: eu-central-1, us-east-1, etc
AWS_REGION="auto"
# NEU: Die Endpoint URL deines S3-kompatiblen Anbieters.
# Lasse dies leer oder kommentiere es aus, wenn du AWS S3 verwendest.
# Beispiele:
# DigitalOcean (Frankfurt): https://fra1.digitaloceanspaces.com
# Wasabi (EU Central 1): https://s3.eu-central-1.wasabisys.com
# MinIO (lokal): http://localhost:9000
S3_ENDPOINT_URL="https://dein-s3-endpoint.example.com"
# WICHTIG: Die öffentliche Basis-URL deines Buckets.
# Diese ist für JEDEN Anbieter (auch AWS) notwendig, um den finalen Link korrekt anzuzeigen.
# Stelle sicher, dass sie mit einem Slash / endet!
# Beispiele:
# AWS: https://<bucket-name>.s3.<region>.amazonaws.com/
# DO Spaces: https://<bucket-name>.<region>.cdn.digitaloceanspaces.com/ (wenn CDN aktiv) oder https://<bucket-name>.<region>.digitaloceanspaces.com/
# Wasabi: https://s3.<region>.wasabisys.com/<bucket-name>/
S3_PUBLIC_URL_BASE="https://dein-bucket-public-url.example.com/"
# Verlauf aktivieren/deaktivieren (true/false)
# Wenn deaktiviert, wird kein Verlauf angezeigt, gespeichert oder geladen.
ENABLE_HISTORY="true"
# Anzahl der Worker-Threads für gleichzeitige Downloads/Uploads.
# WICHTIG: Die Statusanzeige im Frontend funktioniert nur korrekt mit MAX_WORKERS=1.
# Bei Werten > 1 werden Aufträge parallel bearbeitet, aber der Status im Frontend
# kann irreführend sein (zeigt nur den Status des letzten Workers).
MAX_WORKERS="1"
# Pfad zur Cookie-Datei (im Netscape-Format).
# Notwendig für Downloads von Plattformen, die Login erfordern (z.B. private Instagram/Twitter).
# Der Pfad muss aus Sicht des Containers gültig sein (z.B. wenn per Volume gemountet).
# Beispiel: /app/cookies/instagram.txt
# Lasse leer oder kommentiere aus, wenn nicht benötigt.
COOKIE_FILE_PATH=""

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# 1. Basis-Image wählen (Python 3.13 oder neuer empfohlen)
FROM python:3.13.3-slim
# 2. Metadaten (optional)
LABEL maintainer="MrUnknownDE"
LABEL description="Webapp zum Download von SoundCloud/YouTube/TikTok Tracks und Upload zu S3."
# 3. Umgebungsvariablen setzen
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_ENV=production
# ENV GUNICORN_CMD_ARGS="--timeout 120" # Beispiel für zusätzliche Gunicorn Args
# 4. Systemabhängigkeiten installieren (inkl. FFmpeg)
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg ca-certificates && \
rm -rf /var/lib/apt/lists/*
# 5. Arbeitsverzeichnis im Container erstellen und setzen
WORKDIR /app
# 6. Python-Abhängigkeiten installieren
COPY requirements.txt ./
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# 7. Anwendungs-Code in das Arbeitsverzeichnis kopieren
COPY . .
# 8. Port freigeben, auf dem die App lauschen wird
EXPOSE 5000
# 9. Befehl zum Starten der Anwendung mit Gunicorn
# WICHTIG: --workers 1 ist entscheidend für diese Lösung!
# --timeout erhöht, falls Downloads/Uploads lange dauern
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--timeout", "120", "app:app"]

948
app.py Normal file
View File

@@ -0,0 +1,948 @@
# -*- coding: utf-8 -*-
import threading
import os
import sys
import yt_dlp
import boto3
from botocore.exceptions import NoCredentialsError, ClientError
import logging
from dotenv import load_dotenv
import urllib.parse
import json
from datetime import datetime
import random
import string
import re
from flask import Flask, render_template, request, jsonify, Response, copy_current_request_context
import time
# import queue # ALT
import multiprocessing # NEU: Für prozessübergreifende Queue
import math
import uuid
import subprocess # NEU: Für FFmpeg Aufruf
import traceback # NEU: Für detaillierte Fehlermeldungen
# --- Konstanten ---
HISTORY_FILE = "download_history.json"
STATS_FILE = "stats.json"
RANDOM_NAME_LENGTH = 4
MAX_FILENAME_RETRIES = 10
ANSI_ESCAPE_REGEX = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
URL_REGEX = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+')
# NEU: Plattformen expliziter definieren
SUPPORTED_PLATFORMS = ["SoundCloud", "YouTube", "TikTok", "Instagram", "Twitter"]
DEFAULT_PLATFORM = "SoundCloud"
DEFAULT_YT_FORMAT = "mp3"
MP3_BITRATES = ["Best", "256k", "192k", "128k", "64k"]
MP4_QUALITIES = ["Best", "Medium (~720p)", "Low (~480p)"]
DEFAULT_MP3_BITRATE = "192k"
DEFAULT_MP4_QUALITY = "Best"
DOWNLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sc_downloads")
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
JOB_STATUS_TTL_SECONDS = 300 # 5 Minuten Lebenszeit für abgeschlossene Job-Status
# NEU: FFmpeg Kompatibilitäts-Parameter
FFMPEG_COMPAT_ARGS = [
'-c:v', 'libx264', # Video Codec: H.264
'-profile:v', 'main', # Profil: Main (gute Kompatibilität & Qualität)
'-preset', 'fast', # Encoding-Geschwindigkeit (Kompromiss)
'-pix_fmt', 'yuv420p', # Pixelformat (sehr kompatibel)
'-c:a', 'aac', # Audio Codec: AAC (Standard für MP4)
'-b:a', '128k', # Audio Bitrate
'-movflags', '+faststart' # Für Web-Streaming optimieren
]
# --- Konfiguration für Logging ---
log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s') # ThreadName hinzugefügt
log_handler = logging.StreamHandler(sys.stdout)
log_handler.setFormatter(log_formatter)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Verhindere doppelte Handler, falls die App neu geladen wird (z.B. bei Flask Debug)
if not logger.hasHandlers():
logger.addHandler(log_handler)
elif len(logger.handlers) > 1:
for handler in logger.handlers[:-1]:
logger.removeHandler(handler)
# --- Lade Umgebungsvariablen ---
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
env_loaded_successfully = load_dotenv(dotenv_path=dotenv_path)
if env_loaded_successfully: logging.info(f".env Datei gefunden und geladen von: {dotenv_path}")
else: logging.warning(".env Datei nicht gefunden...")
# --- Konfiguration aus .env lesen ---
ENABLE_HISTORY = os.getenv('ENABLE_HISTORY', 'true').lower() == 'true'
try:
MAX_WORKERS = int(os.getenv('MAX_WORKERS', '1'))
if MAX_WORKERS < 1:
MAX_WORKERS = 1
logging.warning("MAX_WORKERS muss mindestens 1 sein, wurde auf 1 gesetzt.")
except ValueError:
MAX_WORKERS = 1
logging.warning("Ungültiger Wert für MAX_WORKERS in .env, verwende Standardwert 1.")
if MAX_WORKERS <= 0: MAX_WORKERS = 1 # Sicherstellen, dass mindestens 1 Worker läuft
logging.info(f"Verlauf aktiviert: {ENABLE_HISTORY}")
logging.info(f"Maximale Worker-Threads (für Hintergrundverarbeitung): {MAX_WORKERS}")
# --- Flask App Initialisierung ---
app = Flask(__name__)
app.secret_key = os.urandom(24)
# --- Globaler Status -> Job-Status Speicher ---
manager = multiprocessing.Manager()
job_statuses = manager.dict()
task_lock = threading.Lock()
# --- Worker Queue (Prozesssicher) ---
task_queue = multiprocessing.Queue()
# --- Hilfsfunktionen (Backend - unverändert) ---
def generate_random_part(length=RANDOM_NAME_LENGTH):
characters = string.ascii_lowercase + string.digits
return ''.join(random.choice(characters) for _ in range(length))
def generate_s3_object_name(extension):
year_part = datetime.now().strftime("%y")
random_part = generate_random_part()
if not extension.startswith('.'): extension = '.' + extension
return f"{year_part}{random_part}{extension.lower()}"
def strip_ansi_codes(text):
return ANSI_ESCAPE_REGEX.sub('', text)
def format_size(size_bytes):
if size_bytes == 0: return "0 B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = int(math.floor(math.log(size_bytes, 1024))) if size_bytes > 0 else 0
p = math.pow(1024, i)
s = round(size_bytes / p, 2) if p > 0 else 0
return f"{s} {size_name[i]}"
# --- Status Update Funktion (leicht angepasst für Manager Dict) ---
def update_status(job_id, message=None, progress=None, log_entry=None, error=None, result_url=None, running=None, status_code=None):
with task_lock:
if job_id not in job_statuses:
logging.warning(f"Versuch, Status für unbekannten Job {job_id} zu aktualisieren.")
return
current_job_status = dict(job_statuses[job_id])
current_job_status["last_update"] = time.time()
if message is not None: current_job_status["message"] = message
if progress is not None: current_job_status["progress"] = max(0.0, min(100.0, float(progress)))
if log_entry is not None:
clean_log = strip_ansi_codes(str(log_entry))
if "logs" not in current_job_status or not isinstance(current_job_status["logs"], list):
current_job_status["logs"] = []
log_list = list(current_job_status["logs"])
log_list.append(f"{datetime.now().strftime('%H:%M:%S')} - {clean_log}")
max_logs = 100
current_job_status["logs"] = log_list[-max_logs:]
if error is not None:
current_job_status["error"] = strip_ansi_codes(str(error))
current_job_status["running"] = False
current_job_status["message"] = f"Fehler: {current_job_status['error']}"
current_job_status["status"] = "error"
logging.error(f"Job Error [{job_id}]: {current_job_status['error']}")
if result_url is not None: current_job_status["result_url"] = result_url
if running is not None:
current_job_status["running"] = bool(running)
if not current_job_status["running"]:
if not current_job_status.get("error"):
if current_job_status.get("status") not in ["error", "queued", "completed"]:
current_job_status["status"] = "completed"
current_job_status["message"] = current_job_status.get("message", "Abgeschlossen!")
if status_code is not None:
current_job_status["status"] = status_code
job_statuses[job_id] = current_job_status
# --- Callback-Erzeuger (unverändert) ---
def create_status_callback(job_id):
def callback(message):
update_status(job_id, log_entry=message, message=message)
return callback
def create_progress_callback(job_id):
def callback(value):
update_status(job_id, progress=value)
return callback
# --- Kernfunktionen ---
def download_track(job_id, url, platform, format_preference, mp3_bitrate, mp4_quality, codec_preference, output_path="."):
track_title = None; final_extension = None
status_callback = create_status_callback(job_id)
progress_callback = create_progress_callback(job_id)
status_callback(f"Starte Download von {platform}...")
progress_callback(0.0)
os.makedirs(output_path, exist_ok=True)
last_reported_progress = -1
def _progress_hook_logic(d):
nonlocal last_reported_progress
if d['status'] == 'downloading':
filename = strip_ansi_codes(d.get('info_dict', {}).get('title', d.get('filename', 'Datei')))
percent_str = strip_ansi_codes(d.get('_percent_str', 'N/A')).strip()
speed_str = strip_ansi_codes(d.get('_speed_str', 'N/A')).strip()
eta_str = strip_ansi_codes(d.get('_eta_str', 'N/A')).strip()
total_bytes_str = strip_ansi_codes(d.get('_total_bytes_str', 'N/A')).strip()
percent_float = None
try:
percent_str_cleaned = percent_str.replace('%','').strip()
percent_float = float(percent_str_cleaned)
if progress_callback: progress_callback(percent_float)
except ValueError:
percent_float = 0.0
current_prog = int(percent_float)
if current_prog != -1 and (current_prog == 0 or current_prog == 100 or abs(current_prog - last_reported_progress) >= 5):
status_msg = f"Download: {filename} - {percent_str} von {total_bytes_str} @ {speed_str}, ETA: {eta_str}"
if status_callback: status_callback(status_msg)
last_reported_progress = current_prog
elif d['status'] == 'finished':
filename = strip_ansi_codes(d.get('filename', 'Datei'))
if status_callback: status_callback(f"Download von {os.path.basename(filename)} beendet, prüfe Nachbearbeitung...")
last_reported_progress = -1
elif d['status'] == 'error':
filename = strip_ansi_codes(d.get('filename', 'Datei'))
if status_callback: status_callback(f"Fehler beim Download von {os.path.basename(filename)}.")
last_reported_progress = -1
needs_ffmpeg_conversion = (codec_preference == 'h264' and platform in ["YouTube", "TikTok", "Instagram", "Twitter"])
if needs_ffmpeg_conversion:
try:
ffmpeg_check = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, check=True)
logging.info(f"[{job_id}] FFmpeg gefunden, H.264 Konvertierung ist möglich.")
except (FileNotFoundError, subprocess.CalledProcessError) as ffmpeg_err:
error_msg = "Fehler: FFmpeg nicht gefunden oder nicht ausführbar. H.264 Konvertierung nicht möglich."
status_callback(error_msg)
logging.error(f"[{job_id}] {error_msg} Details: {ffmpeg_err}")
update_status(job_id, error=error_msg, running=False)
return None, None, None
ydl_opts = {
'outtmpl': os.path.join(output_path, '%(title)s.%(ext)s'),
'noplaylist': True, 'quiet': True, 'noprogress': True,
'ffmpeg_location': None, 'logger': logging.getLogger('yt_dlp'),
'progress_hooks': [_progress_hook_logic],
'restrictfilenames': True, 'writethumbnail': False, 'no_color': True,
'postprocessors': [],
'cookiefile': os.getenv('COOKIE_FILE_PATH') or None,
}
if ydl_opts['cookiefile']: logging.info(f"[{job_id}] Verwende Cookie-Datei: {ydl_opts['cookiefile']}")
else: logging.info(f"[{job_id}] Keine Cookie-Datei konfiguriert.")
# --- Format-Optionen ---
if platform == "SoundCloud":
final_extension = '.mp3'; ydl_opts['format'] = 'bestaudio/best'
postprocessor_opts = {'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3'}
if mp3_bitrate != "Best": postprocessor_opts['preferredquality'] = mp3_bitrate.replace('k', ''); logging.info(f"[{job_id}] MP3-Qualität angefordert: {mp3_bitrate}")
else: logging.info(f"[{job_id}] MP3-Qualität angefordert: Best")
ydl_opts['postprocessors'] = [postprocessor_opts]; ydl_opts['outtmpl'] = os.path.join(output_path, '%(title)s.mp3')
elif platform == "YouTube" and format_preference == 'mp3':
final_extension = '.mp3'; ydl_opts['format'] = 'bestaudio/best'
postprocessor_opts = {'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3'}
if mp3_bitrate != "Best": postprocessor_opts['preferredquality'] = mp3_bitrate.replace('k', ''); logging.info(f"[{job_id}] MP3-Qualität angefordert: {mp3_bitrate}")
else: logging.info(f"[{job_id}] MP3-Qualität angefordert: Best")
ydl_opts['postprocessors'] = [postprocessor_opts]; ydl_opts['outtmpl'] = os.path.join(output_path, '%(title)s.mp3')
elif platform == "YouTube" and format_preference == 'mp4':
final_extension = '.mp4'
if mp4_quality == "Best": ydl_opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'; logging.info(f"[{job_id}] MP4-Qualität angefordert: Best")
elif "Medium" in mp4_quality: ydl_opts['format'] = 'bestvideo[height<=?720][ext=mp4]+bestaudio[ext=m4a]/best[height<=?720][ext=mp4]/best[height<=?720]'; logging.info(f"[{job_id}] MP4-Qualität angefordert: Medium (~720p)")
elif "Low" in mp4_quality: ydl_opts['format'] = 'bestvideo[height<=?480][ext=mp4]+bestaudio[ext=m4a]/best[height<=?480][ext=mp4]/best[height<=?480]'; logging.info(f"[{job_id}] MP4-Qualität angefordert: Low (~480p)")
else: ydl_opts['format'] = 'best[ext=mp4]/best'; logging.warning(f"[{job_id}] Unbekannte MP4-Qualität '{mp4_quality}', verwende 'best'.")
if codec_preference == 'h264': logging.info(f"[{job_id}] H.264 Konvertierung für YouTube MP4 angefordert (wird nach Download durchgeführt).")
else: logging.info(f"[{job_id}] Original-Codec für YouTube MP4 beibehalten.")
elif platform == "TikTok":
final_extension = '.mp4'
ydl_opts['format'] = 'bestvideo[ext=mp4]+bestaudio/best[ext=mp4]/best'
logging.info(f"[{job_id}] TikTok Download angefordert (MP4 Best)")
if codec_preference == 'h264': logging.info(f"[{job_id}] H.264 Konvertierung für TikTok angefordert (wird nach Download durchgeführt).")
else: logging.info(f"[{job_id}] Original-Codec für TikTok beibehalten.")
elif platform == "Instagram":
final_extension = '.mp4'
ydl_opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]/best[ext=mp4]/best'
logging.info(f"[{job_id}] Instagram Reel Download angefordert (MP4 Best)")
if codec_preference == 'h264': logging.info(f"[{job_id}] H.264 Konvertierung für Instagram angefordert (wird nach Download durchgeführt).")
else: logging.info(f"[{job_id}] Original-Codec für Instagram beibehalten.")
elif platform == "Twitter":
final_extension = '.mp4'
ydl_opts['format'] = 'bestvideo[ext=mp4]+bestaudio/bestvideo+bestaudio/best[ext=mp4]/best'
logging.info(f"[{job_id}] Twitter Video Download angefordert (Format: {ydl_opts['format']})")
if codec_preference == 'h264': logging.info(f"[{job_id}] H.264 Konvertierung für Twitter angefordert (wird nach Download durchgeführt).")
else: logging.info(f"[{job_id}] Original-Codec für Twitter beibehalten.")
else:
status_callback(f"Fehler: Ungültige Kombination: {platform}/{format_preference}")
update_status(job_id, error=f"Ungültige Kombination: {platform}/{format_preference}", running=False)
return None, None, None
downloaded_file_path = None; actual_downloaded_filename = None
original_download_path = None
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
status_callback("Extrahiere Informationen...")
info_dict = ydl.extract_info(url, download=False); track_title = info_dict.get('title', 'Unbekannter Titel')
if platform in ["Instagram", "Twitter"] and not track_title:
track_title = f"{platform}_Video_{info_dict.get('id', generate_random_part(6))}"
logging.info(f"[{job_id}] Kein Titel gefunden, verwende generierten Titel: {track_title}")
elif platform in ["Instagram", "Twitter"]:
track_title = URL_REGEX.sub('', track_title).strip()
if not track_title: track_title = f"{platform}_Video_{info_dict.get('id', generate_random_part(6))}"
sanitized_title_for_filename = ydl.prepare_filename(info_dict)
base_name_from_title = os.path.splitext(os.path.basename(sanitized_title_for_filename))[0]
if platform == "SoundCloud" or (platform == "YouTube" and format_preference == 'mp3'): final_extension = '.mp3'
elif platform in ["YouTube", "TikTok", "Instagram", "Twitter"]: final_extension = '.mp4'
else: final_extension = '.mp4' if format_preference == 'mp4' else '.mp3'; logging.warning(f"[{job_id}] Unerwarteter Fall bei Endungsbestimmung, verwende {final_extension}")
status_callback(f"Downloade '{track_title}'...")
ydl.download([url])
downloaded_file_path = None
possible_extensions = [final_extension]
if final_extension == '.mp4': possible_extensions.extend(['.webm', '.mkv', '.mov', '.avi'])
found_file = None; latest_mtime = 0
for f in os.listdir(output_path):
file_base, file_ext = os.path.splitext(f)
if file_base.lower().startswith(base_name_from_title.lower()) and file_ext.lower() in possible_extensions:
current_path = os.path.join(output_path, f)
current_mtime = os.path.getmtime(current_path)
if current_mtime > latest_mtime:
found_file = current_path; latest_mtime = current_mtime
if found_file:
downloaded_file_path = found_file; original_download_path = downloaded_file_path
actual_downloaded_filename = os.path.basename(downloaded_file_path)
status_callback(f"Download abgeschlossen: {actual_downloaded_filename}")
logging.info(f"[{job_id}] Datei heruntergeladen: {downloaded_file_path}")
else:
error_msg = f"Fehler: Konnte heruntergeladene Datei für '{track_title}' nicht im Ordner '{output_path}' finden."
status_callback(error_msg); logging.error(f"[{job_id}] {error_msg}")
update_status(job_id, error=error_msg, running=False)
return None, None, None
if needs_ffmpeg_conversion and downloaded_file_path:
status_callback("Starte H.264 Kompatibilitäts-Konvertierung (kann dauern)...")
logging.info(f"[{job_id}] Starte explizite FFmpeg H.264 Konvertierung für: {downloaded_file_path}")
base_name, _ = os.path.splitext(downloaded_file_path)
converted_file_path = f"{base_name}_h264.mp4"; final_extension = '.mp4'
ffmpeg_command = ['ffmpeg', '-i', downloaded_file_path, '-y'] + FFMPEG_COMPAT_ARGS + [converted_file_path]
logging.info(f"[{job_id}] FFmpeg Befehl: {' '.join(ffmpeg_command)}")
try:
process = subprocess.run(ffmpeg_command, capture_output=True, text=True, check=True)
logging.info(f"[{job_id}] FFmpeg Konvertierung erfolgreich abgeschlossen.")
logging.debug(f"[{job_id}] FFmpeg stderr:\n{process.stderr}")
status_callback("H.264 Konvertierung erfolgreich.")
downloaded_file_path = converted_file_path
actual_downloaded_filename = os.path.basename(downloaded_file_path)
if original_download_path and os.path.exists(original_download_path) and original_download_path != downloaded_file_path:
try: os.remove(original_download_path); logging.info(f"[{job_id}] Ursprüngliche Datei '{os.path.basename(original_download_path)}' nach Konvertierung gelöscht.")
except OSError as del_err: logging.warning(f"[{job_id}] Konnte ursprüngliche Datei '{os.path.basename(original_download_path)}' nicht löschen: {del_err}")
except subprocess.CalledProcessError as e:
error_msg = f"Fehler bei der H.264 Konvertierung mit FFmpeg."
logging.error(f"[{job_id}] {error_msg} Rückgabecode: {e.returncode}")
logging.error(f"[{job_id}] FFmpeg stderr:\n{e.stderr}")
status_callback(f"{error_msg} Details im Log.")
update_status(job_id, error=error_msg, running=False)
# Aufräumen: Lösche die (möglicherweise unvollständige) konvertierte Datei
if os.path.exists(converted_file_path):
try:
os.remove(converted_file_path)
except OSError:
pass
return None, None, None
except Exception as e:
error_msg = f"Allgemeiner Fehler während der FFmpeg Konvertierung: {e}"
logging.exception(f"[{job_id}] {error_msg}") # Log traceback
status_callback(error_msg)
update_status(job_id, error=error_msg, running=False)
# --- KORREKTUR HIER ---
# Aufräumen
if os.path.exists(converted_file_path):
try: # Korrekt eingerückt
os.remove(converted_file_path)
except OSError: # Korrekt eingerückt
pass # Korrekt eingerückt
# --- ENDE KORREKTUR ---
return None, None, None
except yt_dlp.utils.DownloadError as e:
err_str = strip_ansi_codes(str(e))
if "Unsupported URL" in err_str: error_msg = "Download-Fehler: Nicht unterstützte URL."
elif "Video unavailable" in err_str: error_msg = "Download-Fehler: Video nicht verfügbar."
elif "Private video" in err_str: error_msg = "Download-Fehler: Video ist privat."
elif "HTTP Error 403" in err_str: error_msg = "Download-Fehler: Zugriff verweigert (403)."
elif "HTTP Error 404" in err_str: error_msg = "Download-Fehler: Nicht gefunden (404)."
elif "Login is required" in err_str or "age-restricted" in err_str:
error_msg = "Download-Fehler: Inhalt erfordert Login oder ist altersbeschränkt."
if not ydl_opts.get('cookiefile'): error_msg += " (Cookie-Datei nicht konfiguriert)"
else: error_msg += " (Cookie-Datei möglicherweise ungültig/abgelaufen)"
elif "InstagramLoginRequiredError" in err_str:
error_msg = "Download-Fehler: Instagram erfordert Login für diesen Inhalt."
if not ydl_opts.get('cookiefile'): error_msg += " (Cookie-Datei nicht konfiguriert)"
elif "TwitterLoginRequiredError" in err_str:
error_msg = "Download-Fehler: Twitter/X erfordert Login für diesen Inhalt."
if not ydl_opts.get('cookiefile'): error_msg += " (Cookie-Datei nicht konfiguriert)"
else: error_msg = f"Download-Fehler: {err_str[:200]}"
status_callback(error_msg); logging.error(f"[{job_id}] Download-Fehler für {url}: {err_str}", exc_info=False)
update_status(job_id, error=error_msg, running=False)
return None, None, None
except Exception as e:
error_msg = f"Allgemeiner Fehler beim Download/Vorbereitung: {strip_ansi_codes(str(e))}"
status_callback(error_msg); logging.exception(f"[{job_id}] {error_msg}") # Log traceback
update_status(job_id, error=error_msg, running=False)
return None, None, None
if downloaded_file_path:
_, final_extension_from_path = os.path.splitext(downloaded_file_path)
final_extension = final_extension_from_path.lower()
return downloaded_file_path, track_title, final_extension
# --- upload_to_s3 mit verbessertem Logging ---
def upload_to_s3(job_id, file_path, object_name, file_extension, bucket_name, aws_access_key_id, aws_secret_access_key, region_name, endpoint_url=None):
status_callback = create_status_callback(job_id)
logging.info(f"[{job_id}] Starte upload_to_s3 für Datei: {file_path}")
if not file_path or not os.path.exists(file_path):
error_msg = f"Upload-Fehler: Lokale Quelldatei nicht gefunden: '{file_path}'";
status_callback(error_msg); logging.error(f"[{job_id}] {error_msg}")
update_status(job_id, error=error_msg, running=False)
return False
content_type = 'application/octet-stream'; lowered_extension = file_extension.lower()
if lowered_extension == '.mp4': content_type = 'video/mp4'
elif lowered_extension == '.mp3': content_type = 'audio/mpeg'
elif lowered_extension in ['.mov']: content_type = 'video/quicktime'
elif lowered_extension in ['.avi']: content_type = 'video/x-msvideo'
elif lowered_extension in ['.webm']: content_type = 'video/webm'
provider = "AWS S3" if not endpoint_url else "S3-kompatiblen Speicher"
status_callback(f"Starte Upload von '{os.path.basename(file_path)}' ({content_type}) zu {provider} Bucket '{bucket_name}' als '{object_name}'...")
logging.info(f"[{job_id}] Upload Parameter: Bucket={bucket_name}, Key={object_name}, ContentType={content_type}, Endpoint={endpoint_url or 'Default'}")
s3_client_args = { 'aws_access_key_id': aws_access_key_id, 'aws_secret_access_key': aws_secret_access_key, 'region_name': region_name }
if endpoint_url: s3_client_args['endpoint_url'] = endpoint_url
try:
s3_client = boto3.client('s3', **s3_client_args)
extra_args = {'ContentType': content_type}
logging.info(f"[{job_id}] Rufe s3_client.upload_file auf...")
response = s3_client.upload_file(file_path, bucket_name, object_name, ExtraArgs=extra_args)
success_msg = f"Upload erfolgreich abgeschlossen!";
status_callback(success_msg); logging.info(f"[{job_id}] {success_msg}")
return True
except NoCredentialsError:
error_msg = "S3 Upload Fehler: AWS Credentials nicht gefunden oder ungültig.";
status_callback(error_msg); logging.error(f"[{job_id}] {error_msg}")
update_status(job_id, error=error_msg, running=False)
return False
except ClientError as e:
error_code = e.response.get('Error', {}).get('Code', 'Unknown')
error_message = e.response.get('Error', {}).get('Message', 'Keine Details')
full_error = strip_ansi_codes(str(e))
error_msg = f"S3 Client Fehler beim Upload (Code: {error_code}): {error_message}";
status_callback(error_msg); logging.error(f"[{job_id}] {error_msg} - Volle Fehlermeldung: {full_error}", exc_info=False)
update_status(job_id, error=error_msg, running=False)
return False
except Exception as e:
error_msg = f"Allgemeiner Fehler beim S3 Upload: {strip_ansi_codes(str(e))}";
status_callback(error_msg);
logging.error(f"[{job_id}] {error_msg}", exc_info=True)
update_status(job_id, error=error_msg, running=False)
return False
# --- History Funktionen (Backend - unverändert) ---
def load_history():
if not ENABLE_HISTORY: return []
try:
if os.path.exists(HISTORY_FILE):
if os.path.getsize(HISTORY_FILE) == 0: logging.warning(f"{HISTORY_FILE} ist leer."); return []
with open(HISTORY_FILE, 'r', encoding='utf-8') as f: history = json.load(f)
return history if isinstance(history, list) else []
else: return []
except json.JSONDecodeError as e: logging.error(f"Fehler Laden History (JSON ungültig): {e}."); return []
except Exception as e: logging.error(f"Fehler Laden History (Allgemein): {e}"); return []
def save_history(history_data):
if not ENABLE_HISTORY: return True
try:
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history_data, f, indent=4, ensure_ascii=False)
logging.info(f"History gespeichert: {HISTORY_FILE}")
return True
except Exception as e: logging.error(f"Fehler Speichern History: {e}"); return False
def add_history_entry(platform, title, source_url, s3_url):
if not ENABLE_HISTORY: return True
history = load_history()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry = {"timestamp": timestamp, "platform": platform, "title": title, "source_url": source_url, "s3_url": s3_url}
history.insert(0, entry)
return save_history(history)
def clear_history_file():
if not ENABLE_HISTORY: return True
try:
if os.path.exists(HISTORY_FILE): os.remove(HISTORY_FILE); logging.info("History-Datei gelöscht.")
return True
except Exception as e: logging.error(f"Fehler Löschen History-Datei: {e}"); return False
# --- Statistik Funktionen (Backend - unverändert) ---
def load_stats():
default_stats = {'total_jobs': 0, 'successful_jobs': 0, 'total_duration_seconds': 0.0, 'total_size_bytes': 0}
try:
if os.path.exists(STATS_FILE):
if os.path.getsize(STATS_FILE) == 0: logging.warning(f"{STATS_FILE} ist leer."); return default_stats
with open(STATS_FILE, 'r', encoding='utf-8') as f: stats = json.load(f)
for key, default_value in default_stats.items():
if key not in stats: stats[key] = default_value
return stats
else: return default_stats
except json.JSONDecodeError as e: logging.error(f"Fehler Laden Statistik (JSON ungültig): {e}."); return default_stats
except Exception as e: logging.error(f"Fehler Laden Statistik (Allgemein): {e}"); return default_stats
def save_stats(stats_data):
try:
with open(STATS_FILE, 'w', encoding='utf-8') as f: json.dump(stats_data, f, indent=4, ensure_ascii=False)
return True
except Exception as e: logging.error(f"Fehler Speichern Statistik: {e}"); return False
def update_stats(duration_seconds, file_size_bytes, success):
stats = load_stats()
stats['total_jobs'] = stats.get('total_jobs', 0) + 1
if success:
stats['successful_jobs'] = stats.get('successful_jobs', 0) + 1
stats['total_duration_seconds'] = stats.get('total_duration_seconds', 0.0) + duration_seconds
stats['total_size_bytes'] = stats.get('total_size_bytes', 0) + file_size_bytes
save_stats(stats)
# --- Haupt-Verarbeitungsfunktion mit verbessertem Logging/Error Handling ---
def run_download_upload_task(job_id, url, platform, format_preference, mp3_bitrate, mp4_quality,
codec_preference, access_key, secret_key, bucket_name, region_name, endpoint_url):
start_time = time.time()
downloaded_file = None; track_title = None; file_extension = None
s3_object_name = None; public_url = None; s3_client = None
process_ok = False # Wird nur True, wenn *alles* klappt
final_error_message = None
file_size_bytes = 0
update_status(job_id, message="Starte Verarbeitung...", running=True, status_code="running")
logging.info(f"[{job_id}] Worker startet Task für URL: {url}")
try:
# --- Download Phase ---
logging.info(f"[{job_id}] Starte Download-Phase...")
downloaded_file, track_title, file_extension = download_track(
job_id, url, platform, format_preference, mp3_bitrate, mp4_quality, codec_preference, DOWNLOAD_DIR)
with task_lock:
job_failed_during_download = job_statuses.get(job_id, {}).get("error") is not None
if job_failed_during_download:
logging.error(f"[{job_id}] Fehler während Download/Konvertierung erkannt. Breche Verarbeitung ab.")
elif not (downloaded_file and track_title and file_extension):
final_error_message = "Download/Konvertierung fehlgeschlagen (unerwarteter Zustand)."
logging.error(f"[{job_id}] {final_error_message}")
update_status(job_id, error=final_error_message, running=False)
else:
# --- Upload Phase (nur wenn Download OK) ---
logging.info(f"[{job_id}] Download erfolgreich: {downloaded_file}. Starte Upload-Phase...")
try:
if os.path.exists(downloaded_file):
file_size_bytes = os.path.getsize(downloaded_file)
logging.info(f"[{job_id}] Dateigröße: {format_size(file_size_bytes)}")
else:
logging.warning(f"[{job_id}] Heruntergeladene Datei {downloaded_file} existiert nicht mehr vor dem Upload?")
except OSError as size_e:
logging.warning(f"[{job_id}] Konnte Dateigröße nicht ermitteln: {size_e}")
update_status(job_id, message="Verbinde mit S3 Speicher...")
s3_client_args = { 'aws_access_key_id': access_key, 'aws_secret_access_key': secret_key, 'region_name': region_name }
if endpoint_url: s3_client_args['endpoint_url'] = endpoint_url
try:
s3_client = boto3.client('s3', **s3_client_args)
logging.info(f"[{job_id}] S3 Client erfolgreich erstellt.")
except Exception as client_e:
final_error_message = f"Fehler bei S3 Client Erstellung: {client_e}"
logging.error(f"[{job_id}] {final_error_message}", exc_info=True)
update_status(job_id, error=final_error_message, running=False)
raise
update_status(job_id, message=f"Generiere eindeutigen S3 Dateinamen mit Endung '{file_extension}'...")
unique_name_found = False
for attempt in range(MAX_FILENAME_RETRIES):
candidate_name = generate_s3_object_name(file_extension)
logging.debug(f"[{job_id}] Prüfe S3 Name (Versuch {attempt+1}/{MAX_FILENAME_RETRIES}): {candidate_name}")
try:
s3_client.head_object(Bucket=bucket_name, Key=candidate_name)
logging.warning(f"[{job_id}] S3 Name '{candidate_name}' existiert bereits.")
except ClientError as e:
if e.response['Error']['Code'] in ['404', 'NoSuchKey', 'NotFound']:
s3_object_name = candidate_name; unique_name_found = True
logging.info(f"[{job_id}] Eindeutiger S3 Name gefunden: {s3_object_name}")
break
else:
final_error_message = f"S3 Fehler bei Namensprüfung ({candidate_name}): {e}"
logging.error(f"[{job_id}] {final_error_message}", exc_info=True)
update_status(job_id, error=final_error_message, running=False)
raise
except Exception as head_e:
final_error_message = f"Allgemeiner Fehler bei S3 Namensprüfung ({candidate_name}): {head_e}"
logging.error(f"[{job_id}] {final_error_message}", exc_info=True)
update_status(job_id, error=final_error_message, running=False)
raise
if not unique_name_found:
final_error_message = f"Konnte keinen eindeutigen S3 Namen nach {MAX_FILENAME_RETRIES} Versuchen finden."
logging.error(f"[{job_id}] {final_error_message}")
update_status(job_id, error=final_error_message, running=False)
else:
update_status(job_id, message="Starte Upload...", progress=50)
logging.info(f"[{job_id}] Rufe upload_to_s3 auf für Datei '{downloaded_file}' nach '{bucket_name}/{s3_object_name}'")
upload_success = upload_to_s3(
job_id, downloaded_file, s3_object_name, file_extension, bucket_name,
access_key, secret_key, region_name, endpoint_url
)
logging.info(f"[{job_id}] upload_to_s3 Aufruf beendet. Erfolg: {upload_success}")
with task_lock:
job_failed_during_upload = job_statuses.get(job_id, {}).get("error") is not None
if job_failed_during_upload:
logging.error(f"[{job_id}] Fehler während Upload erkannt. Breche Verarbeitung ab.")
elif not upload_success:
final_error_message = "Upload fehlgeschlagen (unerwarteter Zustand)."
logging.error(f"[{job_id}] {final_error_message}")
update_status(job_id, error=final_error_message, running=False)
else:
logging.info(f"[{job_id}] Upload erfolgreich.")
update_status(job_id, message="Upload erfolgreich!", progress=100)
process_ok = True
final_s3_url_for_history = f"s3://{bucket_name}/{s3_object_name}"
s3_public_url_base = os.getenv('S3_PUBLIC_URL_BASE')
if s3_public_url_base:
safe_object_name = urllib.parse.quote(s3_object_name)
public_url = s3_public_url_base.rstrip('/') + '/' + safe_object_name
update_status(job_id, result_url=public_url, message="Abgeschlossen!")
final_s3_url_for_history = public_url
logging.info(f"[{job_id}] Datei öffentlich erreichbar unter: {public_url}")
else:
update_status(job_id, message="Abgeschlossen! (Keine Public URL Base konfiguriert)")
logging.warning(f"[{job_id}] Öffentliche URL kann nicht angezeigt werden (S3_PUBLIC_URL_BASE fehlt).")
if not add_history_entry(platform, track_title, url, final_s3_url_for_history):
logging.warning(f"[{job_id}] Konnte Eintrag nicht zur History hinzufügen.")
update_status(job_id, log_entry="WARNUNG: Konnte Eintrag nicht zur History hinzufügen.")
except Exception as e:
logging.exception(f"[{job_id}] Unerwarteter Fehler im Hauptverarbeitungsblock für URL {url}:")
final_error_message = f"Unerwarteter Verarbeitungsfehler: {strip_ansi_codes(str(e))}"
try:
with task_lock:
if job_id in job_statuses and not job_statuses[job_id].get("error"):
update_status(job_id, error=final_error_message, running=False)
except Exception as inner_e:
logging.error(f"[{job_id}] Kritischer Fehler: Konnte Fehlerstatus nach Hauptfehler nicht setzen: {inner_e}")
process_ok = False
finally:
end_time = time.time()
duration = end_time - start_time
final_status_to_set = None
job_success_status = 'FEHLER'
try:
with task_lock:
# Stelle sicher, dass der Job noch existiert, bevor darauf zugegriffen wird
if job_id in job_statuses:
current_job_status = job_statuses.get(job_id, {})
if current_job_status.get("error"):
final_status_to_set = "error"
job_success_status = 'FEHLER'
process_ok = False
elif process_ok:
final_status_to_set = "completed"
job_success_status = 'OK'
else:
final_status_to_set = "error"
job_success_status = 'FEHLER'
if not current_job_status.get("error"):
logging.warning(f"[{job_id}] Prozess nicht erfolgreich, aber kein expliziter Fehler gesetzt. Setze Status auf 'error'.")
error_msg_fallback = "Verarbeitung fehlgeschlagen (Grund unklar)."
current_job_status_dict = dict(current_job_status)
current_job_status_dict["error"] = error_msg_fallback
current_job_status_dict["message"] = f"Fehler: {error_msg_fallback}"
current_job_status_dict["status"] = "error"
current_job_status_dict["running"] = False
job_statuses[job_id] = current_job_status_dict
else:
logging.warning(f"[{job_id}] Job nicht mehr in job_statuses im finally-Block.")
# Kein Status kann mehr gesetzt werden
# Setze finalen Status und running=False nur, wenn Job noch existiert
if job_id in job_statuses:
update_status(job_id, status_code=final_status_to_set, running=False)
except Exception as final_status_e:
logging.exception(f"[{job_id}] Fehler beim Setzen des finalen Job-Status:")
try:
if job_id in job_statuses: update_status(job_id, running=False)
except: pass
logging.info(f"Worker-Task für Job {job_id} (URL {url}) beendet. Status: {job_success_status}, Dauer: {duration:.2f}s")
try:
actual_file_size = file_size_bytes if process_ok else 0
update_stats(duration, actual_file_size, process_ok)
except Exception as stats_e:
logging.error(f"[{job_id}] Fehler beim Aktualisieren der Statistik: {stats_e}")
if downloaded_file and os.path.exists(downloaded_file):
try:
logging.info(f"[{job_id}] Versuche, lokale Datei zu löschen: {downloaded_file}")
os.remove(downloaded_file)
logging.info(f"[{job_id}] Temporäre lokale Datei '{os.path.basename(downloaded_file)}' erfolgreich gelöscht.")
if process_ok and job_id in job_statuses:
try: update_status(job_id, log_entry=f"Lokale Datei '{os.path.basename(downloaded_file)}' aufgeräumt.")
except: pass
except OSError as e:
logging.error(f"[{job_id}] Fehler beim Löschen der temporären Datei '{os.path.basename(downloaded_file)}': {e}")
if job_id in job_statuses:
try: update_status(job_id, log_entry=f"WARNUNG: Lokale Datei nicht gelöscht: {e}")
except: pass
except Exception as cleanup_e:
logging.exception(f"[{job_id}] Unerwarteter Fehler beim Aufräumen der Datei {downloaded_file}:")
if job_id in job_statuses:
try: update_status(job_id, log_entry=f"WARNUNG: Fehler beim Datei-Cleanup: {cleanup_e}")
except: pass
# --- Worker Thread Funktion (unverändert) ---
def worker_thread_target():
logging.info(f"Worker-Thread {threading.current_thread().name} gestartet und wartet auf Tasks...")
while True:
task_data = None
current_job_id = None
try:
task_data = task_queue.get()
current_job_id = task_data[0]
task_args = task_data[1:]
logging.info(f"Worker {threading.current_thread().name} holt neuen Task [{current_job_id}] aus der Queue für URL: {task_args[0][:50]}...")
run_download_upload_task(current_job_id, *task_args)
logging.info(f"Worker {threading.current_thread().name} hat Task [{current_job_id}] beendet.")
except Exception as e:
logging.exception(f"Schwerwiegender Fehler im Worker-Thread {threading.current_thread().name} für Job {current_job_id}:")
try:
if current_job_id:
error_msg = f"Schwerer Worker-Fehler: {e}"
with task_lock:
if current_job_id in job_statuses and not job_statuses[current_job_id].get("error"):
update_status(current_job_id, error=error_msg, running=False, status_code="error")
elif current_job_id in job_statuses:
update_status(current_job_id, running=False)
else:
logging.error("Konnte Job-Status nach schwerem Worker-Fehler nicht aktualisieren (keine Job-ID).")
except Exception as inner_e:
logging.error(f"Konnte Job-Status nach schwerem Worker-Fehler nicht aktualisieren: {inner_e}")
time.sleep(5)
# --- Flask Routen ---
@app.route('/')
def index():
global job_statuses
if not isinstance(job_statuses, type(manager.dict())):
job_statuses = manager.dict()
logging.warning("job_statuses wurde neu initialisiert (wahrscheinlich nach Reload).")
return render_template('index.html', history_enabled=ENABLE_HISTORY)
@app.route('/start_download', methods=['POST'])
def start_download():
# (Logik unverändert)
url = request.form.get('url'); platform = request.form.get('platform', DEFAULT_PLATFORM)
yt_format = request.form.get('yt_format', DEFAULT_YT_FORMAT); mp3_bitrate = request.form.get('mp3_bitrate', DEFAULT_MP3_BITRATE)
mp4_quality = request.form.get('mp4_quality', DEFAULT_MP4_QUALITY)
codec_preference = request.form.get('codec_preference', 'original')
is_valid_url = False
if url and url.startswith(("http://", "https://")):
parsed_url = urllib.parse.urlparse(url)
domain = parsed_url.netloc.lower()
path = parsed_url.path.lower()
if platform == "SoundCloud" and "soundcloud.com" in domain: is_valid_url = True
elif platform == "YouTube" and ("youtube.com" in domain or "youtu.be" in domain): is_valid_url = True
elif platform == "TikTok" and "tiktok.com" in domain: is_valid_url = True
elif platform == "Instagram" and "instagram.com" in domain and ("/reel/" in path or "/p/" in path): is_valid_url = True
elif platform == "Twitter" and ("twitter.com" in domain or "x.com" in domain) and "/status/" in path: is_valid_url = True
elif platform not in SUPPORTED_PLATFORMS:
logging.warning(f"Unbekannte Plattform '{platform}' angegeben, versuche trotzdem mit URL '{url}'")
is_valid_url = True
if not is_valid_url:
error_msg = f"Ungültige URL für {platform}."
if platform == "Instagram": error_msg += " Stelle sicher, dass es ein Reel- oder Post-Link ist (enthält /reel/ oder /p/)."
if platform == "Twitter": error_msg += " Stelle sicher, dass es ein Tweet-Link ist (enthält /status/)."
return jsonify({"error": error_msg}), 400
if ENABLE_HISTORY:
history = load_history()
for entry in history:
entry_url = entry.get('source_url') or entry.get('soundcloud_url')
if entry_url == url:
entry_platform = entry.get('platform', 'Unbekannt')
return jsonify({"error": f"Dieser Link ({entry_platform}) wurde bereits verarbeitet (Verlauf aktiv)."}), 400
access_key = os.getenv('AWS_ACCESS_KEY_ID'); secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
bucket_name = os.getenv('AWS_S3_BUCKET_NAME'); region_name = os.getenv('AWS_REGION')
endpoint_url = os.getenv('S3_ENDPOINT_URL')
if not (access_key and secret_key and bucket_name): return jsonify({"error": "S3 Konfiguration in .env unvollständig."}), 500
job_id = str(uuid.uuid4())
task_args = (url, platform, yt_format, mp3_bitrate, mp4_quality, codec_preference,
access_key, secret_key, bucket_name, region_name, endpoint_url)
with task_lock:
job_statuses[job_id] = {
"running": False, "message": "In Warteschlange...", "progress": 0.0,
"logs": [f"{datetime.now().strftime('%H:%M:%S')} - Auftrag eingereiht."],
"error": None, "result_url": None, "start_time": time.time(),
"last_update": time.time(), "status": "queued"
}
task_queue.put((job_id,) + task_args)
logging.info(f"Neuer Task [{job_id}] zur Queue hinzugefügt für {url}.")
return jsonify({"message": f"Auftrag eingereiht.", "job_id": job_id}), 202
@app.route('/status')
def get_status():
job_id = request.args.get('job_id')
if not job_id:
return jsonify({"error": "Job ID fehlt.", "running": False, "status": "error"}), 400
with task_lock:
if job_id not in job_statuses:
return jsonify({"error": "Job nicht gefunden oder bereits aufgeräumt.", "running": False, "status": "not_found"}), 404
current_status_copy = dict(job_statuses[job_id])
if current_status_copy.get("status") == "queued":
position = 1
total_queued = 0
current_job_start_time = current_status_copy.get("start_time", 0)
all_job_ids = list(job_statuses.keys())
for other_job_id in all_job_ids:
other_status = job_statuses.get(other_job_id)
if other_status and other_status.get("status") == "queued":
total_queued += 1
if other_job_id != job_id and other_status.get("start_time", 0) < current_job_start_time:
position += 1
current_status_copy["position"] = position
current_status_copy["total_queued"] = total_queued
current_status_copy.pop("queue_size", None)
if "logs" in current_status_copy and not isinstance(current_status_copy["logs"], list):
current_status_copy["logs"] = list(current_status_copy["logs"])
return jsonify(current_status_copy)
@app.route('/history')
def get_history():
history = load_history()
return jsonify(history)
@app.route('/clear_history', methods=['POST'])
def clear_history_route():
if clear_history_file():
return jsonify({"message": "Verlauf gelöscht (falls aktiviert)."}), 200
else:
return jsonify({"error": "Fehler beim Löschen des Verlaufs."}), 500
@app.route('/stats')
def get_stats():
stats_data = load_stats()
avg_duration = 0.0
if stats_data.get('successful_jobs', 0) > 0:
avg_duration = stats_data.get('total_duration_seconds', 0.0) / stats_data['successful_jobs']
formatted_stats = {
"total_jobs": stats_data.get('total_jobs', 0),
"successful_jobs": stats_data.get('successful_jobs', 0),
"average_duration_seconds": round(avg_duration, 2),
"total_size_formatted": format_size(stats_data.get('total_size_bytes', 0))
}
return jsonify(formatted_stats)
# --- Cleanup Funktion (leicht angepasst für Manager Dict) ---
def cleanup_old_jobs():
logging.info("Job Status Cleanup Thread gestartet.")
while True:
time.sleep(60)
now = time.time()
jobs_to_remove = []
try:
current_job_ids = list(job_statuses.keys())
for job_id in current_job_ids:
status = job_statuses.get(job_id)
if not status: continue
is_running = status.get("running", False)
last_update = status.get("last_update", 0)
is_queued_long_time = status.get("status") == "queued" and (now - status.get("start_time", 0)) > (JOB_STATUS_TTL_SECONDS * 2)
if (not is_running and (now - last_update) > JOB_STATUS_TTL_SECONDS) or is_queued_long_time:
if is_queued_long_time:
logging.warning(f"Räume sehr alten 'queued' Job {job_id} auf (möglicherweise hängt der Worker).")
jobs_to_remove.append(job_id)
if jobs_to_remove:
logging.info(f"Räume {len(jobs_to_remove)} alte Job-Status auf: {', '.join(jobs_to_remove)}")
for job_id in jobs_to_remove:
job_statuses.pop(job_id, None)
except Exception as e:
logging.error(f"Fehler im Cleanup Thread: {e}", exc_info=True)
# --- Globaler Thread-Start für Gunicorn (unverändert) ---
_threads_started_globally = False
_background_threads = []
def start_background_threads():
global _threads_started_globally, _background_threads
if _threads_started_globally:
all_running = True
for t in _background_threads:
if not t.is_alive():
all_running = False
logging.warning(f"Hintergrund-Thread {t.name} lief nicht mehr.")
if all_running and _background_threads:
logging.info("Hintergrund-Threads wurden bereits global gestartet und laufen noch.")
return
else:
logging.warning("Einige Hintergrund-Threads liefen nicht mehr oder Liste war leer. Starte neu.")
_threads_started_globally = False
_background_threads = []
logging.info("Starte Hintergrund-Threads global...")
print(f"--> Starte {MAX_WORKERS} Worker-Thread(s) global...")
for i in range(MAX_WORKERS):
worker = threading.Thread(target=worker_thread_target, daemon=True, name=f"BGWorker-{i+1}")
worker.start()
_background_threads.append(worker)
print(f"--> Worker {i+1} gestartet.")
print("--> Starte Job Status Cleanup Thread global...")
cleanup = threading.Thread(target=cleanup_old_jobs, daemon=True, name="CleanupThread")
cleanup.start()
_background_threads.append(cleanup)
_threads_started_globally = True
logging.info(f"Hintergrund-Threads global gestartet ({len(_background_threads)} Threads).")
start_background_threads()
# --- Hauptprogramm (nur für lokale Entwicklung mit `python app.py`) ---
if __name__ == '__main__':
import shutil
if shutil.which("ffmpeg") is None: print("\nWARNUNG: FFmpeg nicht im PATH gefunden (innerhalb Containers OK).\n")
else: print("\nINFO: FFmpeg gefunden.\n")
print(f"\nFlask App startet (lokaler Modus)...");
print(f"Download-Verzeichnis: {DOWNLOAD_DIR}")
print(f"Verlauf aktiviert: {ENABLE_HISTORY}")
print(f"Öffne http://127.0.0.1:5000 oder http://<Deine-IP>:5000 im Browser.")
print("(Beende mit STRG+C)\n")
app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False)

17
compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
web:
build: . # Baut das Image aus dem Dockerfile im aktuellen Verzeichnis
container_name: medien-dl # Optional: Gibt dem Container einen festen Namen
ports:
- "5000:5000" # Mappt Port 5000 des Hosts auf Port 5000 des Containers
volumes:
# Mountet die History-Datei vom Host in den Container (persistent)
- ./data/download_history.json:/app/download_history.json
# Mountet die Statistik-Datei vom Host in den Container (persistent)
- ./data/stats.json:/app/stats.json
# Mountet den Download-Ordner vom Host in den Container (persistent)
# Achtung: Stelle sicher, dass die Berechtigungen passen!
- ./data/sc_downloads:/app/sc_downloads
env_file:
- .env # Lädt Umgebungsvariablen aus der .env-Datei im Host-System
restart: unless-stopped # Startet den Container neu, außer er wurde manuell gestoppt

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask>=2.0.0
python-dotenv>=0.21.0
yt-dlp
boto3>=1.28.0
# Optional, aber nützlich für Produktion:
gunicorn

577
static/script.js Normal file
View File

@@ -0,0 +1,577 @@
document.addEventListener('DOMContentLoaded', () => {
// --- Selektoren & Globale Variablen ---
const selectors = {
form: '#upload-form',
submitButton: '#submit-button',
platformRadios: 'input[name="platform"]',
ytOptionsDiv: '#youtube-options',
ytQualityDiv: '#youtube-quality',
ytFormatRadios: 'input[name="yt_format"]',
mp3QualitySection: '#mp3-quality-section',
mp4QualitySection: '#mp4-quality-section',
codecOptionsSection: '#codec-options-section', // Für Video Codec
progressBar: '#progress-bar',
statusMessage: '#status-message',
logContent: '#log-content',
logOutput: '#log-output',
resultUrlArea: '#result-url-area',
resultUrlLink: '#result-url',
copyResultUrlLink: '#copy-result-url', // Nicht mehr im HTML, aber lassen wir es hier
errorMessage: '#error-message',
historyTableBody: '#history-table tbody',
clearHistoryButton: '#clear-history-button',
contextMenu: '#context-menu',
queueInfo: '#queue-info', // Behalten wir für evtl. spätere Nutzung
statsTotalJobs: '#stats-total-jobs',
statsAvgDuration: '#stats-avg-duration',
statsTotalSize: '#stats-total-size',
urlHelpText: '#urlHelp',
};
const dom = {}; // Objekt für DOM-Elemente
let pollingInterval = null;
let currentJobId = null; // Aktuelle Job-ID speichern
let isPolling = false; // Separater State für aktives Polling
const historyEnabled = !!document.querySelector(selectors.clearHistoryButton);
const videoPlatforms = ['YouTube', 'TikTok', 'Instagram', 'Twitter'];
// --- Initialisierung ---
// (Unverändert)
function init() {
for (const key in selectors) {
dom[key] = document.querySelector(selectors[key]);
if (!dom[key] && ['form', 'submitButton', 'statusMessage', 'progressBar', 'logContent', 'errorMessage', 'queueInfo', 'statsTotalJobs', 'statsAvgDuration', 'statsTotalSize', 'codecOptionsSection', 'urlHelpText'].includes(key)) {
console.warn(`Optionales DOM-Element nicht gefunden: ${selectors[key]}`);
} else if (!dom[key] && !['copyResultUrlLink', 'clearHistoryButton', 'historyTableBody', 'contextMenu'].includes(key)) {
console.error(`Kritisches DOM-Element nicht gefunden: ${selectors[key]}`);
}
}
dom.platformRadios = document.querySelectorAll(selectors.platformRadios);
dom.ytFormatRadios = document.querySelectorAll(selectors.ytFormatRadios);
setupEventListeners();
updateDynamicOptionsVisibility();
if (historyEnabled) {
fetchHistory();
} else {
if(dom.historyTableBody) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">- Verlauf deaktiviert -</td></tr>';
}
}
resetUIState();
fetchStats();
console.log("Uploader UI initialisiert.");
console.log("History aktiviert (Frontend):", historyEnabled);
}
// --- Event Listener Setup ---
// (Unverändert)
function setupEventListeners() {
if (dom.form) dom.form.addEventListener('submit', handleFormSubmit);
dom.platformRadios.forEach(radio => radio.addEventListener('change', updateDynamicOptionsVisibility));
dom.ytFormatRadios.forEach(radio => radio.addEventListener('change', updateYoutubeQualityVisibility));
if (dom.clearHistoryButton) dom.clearHistoryButton.addEventListener('click', handleClearHistory);
if (dom.copyResultUrlLink) {
dom.copyResultUrlLink.addEventListener('click', (e) => {
e.preventDefault();
if (dom.resultUrlLink && dom.resultUrlLink.href) {
copyToClipboard(dom.resultUrlLink.href);
appendLog("Ergebnis-URL kopiert.", "info");
}
});
}
document.addEventListener('click', hideContextMenu);
if (dom.contextMenu) dom.contextMenu.addEventListener('click', handleContextMenuClick);
if (dom.historyTableBody && historyEnabled) {
dom.historyTableBody.addEventListener('contextmenu', (event) => {
const targetLink = event.target.closest('a[data-url]');
if (targetLink) showContextMenu(event, targetLink.dataset.url);
});
}
}
// --- UI Update Funktionen ---
// (Unverändert)
function resetUIState() {
if (dom.submitButton) {
dom.submitButton.disabled = false;
dom.submitButton.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Download starten';
}
if (dom.statusMessage) dom.statusMessage.textContent = 'Bereit.';
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
if (dom.resultUrlArea) dom.resultUrlArea.classList.add('d-none');
updateProgressBar(0, false, false, false); // Reset progress bar
if (dom.logContent) dom.logContent.textContent = '';
// updateQueueInfo(0); // Nicht mehr direkt hier benötigt
if (dom.queueInfo) dom.queueInfo.classList.add('d-none'); // Queue Info Badge ausblenden
currentJobId = null;
isPolling = false;
stopPolling();
console.log("UI State reset.");
}
// (Unverändert)
function setUIProcessing(isStarting = false) {
if (dom.submitButton) {
dom.submitButton.disabled = true;
}
if (dom.statusMessage) dom.statusMessage.textContent = 'Sende Auftrag...';
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
if (isStarting) {
if (dom.resultUrlArea) dom.resultUrlArea.classList.add('d-none');
if (dom.logContent) dom.logContent.textContent = '';
updateProgressBar(0, false, false, false);
}
}
// (Unverändert - von vorheriger Lösung)
function updateProgressBar(value, isError = false, isRunning = false, isQueued = false) {
if (!dom.progressBar) return;
const percentage = Math.max(0, Math.min(100, Math.round(value)));
dom.progressBar.style.width = `${percentage}%`;
dom.progressBar.textContent = `${percentage}%`;
dom.progressBar.setAttribute('aria-valuenow', percentage);
dom.progressBar.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-secondary', 'progress-bar-animated', 'progress-bar-striped');
if (isError) {
dom.progressBar.classList.add('bg-danger');
dom.progressBar.textContent = 'Fehler';
} else if (isQueued) {
dom.progressBar.classList.add('bg-secondary');
dom.progressBar.textContent = 'Wartet...';
} else if (percentage === 100 && !isRunning) {
dom.progressBar.classList.add('bg-success');
} else if (isRunning) {
dom.progressBar.classList.add('bg-info', 'progress-bar-striped', 'progress-bar-animated');
} else {
dom.progressBar.classList.add('bg-info');
}
}
// --- NEU: updateStatusMessage wird jetzt die Queue-Position anzeigen ---
function updateStatusMessage(message, position = null, totalQueued = null) {
if (!dom.statusMessage) return;
let displayMessage = message || '...';
// Wenn Positionsdaten vorhanden sind, füge sie hinzu
if (position !== null && totalQueued !== null) {
displayMessage = `${message} (Position ${position} von ${totalQueued})`;
}
dom.statusMessage.textContent = displayMessage;
}
// updateQueueInfo wird nicht mehr für die Position verwendet
// function updateQueueInfo(queueSize) { ... }
// (Unverändert)
function appendLog(message, type = 'log') {
if (!dom.logContent || !message) return;
const timestamp = new Date().toLocaleTimeString();
const logLine = `${timestamp} - ${message.trim()}\n`;
dom.logContent.textContent += logLine;
if (dom.logOutput) dom.logOutput.scrollTop = dom.logOutput.scrollHeight;
if (type === 'error') console.error(message);
else if (type === 'warn') console.warn(message);
}
// (Unverändert)
function showError(message) {
if (!dom.errorMessage) return;
let displayMessage = message || "Unbekannter Fehler.";
// Spezifische Fehlertexte verbessern (unverändert)
const lowerCaseMessage = message ? message.toLowerCase() : "";
if (lowerCaseMessage.includes("login is required") || lowerCaseMessage.includes("age-restricted") || lowerCaseMessage.includes("instagramloginrequired") || lowerCaseMessage.includes("twitterloginrequired")) {
displayMessage = "Dieser Inhalt erfordert eine Anmeldung oder ist altersbeschränkt und kann nicht direkt heruntergeladen werden.";
} else if (lowerCaseMessage.includes("video unavailable")) {
displayMessage = "Fehler: Dieses Video ist nicht (mehr) verfügbar.";
} else if (lowerCaseMessage.includes("unsupported url")) {
displayMessage = "Fehler: Die eingegebene URL wird nicht unterstützt oder ist kein gültiger Link für die gewählte Plattform.";
} else if (lowerCaseMessage.includes("403") || lowerCaseMessage.includes("access denied")) {
displayMessage = "Fehler: Zugriff auf den Inhalt verweigert (403).";
} else if (lowerCaseMessage.includes("404") || lowerCaseMessage.includes("not found")) {
displayMessage = "Fehler: Inhalt nicht gefunden (404).";
} else if (lowerCaseMessage.includes("already processed")) {
displayMessage = message; // Behalte Servernachricht
} else if (lowerCaseMessage.includes("worker-fehler") || lowerCaseMessage.includes("schwerer worker-fehler")) {
displayMessage = `Interner Serverfehler: ${message}`;
} else if (lowerCaseMessage.includes("job nicht gefunden")) {
displayMessage = message; // Behalte Servernachricht
} else if (lowerCaseMessage.includes("ungültige url für instagram")) {
displayMessage = "Fehler: Ungültige URL für Instagram. Es werden nur Reel-Links unterstützt (z.B. .../reel/...).";
} else if (lowerCaseMessage.includes("ungültige url für twitter")) {
displayMessage = "Fehler: Ungültige URL für Twitter/X. Es werden nur Tweet-Links unterstützt (z.B. .../status/...).";
}
dom.errorMessage.textContent = displayMessage;
dom.errorMessage.classList.remove('d-none');
updateStatusMessage('Fehler!'); // Fehlerstatus ohne Position anzeigen
updateProgressBar(dom.progressBar ? parseInt(dom.progressBar.getAttribute('aria-valuenow')) : 0, true, false, false);
appendLog(`Fehler angezeigt: ${displayMessage}`, 'error');
}
// (Unverändert)
function showResult(url) {
if (!dom.resultUrlArea || !dom.resultUrlLink || !url) return;
dom.resultUrlLink.href = url;
dom.resultUrlLink.textContent = url;
dom.resultUrlArea.classList.remove('d-none');
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
}
// --- Dynamische Optionen ---
// (Unverändert)
function updateDynamicOptionsVisibility() {
const selectedPlatform = document.querySelector('input[name="platform"]:checked')?.value;
if (!dom.ytOptionsDiv || !dom.codecOptionsSection || !dom.urlHelpText) return;
dom.ytOptionsDiv.classList.add('d-none');
dom.codecOptionsSection.classList.add('d-none');
if(dom.mp3QualitySection) dom.mp3QualitySection.classList.add('d-none');
if(dom.mp4QualitySection) dom.mp4QualitySection.classList.add('d-none');
if (selectedPlatform === 'Instagram' || selectedPlatform === 'Twitter') {
dom.urlHelpText.classList.remove('d-none');
} else {
dom.urlHelpText.classList.add('d-none');
}
if (selectedPlatform === 'YouTube') {
dom.ytOptionsDiv.classList.remove('d-none');
updateYoutubeQualityVisibility();
}
else if (videoPlatforms.includes(selectedPlatform) && selectedPlatform !== 'YouTube') {
dom.codecOptionsSection.classList.remove('d-none');
}
}
// (Unverändert)
function updateYoutubeQualityVisibility() {
const selectedFormat = document.querySelector('input[name="yt_format"]:checked')?.value;
if (!dom.mp3QualitySection || !dom.mp4QualitySection || !dom.ytQualityDiv || !dom.codecOptionsSection) return;
dom.ytQualityDiv.classList.remove('d-none');
dom.mp3QualitySection.classList.add('d-none');
dom.mp4QualitySection.classList.add('d-none');
dom.codecOptionsSection.classList.add('d-none');
if (selectedFormat === 'mp3') {
dom.mp3QualitySection.classList.remove('d-none');
} else if (selectedFormat === 'mp4') {
dom.mp4QualitySection.classList.remove('d-none');
dom.codecOptionsSection.classList.remove('d-none');
}
}
// --- Event Handler ---
// (Unverändert)
async function handleFormSubmit(event) {
event.preventDefault();
if (isPolling) {
alert("Bitte warte, bis der aktuelle Auftrag abgeschlossen ist, bevor du einen neuen startest.");
return;
}
resetUIState();
setUIProcessing(true);
const formData = new FormData(dom.form);
try {
const response = await fetch('/start_download', { method: 'POST', body: formData });
const result = await response.json();
if (response.ok && result.job_id) {
currentJobId = result.job_id;
updateStatusMessage(result.message || 'Auftrag gesendet...'); // Initiale Meldung ohne Position
appendLog(`Auftrag ${currentJobId} gestartet: ${result.message || ''}`, 'info');
startPolling();
} else {
throw new Error(result.error || `Serverfehler: ${response.status}`);
}
} catch (error) {
console.error('Fehler beim Starten des Downloads:', error);
showError(`Fehler beim Start: ${error.message}`);
resetUIState();
}
}
// (Unverändert)
async function handleClearHistory() {
if (!dom.clearHistoryButton || !dom.historyTableBody) return;
if (confirm('Möchtest du wirklich den gesamten Verlauf löschen?')) {
dom.clearHistoryButton.disabled = true;
try {
const response = await fetch('/clear_history', { method: 'POST' });
if (response.ok) {
appendLog('Verlauf erfolgreich gelöscht.', 'info');
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Verlauf wurde gelöscht.</td></tr>';
} else {
const result = await response.json(); throw new Error(result.error || 'Unbekannter Fehler.');
}
} catch (error) {
console.error('Fehler beim Löschen des Verlaufs:', error); showError(`Fehler beim Löschen: ${error.message}`);
} finally {
dom.clearHistoryButton.disabled = false;
}
}
}
// --- Polling ---
// (Unverändert)
function startPolling() {
if (!currentJobId) {
console.warn("StartPolling ohne Job ID aufgerufen.");
return;
}
stopPolling();
isPolling = true;
if (dom.submitButton) {
dom.submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verarbeite...';
dom.submitButton.disabled = true;
}
pollingInterval = setInterval(fetchStatus, 2000);
fetchStatus(); // Fetch immediately once
console.log(`Polling gestartet für Job ${currentJobId}.`);
}
// (Unverändert)
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
isPolling = false;
console.log("Polling gestoppt.");
}
}
// --- fetchStatus: CORE CHANGE HERE ---
async function fetchStatus() {
if (!currentJobId || !isPolling) {
console.log("FetchStatus abgebrochen (keine JobID oder Polling inaktiv).");
stopPolling();
return;
}
console.log(`Fetching status for job ${currentJobId}...`);
try {
const response = await fetch(`/status?job_id=${currentJobId}`);
if (response.status === 404) {
const status = await response.json();
console.warn(`Job ${currentJobId} nicht gefunden (Status 404). Status:`, status);
showError(status.error || "Auftrag nicht gefunden (möglicherweise zu alt).");
stopPolling();
resetSubmitButton();
return;
}
if (!response.ok) {
throw new Error(`Status-Serverfehler: ${response.status}`);
}
const status = await response.json();
console.log("Received status:", status);
// --- UI Updates basierend auf dem Job-Status ---
try {
if (dom.logContent && Array.isArray(status.logs)) {
dom.logContent.textContent = status.logs.join('\n') + '\n';
if (dom.logOutput) dom.logOutput.scrollTop = dom.logOutput.scrollHeight;
}
} catch (logError) {
console.error("Fehler beim Aktualisieren der Logs:", logError);
}
const isRunning = status.running === true;
const isQueued = status.status === 'queued';
const isCompleted = status.status === 'completed';
const isError = !!status.error || status.status === 'error';
const isNotFound = status.status === 'not_found';
// Update Progress Bar mit allen Zuständen
updateProgressBar(status.progress || 0, isError, isRunning, isQueued);
// --- NEU: Update Status Message mit Positionsinfo ---
if (isQueued && status.position !== undefined && status.total_queued !== undefined) {
updateStatusMessage(status.message || 'In Warteschlange...', status.position, status.total_queued);
} else {
updateStatusMessage(status.message || '...'); // Normale Nachricht ohne Position
}
// --- ENDE NEU ---
// Queue Info Badge nicht mehr verwenden
// updateQueueInfo(status.queue_size || 0);
if (dom.queueInfo) dom.queueInfo.classList.add('d-none');
// --- Logik zum Stoppen des Pollings ---
if (isCompleted || isError || isNotFound) {
console.log(`Job ${currentJobId} ist beendet. Status: ${status.status}`);
stopPolling();
if (isError) {
console.error(`Backend meldet Fehler für Job ${currentJobId}:`, status.error || status.message);
showError(status.error || status.message);
} else if (isCompleted) {
updateStatusMessage('Abgeschlossen!'); // Finale Meldung ohne Position
updateProgressBar(100, false, false, false);
if (status.result_url) {
showResult(status.result_url);
}
if (historyEnabled) fetchHistory();
fetchStats();
} else {
showError(status.error || status.message || "Auftrag beendet, aber Status unklar.");
}
resetSubmitButton();
} else if (isRunning || isQueued) { // Polling weiterführen wenn running ODER queued
if (dom.submitButton && !dom.submitButton.disabled) {
dom.submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verarbeite...';
dom.submitButton.disabled = true;
}
} else {
console.warn(`Unerwarteter Job-Status für ${currentJobId}:`, status);
}
} catch (error) {
console.error('Polling-Fehler:', error);
appendLog(`Polling fehlgeschlagen für Job ${currentJobId}: ${error.message}`, 'error');
showError(`Polling-Fehler: ${error.message}. Prozess möglicherweise unterbrochen.`);
stopPolling();
resetUIState();
}
}
// (Unverändert)
function resetSubmitButton() {
if (dom.submitButton) {
dom.submitButton.disabled = false;
dom.submitButton.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Download starten';
console.log("Submit button reset.");
}
}
// --- History ---
// (Unverändert)
async function fetchHistory() {
if (!dom.historyTableBody || !historyEnabled) return;
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted"><i class="fas fa-spinner fa-spin"></i> Lade Verlauf...</td></tr>';
try {
const response = await fetch('/history');
if (!response.ok) throw new Error(`Serverfehler History: ${response.status}`);
const history = await response.json();
renderHistory(history);
} catch (error) {
console.error('Fehler Laden Verlauf:', error);
dom.historyTableBody.innerHTML = `<tr><td colspan="5" class="text-center text-danger">Fehler Laden Verlauf: ${error.message}</td></tr>`;
}
}
// (Unverändert)
function renderHistory(historyData) {
if (!dom.historyTableBody || !historyEnabled) return;
dom.historyTableBody.innerHTML = '';
if (!historyData || !Array.isArray(historyData) || historyData.length === 0) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Keine Einträge im Verlauf.</td></tr>'; return;
}
historyData.forEach(entry => {
try {
const row = dom.historyTableBody.insertRow();
row.insertCell().textContent = entry.timestamp || '';
const platformCell = row.insertCell(); platformCell.classList.add('text-center');
let platformIcon = '<i class="fas fa-question-circle text-muted" title="Unbekannt"></i>';
const platform = entry.platform || 'Unbekannt';
if (platform === 'SoundCloud') platformIcon = '<i class="fab fa-soundcloud text-warning" title="SoundCloud"></i>';
else if (platform === 'YouTube') platformIcon = '<i class="fab fa-youtube text-danger" title="YouTube"></i>';
else if (platform === 'TikTok') platformIcon = '<i class="fab fa-tiktok" title="TikTok"></i>';
else if (platform === 'Instagram') platformIcon = '<i class="fab fa-instagram" title="Instagram Reel"></i>';
else if (platform === 'Twitter') platformIcon = '<i class="fab fa-x-twitter" title="Twitter/X"></i>';
platformCell.innerHTML = platformIcon;
const titleCell = row.insertCell(); titleCell.textContent = entry.title || 'N/A'; titleCell.title = entry.title || '';
createLinkCell(row.insertCell(), entry['source_url']);
createLinkCell(row.insertCell(), entry['s3_url']);
} catch (renderError) {
console.error("Fehler Rendern History-Eintrag:", entry, renderError);
const errorRow = dom.historyTableBody.insertRow();
const cell = errorRow.insertCell(); cell.colSpan = 5; cell.textContent = "Fehler Anzeige Eintrag."; cell.style.color = 'red';
}
});
}
// (Unverändert)
function createLinkCell(cell, url) {
if (!cell || typeof cell.appendChild !== 'function') return;
cell.innerHTML = '';
const urlString = (url === null || typeof url === 'undefined') ? '' : String(url).trim();
if (urlString && (urlString.startsWith('http://') || urlString.startsWith('https://'))) {
try {
const link = document.createElement('a'); link.href = urlString;
link.textContent = urlString.length > 50 ? urlString.substring(0, 47) + '...' : urlString;
link.title = urlString; link.target = "_blank"; link.rel = "noopener noreferrer";
link.dataset.url = urlString; // Für Kontextmenü
cell.appendChild(link);
} catch (e) { console.error(`Fehler Link Erstellung für '${urlString}':`, e); cell.textContent = urlString || 'Fehler'; }
} else { cell.textContent = urlString || 'N/A'; cell.title = urlString; }
}
// --- Kontextmenü ---
// (Unverändert)
let currentContextMenuUrl = null;
function showContextMenu(event, url) {
event.preventDefault();
if (!dom.contextMenu || !url) return;
currentContextMenuUrl = url;
dom.contextMenu.style.top = `${event.clientY}px`;
dom.contextMenu.style.left = `${event.clientX}px`;
dom.contextMenu.classList.remove('d-none');
}
function hideContextMenu() {
if (dom.contextMenu) dom.contextMenu.classList.add('d-none');
currentContextMenuUrl = null;
}
function handleContextMenuClick(event) {
const action = event.target.closest('[data-action]')?.dataset.action;
if (action && currentContextMenuUrl) {
if (action === 'open') window.open(currentContextMenuUrl, '_blank');
else if (action === 'copy') { copyToClipboard(currentContextMenuUrl); appendLog("URL aus History kopiert.", "info"); }
}
hideContextMenu();
}
// --- Statistik ---
// (Unverändert)
async function fetchStats() {
if (!dom.statsTotalJobs || !dom.statsAvgDuration || !dom.statsTotalSize) return;
try {
const response = await fetch('/stats');
if (!response.ok) throw new Error(`Statistik-Serverfehler: ${response.status}`);
const stats = await response.json();
dom.statsTotalJobs.textContent = stats.total_jobs ?? 'N/A';
dom.statsAvgDuration.textContent = stats.average_duration_seconds ?? 'N/A';
dom.statsTotalSize.textContent = stats.total_size_formatted ?? 'N/A';
} catch (error) {
console.error('Fehler Laden Statistiken:', error);
dom.statsTotalJobs.textContent = 'Fehler'; dom.statsAvgDuration.textContent = 'Fehler'; dom.statsTotalSize.textContent = 'Fehler';
}
}
// --- Hilfsfunktionen ---
// (Unverändert)
function copyToClipboard(text) {
if (!navigator.clipboard) { /* Fallback */
try {
const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed";
document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy');
document.body.removeChild(ta); appendLog("Link kopiert (Fallback).", "info");
} catch (err) { console.error('Fallback Copy failed:', err); alert("Kopieren fehlgeschlagen."); } return;
}
navigator.clipboard.writeText(text).then(() => appendLog("Link kopiert.", "info"))
.catch(err => { console.error('Async Copy failed:', err); alert("Kopieren fehlgeschlagen."); });
}
// --- Start ---
init();
}); // Ende DOMContentLoaded

168
static/style.css Normal file
View File

@@ -0,0 +1,168 @@
/* Custom Styles (Ergänzung zu Bootstrap) */
html {
height: 100%; /* Wichtig für min-height im body */
}
body {
background-color: #e9ecef; /* Hellerer Hintergrund */
display: flex; /* Flexbox aktivieren */
flex-direction: column; /* Hauptachse vertikal */
min-height: 100vh; /* Mindesthöhe des Viewports */
}
/* Hauptinhalts-Container soll wachsen */
.main-content {
flex: 1 0 auto; /* flex-grow: 1, flex-shrink: 0, flex-basis: auto */
/* Kein margin-bottom hier, der Footer kümmert sich um den Abstand */
}
/* Footer soll nicht schrumpfen und hat eigenen Abstand/Styling */
.page-footer {
flex-shrink: 0; /* Verhindert, dass der Footer schrumpft */
background-color: #f8f9fa; /* Optional: Leichter Hintergrund für Footer */
/* mt-auto im HTML sorgt für den Push nach unten */
/* padding-top und padding-bottom im HTML oder hier definieren */
}
.page-footer hr {
margin-top: 0; /* Abstand der Linie anpassen */
margin-bottom: 1rem;
}
.page-footer p {
margin-bottom: 0.5rem;
}
.page-footer i {
margin-right: 5px;
}
.page-footer strong {
color: #343a40; /* Etwas dunklerer Text für Werte */
}
/* --- Restliche Styles --- */
/* Verbessere Lesbarkeit im Log */
#log-output {
max-height: 250px;
overflow-y: auto; /* Vertikales Scrollen bei Bedarf */
overflow-x: auto; /* NEU: Horizontales Scrollen bei Bedarf (Fallback) */
background-color: #f8f8f8;
border: 1px solid #ddd;
padding: 10px;
border-radius: 3px;
font-size: 0.85rem;
}
#log-content {
white-space: pre-wrap; /* Wichtig: Behält Zeilenumbrüche bei UND erlaubt Umbruch langer Zeilen */
overflow-wrap: break-word;/* Wichtig: Bricht lange Wörter/URLs um, um Überlauf zu verhindern */
word-wrap: break-word; /* Älterer Alias für overflow-wrap */
margin: 0;
font-family: monospace;
/* Entferne explizite Breiten- oder Overflow-Regeln hier, der Container steuert das */
}
/* Tabelle responsiver machen */
.table-responsive {
max-height: 500px; /* Höhe begrenzen */
}
#history-table th,
#history-table td {
vertical-align: middle; /* Vertikal zentrieren */
font-size: 0.85rem;
word-break: break-all;
}
#history-table th:nth-child(2), /* Plattform Icon Spalte */
#history-table td:nth-child(2) {
text-align: center;
width: 40px; /* Feste Breite für Icon */
}
#history-table td a {
/* Bootstrap übernimmt Link-Styling, Cursor wird per JS gesetzt */
cursor: pointer;
}
/* Kontextmenü (Bootstrap-ähnlich) */
.context-menu {
z-index: 1050; /* Über anderen Elementen */
min-width: 150px;
}
.context-menu .list-group-item {
cursor: pointer;
font-size: 0.9rem;
}
.context-menu .list-group-item i {
width: 1.2em; /* Platz für Icons */
}
/* Versteckte Elemente */
.d-none {
display: none !important; /* Wichtig, um Bootstrap zu überschreiben, falls nötig */
}
/* Fortschrittsbalken Text zentrieren */
.progress {
position: relative;
}
.progress-bar {
/* Textfarbe anpassen, falls nötig */
/* color: #212529; */
font-weight: bold;
display: flex; /* Ermöglicht Zentrierung */
justify-content: center;
align-items: center;
overflow: hidden; /* Verhindert, dass Text überläuft */
}
/* Optional: Fehlerhafter Fortschrittsbalken */
.progress-bar.bg-danger {
color: white;
}
/* Kleinere Anpassungen für Radio-Buttons */
.form-check-label i {
margin-right: 4px;
width: 1em; /* Platz für Icon */
}
/* Anpassung für Comboboxen */
#youtube-quality .form-select-sm {
max-width: 180px; /* Breite begrenzen */
display: inline-block; /* Nebeneinander mit Label */
width: auto; /* Automatische Breite */
}
#youtube-quality label {
margin-right: 5px;
}
#mp3-quality-section, #mp4-quality-section {
margin-right: 15px; /* Abstand zwischen Qualitätsoptionen */
}
/* Plattform Icons im Formular */
.form-check-label i.fa-soundcloud { color: #ff5500; }
.form-check-label i.fa-youtube { color: #ff0000; }
.form-check-label i.fa-tiktok { color: #000000; } /* Oder #fe2c55, #00f2ea */
.form-check-label i.fa-instagram { color: #E1306C; } /* Instagram Pink */
.form-check-label i.fa-x-twitter { color: #000000; } /* Twitter/X Schwarz */
.form-check-label i.fa-twitter { color: #1DA1F2; } /* Altes Twitter Blau (Fallback) */
/* Plattform Icons in der History Tabelle */
#history-table td i.fa-soundcloud { color: #ff5500; }
#history-table td i.fa-youtube { color: #ff0000; }
#history-table td i.fa-tiktok { color: #000000; } /* Oder #fe2c55, #00f2ea */
#history-table td i.fa-instagram { color: #E1306C; } /* Instagram Pink */
#history-table td i.fa-x-twitter { color: #000000; } /* Twitter/X Schwarz */
#history-table td i.fa-twitter { color: #1DA1F2; } /* Altes Twitter Blau (Fallback) */
/* Hilfetext für URL-Eingabe */
#urlHelp {
font-size: 0.8rem;
}

255
templates/index.html Normal file
View File

@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO Meta Tags -->
<meta name="description" content="Nutzen Sie den MrUnknownDE Socialmedia Downloader, um Videos und Musik von YouTube (MP3/MP4), SoundCloud, TikTok, Instagram Reels und Twitter/X einfach herunterzuladen. Fügen Sie die URL ein und starten Sie den Download.">
<meta name="keywords" content="Social Media Downloader, YouTube Downloader, SoundCloud Downloader, TikTok Downloader, Instagram Reel Downloader, Twitter Downloader, X Downloader, Video Downloader, Audio Downloader, MP3, MP4, Herunterladen, Konvertieren, MrUnknownDE, medien.mrunk.de">
<meta name="author" content="MrUnknownDE">
<meta name="robots" content="index, follow"> <!-- Erlaubt Suchmaschinen das Indexieren und Folgen von Links -->
<!-- Open Graph / Facebook Meta Tags -->
<meta property="og:title" content="MrUnknownDE Socialmedia Downloader - Medien von YouTube, SC, TikTok, IG, Twitter herunterladen">
<meta property="og:description" content="Einfacher Download von Videos und Musik (MP3/MP4) von beliebten Social Media Plattformen wie YouTube, SoundCloud, TikTok, Instagram Reels und Twitter/X.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://medien.mrunk.de/"> <!-- Ersetze dies mit der tatsächlichen URL deiner Seite -->
<meta property="og:image" content="https://medien.mrunk.de/static/og-image.png"> <!-- Ersetze dies mit der URL zu einem Vorschaubild (z.B. 1200x630px) -->
<meta property="og:site_name" content="medien.mrunk.de">
<meta property="og:locale" content="de_DE">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image"> <!-- 'summary_large_image' ist oft ansprechender -->
<meta name="twitter:title" content="MrUnknownDE Socialmedia Downloader - Medien von YouTube, SC, TikTok, IG, Twitter herunterladen">
<meta name="twitter:description" content="Einfacher Download von Videos und Musik (MP3/MP4) von YouTube, SoundCloud, TikTok, Instagram Reels und Twitter/X.">
<meta name="twitter:image" content="https://medien.mrunk.de/static/og-image.png"> <!-- Gleiches Bild wie für Open Graph verwenden -->
<!-- Optional: Füge deinen Twitter-Benutzernamen hinzu -->
<!-- <meta name="twitter:site" content="@DeinTwitterHandle"> -->
<!-- <meta name="twitter:creator" content="@MrUnknownDE"> -->
<title>medien.mrunk.de - Social Media Downloader</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Font Awesome CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<!-- Hauptinhalts-Container -->
<div class="container mt-4 main-content">
<h1 class="mb-4 text-center"><i class="fas fa-cloud-upload-alt"></i> MrUnknownDE Socialmedia Downloader</h1>
<div class="row g-4">
<!-- Downloader Spalte -->
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header">
<i class="fas fa-upload"></i> Downloader
</div>
<div class="card-body">
<form id="upload-form">
<!-- Plattform Auswahl -->
<div class="mb-3">
<label class="form-label fw-bold">Plattform:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-sc" value="SoundCloud" checked>
<label class="form-check-label" for="platform-sc"><i class="fab fa-soundcloud text-warning"></i> SoundCloud</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-yt" value="YouTube">
<label class="form-check-label" for="platform-yt"><i class="fab fa-youtube text-danger"></i> YouTube</label> <!-- (+ Reels) entfernt für Klarheit -->
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-tt" value="TikTok">
<label class="form-check-label" for="platform-tt"><i class="fab fa-tiktok"></i> TikTok</label>
</div>
<!-- NEU: Instagram Radio Button -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-ig" value="Instagram">
<label class="form-check-label" for="platform-ig"><i class="fab fa-instagram"></i> Instagram Reel</label>
</div>
<!-- NEU: Twitter Radio Button -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-tw" value="Twitter">
<!-- Verwende fab fa-x-twitter für das neue Logo oder fab fa-twitter für das alte -->
<label class="form-check-label" for="platform-tw"><i class="fab fa-x-twitter"></i> Twitter/X Video</label>
</div>
</div>
</div>
<!-- URL Eingabe -->
<div class="mb-3">
<label for="url" class="form-label fw-bold"><i class="fas fa-link"></i> Quelle URL:</label>
<input type="url" class="form-control" id="url" name="url" required placeholder="https://...">
<small id="urlHelp" class="form-text text-muted">
Für Instagram nur Reel-Links (z.B. .../reel/...), für Twitter/X nur Tweet-Links (z.B. .../status/...).
</small>
</div>
<!-- YouTube Optionen (dynamisch) -->
<div id="youtube-options" class="mb-3 d-none border-top pt-3"> <!-- border-top hinzugefügt für visuelle Trennung -->
<label class="form-label fw-bold">YouTube Optionen:</label>
<div class="mb-2">
<label class="form-label small">Format:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="yt_format" id="format-mp3" value="mp3" checked>
<label class="form-check-label small" for="format-mp3"><i class="fas fa-file-audio text-primary"></i> MP3</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="yt_format" id="format-mp4" value="mp4">
<label class="form-check-label small" for="format-mp4"><i class="fas fa-file-video text-info"></i> MP4</label>
</div>
</div>
</div>
<div id="youtube-quality">
<div id="mp3-quality-section">
<label for="mp3_bitrate" class="form-label small"><i class="fas fa-sliders-h"></i> MP3 Bitrate:</label>
<select class="form-select form-select-sm d-inline-block w-auto" id="mp3_bitrate" name="mp3_bitrate">
<option>Best</option>
<option>256k</option>
<option selected>192k</option>
<option>128k</option>
<option>64k</option>
</select>
</div>
<div id="mp4-quality-section" class="d-none">
<label for="mp4_quality" class="form-label small"><i class="fas fa-photo-video"></i> MP4 Qualität:</label>
<select class="form-select form-select-sm d-inline-block w-auto" id="mp4_quality" name="mp4_quality">
<option selected>Best</option>
<option>Medium (~720p)</option>
<option>Low (~480p)</option>
</select>
</div>
</div>
</div>
<!-- Codec Optionen (dynamisch für YT-MP4, TikTok, Instagram, Twitter) -->
<div id="codec-options-section" class="mb-3 d-none border-top pt-3">
<label class="form-label fw-bold">Video Codec:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="codec_preference" id="codec-original" value="original" checked>
<label class="form-check-label small" for="codec-original"><i class="fas fa-file-video text-info"></i> Original</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="codec_preference" id="codec-h264" value="h264">
<label class="form-check-label small" for="codec-h264"><i class="fas fa-cogs text-secondary"></i> Kompatibel (H.264)</label>
</div>
</div>
<small class="form-text text-muted">
<i class="fas fa-exclamation-triangle text-warning"></i> H.264-Konvertierung kann die Verarbeitung erheblich verlängern.
</small>
</div>
<button type="submit" id="submit-button" class="btn btn-success w-100 mt-3">
<i class="fas fa-cloud-upload-alt"></i> Download starten
</button>
</form>
<!-- Status & Progress -->
<div id="status-area" class="mt-4 pt-3 border-top">
<h5 class="mb-2">Status <span id="queue-info" class="badge bg-secondary ms-2 d-none"></span></h5>
<div class="progress mb-2" role="progressbar" aria-label="Download/Upload Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 25px;">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info text-dark" style="width: 0%">0%</div>
</div>
<p id="status-message" class="text-muted mb-2">Bereit.</p>
<div id="log-output" class="border rounded p-2 bg-light mb-2">
<h6 class="mb-1">Log:</h6>
<pre id="log-content" class="small"></pre>
</div>
<p id="result-url-area" class="alert alert-success d-none" role="alert">
<i class="fas fa-check-circle"></i>→ Download-Link: <a id="result-url" href="#" target="_blank" class="alert-link"></a>
</p>
<p id="error-message" class="alert alert-danger d-none" role="alert"></p>
</div>
</div>
</div>
</div>
<!-- History Spalte -->
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-history"></i> Verlauf</span>
{# Button nur anzeigen, wenn History aktiviert ist #}
{% if history_enabled %}
<button id="clear-history-button" class="btn btn-sm btn-outline-danger" title="Verlauf löschen">
<i class="fas fa-trash-alt"></i> Löschen
</button>
{% else %}
<span class="badge bg-secondary">Deaktiviert</span>
{% endif %}
</div>
<div class="card-body">
{% if not history_enabled %}
<p class="text-muted text-center">Der Verlaufs-Speicher ist in der Konfiguration deaktiviert.</p>
{% endif %}
<div class="table-responsive">
<table id="history-table" class="table table-striped table-hover table-sm caption-top">
{% if history_enabled %}
<caption>Rechtsklick auf URLs für Optionen</caption>
{% endif %}
<thead class="table-light">
<tr>
<th>Zeit</th>
<th><i class="fas fa-globe"></i></th> <!-- Plattform Icon -->
<th>Titel</th>
<th>Quelle URL</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{# Inhalt wird per JS geladen, aber initiale Meldung kann hier stehen #}
{% if history_enabled %}
<tr><td colspan="5" class="text-center text-muted">Lade Verlauf...</td></tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">- Verlauf deaktiviert -</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div> <!-- Ende row -->
</div> <!-- Ende Hauptinhalts-Container -->
<!-- Kontextmenü (versteckt) -->
<ul id="context-menu" class="context-menu list-group position-absolute d-none shadow-lg">
<li class="list-group-item list-group-item-action py-2 px-3" data-action="open"><i class="fas fa-external-link-alt me-2"></i>Öffnen</li>
<li class="list-group-item list-group-item-action py-2 px-3" data-action="copy"><i class="fas fa-copy me-2"></i>Kopieren</li>
</ul>
<!-- Footer (jetzt außerhalb des .container) -->
<footer class="container-fluid mt-auto py-3 text-center text-muted small page-footer">
<hr class="container"> <!-- Trennlinie innerhalb der Container-Breite -->
<div class="container"> <!-- Inhalt wieder zentriert -->
<p>
<span title="Gesamtzahl verarbeiteter Aufträge (erfolgreich oder fehlgeschlagen)">
<i class="fas fa-tasks"></i> Aufträge gesamt: <strong id="stats-total-jobs">...</strong>
</span> |
<span title="Durchschnittliche Verarbeitungszeit für erfolgreiche Aufträge">
<i class="fas fa-stopwatch"></i> Ø Dauer: <strong id="stats-avg-duration">...</strong> Sek.
</span> |
<span title="Gesamtgröße aller erfolgreich hochgeladenen Dateien">
<i class="fas fa-database"></i> Gesamtgröße: <strong id="stats-total-size">...</strong>
</span>
</p>
<p>
<i class="fa-solid fa-circle-info"></i> Download-Links werden nach <strong>7 Tage</strong> gelöscht!
</p>
<p>
<i class="fab fa-discord"></i> Kontakt bei Fragen/Problemen: <strong><a href="https://www.discordapp.com/users/155076323612688384">MrUnknownDE</a></strong>
</p>
</div>
</footer>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>