Files
medien-dl/app.py
2025-09-24 10:51:42 +02:00

948 lines
52 KiB
Python

# -*- 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)