mirror of
https://github.com/MrUnknownDE/medien-dl.git
synced 2026-04-18 22:03:44 +02:00
migration from old repo
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal 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
38
Dockerfile
Normal 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
948
app.py
Normal 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
17
compose.yml
Normal 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
6
requirements.txt
Normal 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
577
static/script.js
Normal 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
168
static/style.css
Normal 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
255
templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user