Files
Video-Download-Helper/main.py
2025-09-15 16:18:23 +02:00

1147 lines
49 KiB
Python

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "PyQt5==5.15.9",
# "PyInstaller==5.13.2",
# "requests==2.31.0",
# ]
# ///
import sys
import os
import json
import uuid
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QComboBox, QTextEdit,
QMessageBox, QListWidget, QFormLayout,
QGroupBox, QTabWidget, QListWidgetItem, QMenu, QAction, QInputDialog)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
# Imports für die neuen Module
from config import (get_base_path, get_user_data_dir, get_user_presets_dir,
CONFIG_FILE, PRESETS_DIR, DEFAULT_CONFIG, SERIES_TEMPLATE)
from utils import mask_sensitive_data, set_window_icon
from download_threads import DownloadThread
from dialogs import PresetDialog, OptionenDialog
class QueueItem:
"""Repräsentiert einen Eintrag in der Download-Queue."""
def __init__(self, url, preset_data, output_dir=None, output_filename=None,
series_info=None, use_local_ytdlp=True, extra_args=None):
self.id = str(uuid.uuid4()) # Eindeutige ID für diesen Queue-Eintrag
self.url = url
self.preset_data = preset_data.copy() if preset_data else {}
self.output_dir = output_dir
self.output_filename = output_filename
self.series_info = series_info.copy() if series_info else {}
self.use_local_ytdlp = use_local_ytdlp
self.extra_args = extra_args or ""
self.status = "Wartend"
def get_display_name(self):
"""Gibt einen lesbaren Namen für die Queue-Anzeige zurück."""
preset_name = self.preset_data.get("name", "Unbekannt")
if self.series_info:
series = self.series_info.get("series", "")
season = self.series_info.get("season", "")
episode = self.series_info.get("episode", "")
if series and season and episode:
return f"{self.url} - {series} S{season}E{episode} ({preset_name})"
return f"{self.url} ({preset_name})"
def to_dict(self):
"""Konvertiert das QueueItem in ein JSON-serialisierbares Dictionary."""
return {
"id": self.id,
"url": self.url,
"preset_data": self.preset_data,
"output_dir": self.output_dir,
"output_filename": self.output_filename,
"series_info": self.series_info,
"use_local_ytdlp": self.use_local_ytdlp,
"extra_args": self.extra_args,
"status": self.status
}
@classmethod
def from_dict(cls, data):
"""Erstellt ein QueueItem aus einem Dictionary."""
item = cls(
url=data["url"],
preset_data=data["preset_data"],
output_dir=data["output_dir"],
output_filename=data["output_filename"],
series_info=data["series_info"],
use_local_ytdlp=data["use_local_ytdlp"],
extra_args=data["extra_args"]
)
item.id = data["id"]
item.status = data["status"]
return item
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Video Download Helper")
self.resize(800, 600)
# Icon für das Hauptfenster setzen
set_window_icon(self)
# Stelle sicher, dass Verzeichnisse existieren
self.ensure_directories()
# Default-Presets automatisch anlegen, falls keine vorhanden
if not any(f.endswith('.json') for f in os.listdir(PRESETS_DIR)):
self.create_default_presets()
self.config = self.load_config()
self.presets = self.load_presets()
self.download_thread = None
self.download_queue = [] # Liste für die Download-Queue
self.current_queue_item = None # Aktuell laufender Download aus der Queue
# UI initialisieren
self.setup_ui()
# Queue aus gespeicherter Datei laden (nach UI-Setup)
self.load_queue()
def ensure_directories(self):
"""Stellt sicher, dass alle benötigten Verzeichnisse existieren."""
# Stelle sicher, dass der Presets-Ordner existiert
os.makedirs(PRESETS_DIR, exist_ok=True)
# Stelle sicher, dass der bin-Ordner existiert
bin_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin")
os.makedirs(bin_dir, exist_ok=True)
def setup_ui(self):
central_widget = QWidget()
main_layout = QVBoxLayout()
# URL Input
url_layout = QHBoxLayout()
url_layout.addWidget(QLabel("Video-URL:"))
self.url_input = QLineEdit()
url_layout.addWidget(self.url_input)
main_layout.addLayout(url_layout)
# Preset Selection
preset_layout = QHBoxLayout()
preset_layout.addWidget(QLabel("Preset:"))
self.preset_combo = QComboBox()
self.update_preset_combo()
preset_layout.addWidget(self.preset_combo)
# Preset Buttons
self.add_preset_btn = QPushButton("Neu")
self.add_preset_btn.clicked.connect(self.add_preset)
preset_layout.addWidget(self.add_preset_btn)
self.edit_preset_btn = QPushButton("Bearbeiten")
self.edit_preset_btn.clicked.connect(self.edit_preset)
preset_layout.addWidget(self.edit_preset_btn)
self.delete_preset_btn = QPushButton("Löschen")
self.delete_preset_btn.clicked.connect(self.delete_preset)
preset_layout.addWidget(self.delete_preset_btn)
main_layout.addLayout(preset_layout)
# Serien-Einstellungen
self.series_group = QGroupBox("Serien-Einstellungen")
self.series_group.setVisible(False)
series_form = QFormLayout()
self.series_input = QLineEdit()
self.season_input = QLineEdit()
self.episode_input = QLineEdit()
series_form.addRow("Serie:", self.series_input)
series_form.addRow("Staffel:", self.season_input)
series_form.addRow("Folge:", self.episode_input)
self.custom_path_input = QLineEdit()
series_form.addRow("Eigener Pfad:", self.custom_path_input)
self.series_group.setLayout(series_form)
main_layout.addWidget(self.series_group)
# Options-Button statt Felder für Standardpfad und yt-dlp-Quelle
options_layout = QHBoxLayout()
self.optionen_btn = QPushButton("Optionen...")
self.optionen_btn.clicked.connect(self.open_optionen_dialog)
options_layout.addWidget(self.optionen_btn)
main_layout.addLayout(options_layout)
# Command Preview
main_layout.addWidget(QLabel("Befehlsvorschau:"))
self.cmd_preview = QTextEdit()
self.cmd_preview.setReadOnly(True)
self.cmd_preview.setMaximumHeight(60)
main_layout.addWidget(self.cmd_preview)
# Download Buttons
download_buttons_layout = QHBoxLayout()
self.download_btn = QPushButton("Download starten")
self.download_btn.clicked.connect(self.start_download)
download_buttons_layout.addWidget(self.download_btn)
# Neu: Queue-Button
self.queue_btn = QPushButton("Zur Queue hinzufügen")
self.queue_btn.clicked.connect(self.add_to_queue)
download_buttons_layout.addWidget(self.queue_btn)
main_layout.addLayout(download_buttons_layout)
# Neu: Tabbed Layout für Ausgabe und Queue
self.tabs = QTabWidget()
# Log Output Tab
log_tab = QWidget()
log_layout = QVBoxLayout()
log_layout.addWidget(QLabel("Ausgabe:"))
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
log_layout.addWidget(self.log_output)
log_tab.setLayout(log_layout)
self.tabs.addTab(log_tab, "Ausgabe")
# Queue Tab
queue_tab = QWidget()
queue_layout = QVBoxLayout()
queue_layout.addWidget(QLabel("Download-Queue:"))
self.queue_list = QListWidget()
self.queue_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.queue_list.customContextMenuRequested.connect(self.show_queue_context_menu)
queue_layout.addWidget(self.queue_list)
queue_buttons = QHBoxLayout()
self.start_queue_btn = QPushButton("Queue starten")
self.start_queue_btn.clicked.connect(self.start_queue)
queue_buttons.addWidget(self.start_queue_btn)
self.clear_queue_btn = QPushButton("Queue leeren")
self.clear_queue_btn.clicked.connect(self.clear_queue)
queue_buttons.addWidget(self.clear_queue_btn)
queue_layout.addLayout(queue_buttons)
queue_tab.setLayout(queue_layout)
self.tabs.addTab(queue_tab, "Queue")
main_layout.addWidget(self.tabs)
# Connect signals
self.url_input.textChanged.connect(self.update_cmd_preview)
self.preset_combo.currentIndexChanged.connect(self.preset_changed)
# Serie, Staffel, Folge Signals
self.series_input.textChanged.connect(self.update_cmd_preview)
self.season_input.textChanged.connect(self.update_cmd_preview)
self.episode_input.textChanged.connect(self.update_cmd_preview)
self.custom_path_input.textChanged.connect(self.update_cmd_preview)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
# Initial update
self.preset_changed()
self.update_cmd_preview()
self.update_queue_buttons()
def load_config(self):
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
except Exception:
return DEFAULT_CONFIG.copy()
return DEFAULT_CONFIG.copy()
def save_config(self):
self.config["output_dir"] = self.output_dir_input.text() if hasattr(self, 'output_dir_input') else self.config.get("output_dir", "")
self.config["use_local_ytdlp"] = self.ytdlp_source_combo.currentIndex() == 0 if hasattr(self, 'ytdlp_source_combo') else self.config.get("use_local_ytdlp", True)
if self.preset_combo.currentIndex() >= 0:
self.config["last_preset"] = self.preset_combo.currentText()
try:
with open(CONFIG_FILE, 'w') as f:
json.dump(self.config, f, indent=4)
except Exception as e:
self.log_output.append(f"Fehler beim Speichern der Konfiguration: {str(e)}")
def load_presets(self):
# Default-Presets immer aus dem Code laden
defaults = [
{
"name": "Audio (MP3)",
"description": "Nur Audio als MP3",
"args": "-x --audio-format mp3",
"has_series_template": False,
"is_audio": True,
"source": "default"
},
{
"name": "Video (Best)",
"description": "Beste Videoqualität",
"args": "-f bestvideo+bestaudio",
"has_series_template": False,
"is_audio": False,
"source": "default"
}
]
presets = defaults.copy()
# User-Presets (können mitgelieferte überschreiben)
user_presets_dir = get_user_presets_dir()
for filename in os.listdir(user_presets_dir):
if filename.endswith('.json'):
try:
with open(os.path.join(user_presets_dir, filename), 'r', encoding='utf-8') as f:
preset = json.load(f)
preset["source"] = "user"
# Überschreibe ggf. gleichnamiges Preset
presets = [p for p in presets if p["name"] != preset["name"]]
presets.append(preset)
except Exception as e:
self.log_output.append(f"Fehler beim Laden von User-Preset {filename}: {str(e)}")
return presets
def create_default_presets(self):
defaults = [
{
"name": "Audio (MP3)",
"description": "Nur Audio als MP3",
"args": "-x --audio-format mp3",
"has_series_template": False,
"is_audio": True,
"source": "default"
},
{
"name": "Video (Best)",
"description": "Beste Videoqualität",
"args": "-f bestvideo+bestaudio",
"has_series_template": False,
"is_audio": False,
"source": "default"
}
]
for preset in defaults:
filename = f"{preset['name'].lower().replace(' ', '_')}.json"
try:
with open(os.path.join(PRESETS_DIR, filename), 'w') as f:
json.dump(preset, f, indent=4)
except Exception as e:
self.log_output.append(f"Fehler beim Erstellen von Standard-Preset {preset['name']}: {str(e)}")
def update_preset_combo(self):
self.preset_combo.clear()
hide_defaults = self.config.get("hide_default_presets", False)
for preset in self.presets:
if hide_defaults and preset.get("source") == "default":
continue
self.preset_combo.addItem(preset["name"])
# Letztes verwendetes Preset auswählen
if self.config["last_preset"]:
index = self.preset_combo.findText(self.config["last_preset"])
if index >= 0:
self.preset_combo.setCurrentIndex(index)
def get_current_preset(self):
if self.preset_combo.currentIndex() >= 0:
preset_name = self.preset_combo.currentText()
for preset in self.presets:
if preset["name"] == preset_name:
return preset
return None
def add_preset(self):
dialog = PresetDialog(self)
if dialog.exec_() == PresetDialog.Accepted:
preset_data = dialog.get_preset_data()
if not preset_data["name"]:
QMessageBox.warning(self, "Fehler", "Der Preset-Name darf nicht leer sein.")
return
for preset in self.presets:
if preset["name"] == preset_data["name"]:
QMessageBox.warning(self, "Fehler", f"Ein Preset mit dem Namen '{preset_data['name']}' existiert bereits.")
return
preset_data["source"] = "user"
self.presets.append(preset_data)
# Speichern des Presets im User-Presets-Ordner
user_presets_dir = get_user_presets_dir()
filename = f"{preset_data['name'].lower().replace(' ', '_')}.json"
try:
with open(os.path.join(user_presets_dir, filename), 'w', encoding='utf-8') as f:
json.dump(preset_data, f, indent=4, ensure_ascii=False)
self.update_preset_combo()
self.preset_combo.setCurrentText(preset_data["name"])
self.update_cmd_preview()
except Exception as e:
self.log_output.append(f"Fehler beim Speichern des Presets: {str(e)}")
def edit_preset(self):
current_preset = self.get_current_preset()
if not current_preset:
QMessageBox.warning(self, "Warnung", "Kein Preset ausgewählt.")
return
dialog = PresetDialog(self, current_preset)
if dialog.exec_() == PresetDialog.Accepted:
new_preset_data = dialog.get_preset_data()
if not new_preset_data["name"]:
QMessageBox.warning(self, "Fehler", "Der Preset-Name darf nicht leer sein.")
return
if new_preset_data["name"] != current_preset["name"]:
for preset in self.presets:
if preset["name"] == new_preset_data["name"] and preset != current_preset:
QMessageBox.warning(self, "Fehler", f"Ein Preset mit dem Namen '{new_preset_data['name']}' existiert bereits.")
return
if new_preset_data["name"] != current_preset["name"]:
old_filename = f"{current_preset['name'].lower().replace(' ', '_')}.json"
try:
os.remove(os.path.join(get_user_presets_dir(), old_filename))
except Exception as e:
self.log_output.append(f"Warnung: Konnte alte Preset-Datei nicht löschen: {str(e)}")
for i, preset in enumerate(self.presets):
if preset["name"] == current_preset["name"]:
new_preset_data["source"] = "user"
self.presets[i] = new_preset_data
break
filename = f"{new_preset_data['name'].lower().replace(' ', '_')}.json"
try:
with open(os.path.join(get_user_presets_dir(), filename), 'w', encoding='utf-8') as f:
json.dump(new_preset_data, f, indent=4, ensure_ascii=False)
old_current_index = self.preset_combo.currentIndex()
self.update_preset_combo()
new_index = self.preset_combo.findText(new_preset_data["name"])
if new_index >= 0:
self.preset_combo.setCurrentIndex(new_index)
else:
self.preset_combo.setCurrentIndex(old_current_index)
self.update_cmd_preview()
except Exception as e:
self.log_output.append(f"Fehler beim Speichern des Presets: {str(e)}")
def delete_preset(self):
current_preset = self.get_current_preset()
if not current_preset:
QMessageBox.warning(self, "Warnung", "Kein Preset ausgewählt.")
return
reply = QMessageBox.question(
self,
"Preset löschen",
f"Möchten Sie das Preset '{current_preset['name']}' wirklich löschen?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.presets = [p for p in self.presets if p["name"] != current_preset["name"]]
filename = f"{current_preset['name'].lower().replace(' ', '_')}.json"
try:
os.remove(os.path.join(get_user_presets_dir(), filename))
self.update_preset_combo()
self.update_cmd_preview()
except Exception as e:
self.log_output.append(f"Fehler beim Löschen des Presets: {str(e)}")
def open_optionen_dialog(self):
dialog = OptionenDialog(
self.config["output_dir"],
self.config["use_local_ytdlp"],
self,
self.config.get("ytdlp_flags", DEFAULT_CONFIG["ytdlp_flags"])
)
if dialog.exec_() == OptionenDialog.Accepted:
output_dir, use_local_ytdlp, ytdlp_flags, hide_defaults, mkvmerge_path, enable_adn_tab, ffmpeg_path = dialog.get_values()
self.config["output_dir"] = output_dir
self.config["use_local_ytdlp"] = use_local_ytdlp
self.config["ytdlp_flags"] = ytdlp_flags
self.config["hide_default_presets"] = hide_defaults
self.config["mkvmerge_path"] = mkvmerge_path
self.config["enable_adn_tab"] = enable_adn_tab
self.config["ffmpeg_path"] = ffmpeg_path
self.update_preset_combo()
self.update_cmd_preview()
def preset_changed(self):
preset = self.get_current_preset()
if not preset:
self.series_group.setVisible(False)
return
# Aktualisiere Serien-UI basierend auf dem Preset
has_series = preset.get("has_series_template", False)
self.series_group.setVisible(has_series)
# Setze den custom_path_input immer zurück, unabhängig vom Preset-Typ
self.custom_path_input.setText(preset.get("custom_path", ""))
if has_series:
self.series_input.setText(preset.get("series", ""))
self.season_input.setText(preset.get("season", "1"))
self.episode_input.setText(preset.get("episode", "1"))
self.update_cmd_preview()
def update_cmd_preview(self):
preset = self.get_current_preset()
if not preset:
self.cmd_preview.setText("Kein Preset ausgewählt.")
return
# Zeige Preset-Informationen in der Ausgabe an
self.show_preset_info(preset)
if self.config["use_local_ytdlp"]:
ytdlp_path = os.path.join(get_base_path(), "bin", "yt-dlp.exe")
if not os.path.exists(ytdlp_path):
ytdlp_path = "yt-dlp" # Fallback auf PATH
else:
ytdlp_path = "yt-dlp"
cmd = [ytdlp_path]
flags = self.config.get("ytdlp_flags", {})
is_audio = preset.get("is_audio", False)
# Login-Optionen
if preset.get("username"):
cmd.extend(["-u", preset["username"]])
if preset.get("password"):
cmd.extend(["-p", preset["password"]])
# Referer
if preset.get("referer"):
cmd.append(f"--referer={preset['referer']}")
# HLS-ffmpeg
if preset.get("hls_ffmpeg"):
cmd.extend(["--downloader", "ffmpeg", "--hls-use-mpegts"])
# FFmpeg-Pfad
if flags.get("use_ffmpeg_location") and self.config.get("ffmpeg_path"):
cmd.extend(["--ffmpeg-location", self.config.get("ffmpeg_path")])
# Untertitel
if preset.get("sublang"):
cmd.extend(["--sub-lang", preset["sublang"]])
if preset.get("embed_subs"):
cmd.append("--embed-subs")
if preset.get("subformat"):
cmd.extend(["--convert-subs", preset["subformat"]])
if flags.get("ignore_config"):
cmd.append("--ignore-config")
if flags.get("remux_mkv") and not is_audio:
cmd.extend(["--remux-video", "mkv"])
if flags.get("embed_metadata"):
cmd.append("--embed-metadata")
if preset["args"]:
args = []
in_quotes = False
current_arg = ""
for char in preset["args"]:
if char == '"' or char == "'":
in_quotes = not in_quotes
current_arg += char
elif char.isspace() and not in_quotes:
if current_arg:
args.append(current_arg)
current_arg = ""
else:
current_arg += char
if current_arg:
args.append(current_arg)
cmd.extend(args)
# Wenn ein eigener Pfad im Preset definiert ist, diesen verwenden
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
output_dir = custom_path if custom_path else self.config["output_dir"]
# Generiere den Dateinamen ohne Debug-Ausgaben
if preset.get("has_series_template", False):
template = preset.get("series_template", SERIES_TEMPLATE)
series = self.series_input.text() or preset.get("series", "")
season = self.season_input.text() or preset.get("season", "1")
episode = self.episode_input.text() or preset.get("episode", "1")
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
# Stelle sicher, dass der Pfad mit einem Trennzeichen endet, wenn er nicht leer ist
if custom_path and not custom_path.endswith("/") and not custom_path.endswith("\\"):
custom_path += "/" # Verwende / als universellen Pfadtrenner
output_name = template
output_name = output_name.replace("{series}", series)
output_name = output_name.replace("{season}", season)
output_name = output_name.replace("{episode}", episode)
# Entferne {path} Platzhalter, da custom_path jetzt als output_dir verwendet wird
output_name = output_name.replace("{path}", "")
# {extension} durch .%(ext)s ersetzen (auch rückwärtskompatibel)
output_name = output_name.replace("{extension}", ".%(ext)s")
# Falls jemand %(ext)s nicht im Template hat, ergänzen wir es am Ende
if "%(ext)s" not in output_name:
output_name += ".%(ext)s"
# Entferne doppelte Schrägstriche
while "//" in output_name:
output_name = output_name.replace("//", "/")
while "\\\\" in output_name:
output_name = output_name.replace("\\\\", "\\")
output_filename = output_name
else:
# Verwende benutzerdefinierte Ausgabevorlage, wenn in den Presets aktiviert
if preset.get("custom_output_template", False) and preset.get("output_template"):
output_filename = preset.get("output_template")
else:
output_filename = "%(title)s.%(ext)s"
# Füge den Ausgabepfad zum Befehl hinzu
if output_dir:
output_path = os.path.join(output_dir, output_filename)
cmd.extend(["-o", output_path])
else:
cmd.extend(["-o", output_filename])
url = self.url_input.text() or "[URL]"
cmd.append(url)
formatted_cmd = []
for arg in cmd:
if " " in arg and not (arg.startswith('"') and arg.endswith('"')) and not (arg.startswith("'") and arg.endswith("'")):
formatted_cmd.append(f'"{arg}"')
else:
formatted_cmd.append(arg)
# Maskiere sensible Daten in der Befehlsvorschau
command_string = " ".join(formatted_cmd)
masked_command = mask_sensitive_data(command_string)
self.cmd_preview.setText(masked_command)
def show_preset_info(self, preset):
"""Zeigt Informationen zum ausgewählten Preset in der Ausgabe an."""
self.log_output.clear()
self.log_output.append(f"Preset: {preset['name']}")
if preset.get("description"):
self.log_output.append(f"Beschreibung: {preset['description']}")
# Ausgabepfad
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
output_dir = custom_path if custom_path else self.config["output_dir"]
self.log_output.append(f"Ausgabeverzeichnis: {output_dir or '(Standard)'}")
# Serien-Informationen
if preset.get("has_series_template", False):
self.log_output.append("\nSerien-Einstellungen:")
template = preset.get("series_template", SERIES_TEMPLATE)
series = self.series_input.text() or preset.get("series", "")
season = self.season_input.text() or preset.get("season", "1")
episode = self.episode_input.text() or preset.get("episode", "1")
self.log_output.append(f" Template: {template}")
self.log_output.append(f" Serie: {series}")
self.log_output.append(f" Staffel: {season}")
self.log_output.append(f" Folge: {episode}")
# Generiere Beispiel-Dateinamen
output_name = template
output_name = output_name.replace("{series}", series)
output_name = output_name.replace("{season}", season)
output_name = output_name.replace("{episode}", episode)
output_name = output_name.replace("{path}", "")
output_name = output_name.replace("{extension}", ".mp4")
self.log_output.append(f" Beispiel-Dateiname: {output_name}")
# Preset-Argumente
self.log_output.append("\nArgumente:")
self.log_output.append(f" {preset['args']}")
# Besondere Eigenschaften
special_features = []
if preset.get("is_audio", False):
special_features.append("Audio-Preset")
if preset.get("username"):
special_features.append("Authentifizierung")
if preset.get("referer"):
special_features.append("Referer")
if preset.get("hls_ffmpeg", False):
special_features.append("HLS-ffmpeg")
if preset.get("sublang") or preset.get("embed_subs") or preset.get("subformat"):
special_features.append("Untertitel")
if preset.get("use_format_selection", False):
special_features.append("Format-Auswahl")
if preset.get("use_dual_audio", False):
special_features.append("Dual-Audio")
if special_features:
self.log_output.append("\nBesondere Eigenschaften:")
self.log_output.append(f" {', '.join(special_features)}")
def get_output_filename(self, preset):
"""Generiert den Ausgabedateinamen basierend auf dem Preset und den aktuellen Einstellungen."""
if preset.get("has_series_template", False):
template = preset.get("series_template", SERIES_TEMPLATE)
series = self.series_input.text() or preset.get("series", "")
season = self.season_input.text() or preset.get("season", "1")
episode = self.episode_input.text() or preset.get("episode", "1")
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
# Stelle sicher, dass der Pfad mit einem Trennzeichen endet, wenn er nicht leer ist
if custom_path and not custom_path.endswith("/") and not custom_path.endswith("\\"):
custom_path += "/" # Verwende / als universellen Pfadtrenner
output_name = template
output_name = output_name.replace("{series}", series)
output_name = output_name.replace("{season}", season)
output_name = output_name.replace("{episode}", episode)
# Entferne {path} Platzhalter, da custom_path jetzt als output_dir verwendet wird
output_name = output_name.replace("{path}", "")
# {extension} durch .%(ext)s ersetzen (auch rückwärtskompatibel)
output_name = output_name.replace("{extension}", ".%(ext)s")
# Falls jemand %(ext)s nicht im Template hat, ergänzen wir es am Ende
if "%(ext)s" not in output_name:
output_name += ".%(ext)s"
# Entferne doppelte Schrägstriche
while "//" in output_name:
output_name = output_name.replace("//", "/")
while "\\\\" in output_name:
output_name = output_name.replace("\\\\", "\\")
return output_name
# Im Nicht-Serienfall einfach den Standardnamen zurückgeben
return "%(title)s.%(ext)s"
def start_download(self):
# Wenn bereits ein Download läuft und der Button als "Abbrechen" angezeigt wird
if self.download_thread and self.download_thread.isRunning():
self.abort_download()
return
url = self.url_input.text()
if not url:
QMessageBox.warning(self, "Fehler", "Bitte geben Sie eine URL ein.")
return
preset = self.get_current_preset()
if not preset:
QMessageBox.warning(self, "Fehler", "Bitte wählen Sie ein Preset aus.")
return
# Wenn im Preset ein eigener Pfad definiert ist, diesen bevorzugen
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
# Wenn custom_path gesetzt ist, verwenden wir diesen anstelle des standard output_dir
output_dir = custom_path if custom_path else self.config["output_dir"]
use_local_ytdlp = self.config["use_local_ytdlp"]
flags = self.config.get("ytdlp_flags", {})
is_audio = preset.get("is_audio", False)
use_dual_audio = preset.get("use_dual_audio", False)
# Aktualisiere die Serien-Informationen im Preset für den Download
# (nur temporär für diesen Download, nicht dauerhaft speichern)
if preset.get("has_series_template", False):
updated_preset = preset.copy()
updated_preset["series"] = self.series_input.text() or preset.get("series", "")
updated_preset["season"] = self.season_input.text() or preset.get("season", "1")
updated_preset["episode"] = self.episode_input.text() or preset.get("episode", "1")
else:
updated_preset = preset
extra_args = []
if preset.get("username"):
extra_args.extend(["-u", preset["username"]])
if preset.get("password"):
extra_args.extend(["-p", preset["password"]])
# Referer
if preset.get("referer"):
extra_args.append(f"--referer={preset['referer']}")
# HLS-ffmpeg
if preset.get("hls_ffmpeg"):
extra_args.extend(["--downloader", "ffmpeg", "--hls-use-mpegts"])
# FFmpeg-Pfad
if flags.get("use_ffmpeg_location") and self.config.get("ffmpeg_path"):
extra_args.extend(["--ffmpeg-location", self.config.get("ffmpeg_path")])
# Untertitel
if preset.get("sublang"):
extra_args.extend(["--sub-lang", preset["sublang"]])
if preset.get("embed_subs"):
extra_args.append("--embed-subs")
if preset.get("subformat"):
extra_args.extend(["--convert-subs", preset["subformat"]])
# Global Flags
if flags.get("ignore_config"):
extra_args.append("--ignore-config")
if flags.get("remux_mkv") and not is_audio and not use_dual_audio:
extra_args.extend(["--remux-video", "mkv"])
if flags.get("embed_metadata"):
extra_args.append("--embed-metadata")
# Prüfe auf Dual-Audio-Muxing
if preset.get("use_dual_audio", False) and not self.config.get("mkvmerge_path"):
QMessageBox.warning(self, "Fehler", "Für Dual-Audio Muxing muss der MKVMerge-Pfad in den Optionen angegeben werden.")
return
cmd_args = " ".join(extra_args) + (" " + preset["args"] if preset["args"] else "")
# Bestimme den Ausgabedateinamen
output_filename = None
if preset.get("has_series_template", False):
output_filename = self.get_output_filename(preset)
elif preset.get("custom_output_template", False) and preset.get("output_template"):
output_filename = preset.get("output_template")
self.download_thread = DownloadThread(
url, output_dir, cmd_args, use_local_ytdlp, output_filename,
updated_preset, self.config
)
# Signal-Verbindungen
self.download_thread.update_signal.connect(self.log_output.append)
self.download_thread.finished_signal.connect(self.download_finished)
self.download_thread.format_selection_signal.connect(self.show_formats)
self.download_thread.format_id_input_signal.connect(self.get_format_id_from_user)
# UI-Zustand ändern
self.download_btn.setText("Download abbrechen")
self.download_btn.setStyleSheet("background-color: #ff6b6b; color: white;")
self.queue_btn.setEnabled(False) # Queue-Button deaktivieren während des Downloads
# Switch zu Log-Tab
self.tabs.setCurrentIndex(0)
# Download starten
self.download_thread.start()
# Config speichern
self.save_config()
def abort_download(self):
"""Bricht den aktuellen Download ab."""
if self.download_thread and self.download_thread.isRunning():
self.download_thread.stop()
self.log_output.append("Download-Abbruch angefordert...")
def download_finished(self, success, message):
# UI-Zustand zurücksetzen
self.download_btn.setText("Download starten")
self.download_btn.setStyleSheet("")
self.queue_btn.setEnabled(True) # Queue-Button wieder aktivieren
if success:
self.log_output.append(message)
QMessageBox.information(self, "Erfolg", message)
else:
self.log_output.append(f"Fehler: {message}")
QMessageBox.warning(self, "Fehler", message)
# Queue-Buttons nach Download aktualisieren
self.update_queue_buttons()
# Wenn aus der Queue, dann nächsten Download starten
if self.current_queue_item and self.download_queue:
self.process_next_queue_item()
else:
self.current_queue_item = None
def show_formats(self, formats):
"""Zeigt die verfügbaren Formate in einem Dialog an."""
# Hier könnte man einen Dialog mit den Formaten anzeigen
# Für einfachheit zeigen wir sie erstmal im Log
self.log_output.append("\n=== Verfügbare Formate ===")
for format_line in formats:
self.log_output.append(format_line)
self.log_output.append("=== Ende der Formate ===\n")
def get_format_id_from_user(self):
"""Fordert den Benutzer zur Eingabe einer Format-ID auf."""
dialog = QInputDialog(self)
dialog.setWindowTitle("Format-ID eingeben")
dialog.setLabelText("Bitte gib die gewünschte Format-ID ein:")
dialog.setInputMode(QInputDialog.TextInput)
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint) # Fragezeichen entfernen
# Icon für den Dialog setzen
set_window_icon(dialog)
ok = dialog.exec_()
format_id = dialog.textValue()
if ok and format_id and self.download_thread:
self.download_thread.set_format_id(format_id)
elif self.download_thread:
# Abbrechen
self.download_thread.set_format_id("abort")
def add_to_queue(self):
"""Fügt den aktuellen Download zur Queue hinzu."""
url = self.url_input.text()
if not url:
QMessageBox.warning(self, "Fehler", "Bitte geben Sie eine URL ein.")
return
preset = self.get_current_preset()
if not preset:
QMessageBox.warning(self, "Fehler", "Bitte wählen Sie ein Preset aus.")
return
# Wenn im Preset ein eigener Pfad definiert ist, diesen bevorzugen
custom_path = self.custom_path_input.text() or preset.get("custom_path", "")
output_dir = custom_path if custom_path else self.config["output_dir"]
# Serieninformationen sammeln
series_info = {}
if preset.get("has_series_template", False):
series_info = {
"series": self.series_input.text() or preset.get("series", ""),
"season": self.season_input.text() or preset.get("season", "1"),
"episode": self.episode_input.text() or preset.get("episode", "1")
}
# Bestimme den Ausgabedateinamen
output_filename = None
if preset.get("has_series_template", False):
output_filename = self.get_output_filename(preset)
elif preset.get("custom_output_template", False) and preset.get("output_template"):
output_filename = preset.get("output_template")
# Queue-Item erstellen
queue_item = QueueItem(
url=url,
preset_data=preset.copy(), # Kopie des Presets mit den aktuellen Werten
output_dir=output_dir,
output_filename=output_filename,
series_info=series_info,
use_local_ytdlp=self.config["use_local_ytdlp"]
)
# Zur Queue hinzufügen
self.download_queue.append(queue_item)
self.update_queue_list()
self.update_queue_buttons()
self.save_queue()
# Tab zur Queue wechseln
self.tabs.setCurrentIndex(1)
QMessageBox.information(self, "Zur Queue hinzugefügt",
f"Der Download wurde zur Queue hinzugefügt.\nQueue enthält jetzt {len(self.download_queue)} Elemente.")
def update_queue_list(self):
"""Aktualisiert die Queue-Liste in der UI."""
self.queue_list.clear()
for item in self.download_queue:
list_item = QListWidgetItem(f"{item.status}: {item.get_display_name()}")
list_item.setData(Qt.UserRole, item.id)
self.queue_list.addItem(list_item)
def update_queue_buttons(self):
"""Aktualisiert den Zustand der Queue-Buttons."""
has_items = len(self.download_queue) > 0
is_downloading = self.download_thread and self.download_thread.isRunning()
self.start_queue_btn.setEnabled(has_items and not is_downloading)
self.clear_queue_btn.setEnabled(has_items)
# Button-Text anpassen
if is_downloading and self.current_queue_item:
self.start_queue_btn.setText("Queue läuft...")
else:
self.start_queue_btn.setText("Queue starten")
def start_queue(self):
"""Startet die Download-Queue."""
if not self.download_queue:
QMessageBox.information(self, "Queue leer", "Die Download-Queue ist leer.")
return
if self.download_thread and self.download_thread.isRunning():
QMessageBox.warning(self, "Download läuft", "Es läuft bereits ein Download.")
return
# Starte den ersten Download in der Queue
self.process_next_queue_item()
def process_next_queue_item(self):
"""Verarbeitet das nächste Element in der Queue."""
if not self.download_queue:
QMessageBox.information(self, "Queue abgeschlossen", "Alle Downloads in der Queue wurden abgeschlossen.")
self.current_queue_item = None
self.update_queue_buttons()
return
# Hole das nächste Element aus der Queue
self.current_queue_item = self.download_queue.pop(0)
self.current_queue_item.status = "Wird heruntergeladen..."
# Aktualisiere die UI
self.update_queue_list()
self.update_queue_buttons()
self.save_queue()
# Switch zu Log-Tab
self.tabs.setCurrentIndex(0)
# Logge den Start
self.log_output.append(f"\n=== Queue-Download gestartet ===")
self.log_output.append(f"URL: {self.current_queue_item.url}")
self.log_output.append(f"Preset: {self.current_queue_item.preset_data.get('name', 'Unbekannt')}")
if self.current_queue_item.series_info:
series_info = self.current_queue_item.series_info
self.log_output.append(f"Serie: {series_info.get('series', '')} S{series_info.get('season', '')}E{series_info.get('episode', '')}")
self.log_output.append("=" * 35)
# Erstelle Download-Thread mit Queue-Daten
preset = self.current_queue_item.preset_data
# Erweitere das Preset um die Serieninfos aus der Queue
if self.current_queue_item.series_info:
preset.update(self.current_queue_item.series_info)
# Erstelle Command-Args wie im normalen Download
flags = self.config.get("ytdlp_flags", {})
is_audio = preset.get("is_audio", False)
use_dual_audio = preset.get("use_dual_audio", False)
extra_args = []
if preset.get("username"):
extra_args.extend(["-u", preset["username"]])
if preset.get("password"):
extra_args.extend(["-p", preset["password"]])
if preset.get("referer"):
extra_args.append(f"--referer={preset['referer']}")
if preset.get("hls_ffmpeg"):
extra_args.extend(["--downloader", "ffmpeg", "--hls-use-mpegts"])
if flags.get("use_ffmpeg_location") and self.config.get("ffmpeg_path"):
extra_args.extend(["--ffmpeg-location", self.config.get("ffmpeg_path")])
if preset.get("sublang"):
extra_args.extend(["--sub-lang", preset["sublang"]])
if preset.get("embed_subs"):
extra_args.append("--embed-subs")
if preset.get("subformat"):
extra_args.extend(["--convert-subs", preset["subformat"]])
if flags.get("ignore_config"):
extra_args.append("--ignore-config")
if flags.get("remux_mkv") and not is_audio and not use_dual_audio:
extra_args.extend(["--remux-video", "mkv"])
if flags.get("embed_metadata"):
extra_args.append("--embed-metadata")
cmd_args = " ".join(extra_args) + (" " + preset["args"] if preset["args"] else "")
# Erstelle und starte Download-Thread
self.download_thread = DownloadThread(
self.current_queue_item.url,
self.current_queue_item.output_dir,
cmd_args,
self.current_queue_item.use_local_ytdlp,
self.current_queue_item.output_filename,
preset,
self.config
)
# Verbinde Signale
self.download_thread.update_signal.connect(self.log_output.append)
self.download_thread.finished_signal.connect(self.queue_download_finished)
self.download_thread.format_selection_signal.connect(self.show_formats)
self.download_thread.format_id_input_signal.connect(self.get_format_id_from_user)
# Starte Download
self.download_thread.start()
def queue_download_finished(self, success, message):
"""Callback für abgeschlossene Queue-Downloads."""
if success:
self.log_output.append(f"Queue-Download erfolgreich: {message}")
else:
self.log_output.append(f"Queue-Download fehlgeschlagen: {message}")
# Verarbeite das nächste Element in der Queue nach einer kurzen Pause
# (Damit der Benutzer die Ausgabe sehen kann)
from PyQt5.QtCore import QTimer
QTimer.singleShot(1000, self.process_next_queue_item)
def show_queue_context_menu(self, position):
"""Zeigt ein Kontextmenü für die Queue-Liste."""
item = self.queue_list.itemAt(position)
if not item:
return
menu = QMenu(self)
remove_action = QAction("Aus Queue entfernen", self)
remove_action.triggered.connect(lambda: self.remove_from_queue(item))
menu.addAction(remove_action)
menu.exec_(self.queue_list.mapToGlobal(position))
def remove_from_queue(self, list_item):
"""Entfernt ein Element aus der Queue."""
item_id = list_item.data(Qt.UserRole)
self.download_queue = [item for item in self.download_queue if item.id != item_id]
self.update_queue_list()
self.update_queue_buttons()
self.save_queue()
def clear_queue(self):
"""Leert die Download-Queue nach Bestätigung."""
if not self.download_queue:
return
reply = QMessageBox.question(
self,
"Queue leeren",
"Möchten Sie wirklich die gesamte Download-Queue leeren?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.download_queue.clear()
self.update_queue_list()
self.update_queue_buttons()
self.save_queue()
def save_queue(self):
"""Speichert die aktuelle Queue in eine Datei."""
queue_file = os.path.join(get_user_data_dir(), "download_queue.json")
try:
queue_data = [item.to_dict() for item in self.download_queue]
with open(queue_file, 'w', encoding='utf-8') as f:
json.dump(queue_data, f, indent=4, ensure_ascii=False)
except Exception as e:
self.log_output.append(f"Fehler beim Speichern der Queue: {str(e)}")
def load_queue(self):
"""Lädt die Queue aus einer gespeicherten Datei."""
queue_file = os.path.join(get_user_data_dir(), "download_queue.json")
if not os.path.exists(queue_file):
return
try:
with open(queue_file, 'r', encoding='utf-8') as f:
queue_data = json.load(f)
self.download_queue = [QueueItem.from_dict(item_data) for item_data in queue_data]
self.update_queue_list()
self.update_queue_buttons()
except Exception as e:
self.log_output.append(f"Fehler beim Laden der Queue: {str(e)}")
def closeEvent(self, event):
"""Wird beim Schließen der Anwendung aufgerufen."""
# Speichere die Konfiguration
self.save_config()
# Speichere die Queue
self.save_queue()
# Beende laufende Downloads
if self.download_thread and self.download_thread.isRunning():
self.download_thread.stop()
self.download_thread.wait(3000) # Warte max 3 Sekunden
event.accept()
def main():
app = QApplication(sys.argv)
# Icon für die Anwendung setzen
set_window_icon(app)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()