Add JDownloader-style settings UI and DLC package queue import
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 00:34:36 +01:00
parent fdfe390d0e
commit da18e3847e
3 changed files with 599 additions and 105 deletions

View File

@ -6,6 +6,7 @@ ueber Real-Debrid zu unrestricten und direkt auf deinen PC zu laden.
## Features ## Features
- Mehrere Links auf einmal (ein Link pro Zeile) - Mehrere Links auf einmal (ein Link pro Zeile)
- DLC Import (`.dlc`) ueber dcrypt.it inklusive Paket-Gruppierung
- Nutzt die Real-Debrid API (`/unrestrict/link`) - Nutzt die Real-Debrid API (`/unrestrict/link`)
- Download-Status pro Link - Download-Status pro Link
- Paket-Ansicht: Paket ist aufklappbar, darunter alle Einzel-Links - Paket-Ansicht: Paket ist aufklappbar, darunter alle Einzel-Links
@ -20,7 +21,11 @@ ueber Real-Debrid zu unrestricten und direkt auf deinen PC zu laden.
- Optionales Auto-Cleanup: Archivteile nach erfolgreichem Entpacken loeschen - Optionales Auto-Cleanup: Archivteile nach erfolgreichem Entpacken loeschen
- Speed-Limit (global oder pro Download), live aenderbar - Speed-Limit (global oder pro Download), live aenderbar
- Linklisten als `.txt` speichern/laden - Linklisten als `.txt` speichern/laden
- DLC-Dateien als Paketliste importieren (`DLC import`)
- `Entpacken nach` + optional `Unterordner erstellen (Paketname)` wie bei JDownloader - `Entpacken nach` + optional `Unterordner erstellen (Paketname)` wie bei JDownloader
- `Settings` (JDownloader-Style):
- Nach erfolgreichem Entpacken: keine / Papierkorb / unwiderruflich loeschen
- Bei Konflikten: ueberschreiben / ueberspringen / umbenennen
- ZIP-Passwort-Check mit `serienfans.org` und `serienjunkies.net` - ZIP-Passwort-Check mit `serienfans.org` und `serienjunkies.net`
- Multi-Part-RAR wird ueber `part1` entpackt (nur wenn alle Parts vorhanden sind) - Multi-Part-RAR wird ueber `part1` entpackt (nur wenn alle Parts vorhanden sind)
- Auto-Update Check ueber GitHub Releases (fuer .exe) - Auto-Update Check ueber GitHub Releases (fuer .exe)
@ -56,11 +61,13 @@ python real_debrid_downloader_gui.py
6. Optional `Hybrid-Entpacken` und `Cleanup` setzen 6. Optional `Hybrid-Entpacken` und `Cleanup` setzen
7. Parallel-Wert setzen (z. B. 20) 7. Parallel-Wert setzen (z. B. 20)
8. Optional Speed-Limit setzen (KB/s, Modus `global` oder `per_download`) 8. Optional Speed-Limit setzen (KB/s, Modus `global` oder `per_download`)
9. Links einfuegen oder per `Links laden` aus `.txt` importieren 9. Links einfuegen oder per `Links laden` / `DLC import` importieren
10. `Download starten` klicken 10. `Download starten` klicken
Wenn du 20 Links einfuegst, werden sie als ein Paket behandelt. Downloads landen in einem Paketordner. Beim Entpacken kann derselbe Paketname automatisch als Unterordner genutzt werden. Wenn du 20 Links einfuegst, werden sie als ein Paket behandelt. Downloads landen in einem Paketordner. Beim Entpacken kann derselbe Paketname automatisch als Unterordner genutzt werden.
Bei DLC-Import mit vielen Paketen setzt die App automatisch Paketmarker (`# package: ...`) und verarbeitet die Pakete in einer Queue.
## Auto-Update (GitHub) ## Auto-Update (GitHub)
1. Standard-Repo ist bereits gesetzt: `Sucukdeluxe/real-debrid-downloader` 1. Standard-Repo ist bereits gesetzt: `Sucukdeluxe/real-debrid-downloader`
@ -73,7 +80,7 @@ Hinweis: Beim Python-Skript gibt es nur einen Release-Hinweis, kein Self-Replace
## Release Build (.exe) ## Release Build (.exe)
```bash ```bash
./build_exe.ps1 -Version 1.0.9 ./build_exe.ps1 -Version 1.1.0
``` ```
Danach liegt die App unter `dist/Real-Debrid-Downloader/`. Danach liegt die App unter `dist/Real-Debrid-Downloader/`.
@ -94,7 +101,6 @@ Danach liegt die App unter `dist/Real-Debrid-Downloader/`.
## App-Icon ## App-Icon
- Das Projekt nutzt `assets/app_icon.png` (aus deinem `Downloads/abc.png`)
- Das Projekt nutzt `assets/app_icon.png` (aus deinem aktuellen Downloads-Icon) - Das Projekt nutzt `assets/app_icon.png` (aus deinem aktuellen Downloads-Icon)
- Beim Build wird automatisch `assets/app_icon.ico` erzeugt - Beim Build wird automatisch `assets/app_icon.ico` erzeugt

View File

@ -1,4 +1,5 @@
import json import json
import html
import queue import queue
import re import re
import shutil import shutil
@ -25,19 +26,29 @@ try:
except ImportError: except ImportError:
pyzipper = None pyzipper = None
try:
from send2trash import send2trash
except ImportError:
send2trash = None
API_BASE_URL = "https://api.real-debrid.com/rest/1.0" API_BASE_URL = "https://api.real-debrid.com/rest/1.0"
CONFIG_FILE = Path(__file__).with_name("rd_downloader_config.json") CONFIG_FILE = Path(__file__).with_name("rd_downloader_config.json")
CHUNK_SIZE = 1024 * 512 CHUNK_SIZE = 1024 * 512
APP_NAME = "Real-Debrid Downloader GUI" APP_NAME = "Real-Debrid Downloader GUI"
APP_VERSION = "1.0.9" APP_VERSION = "1.1.0"
DEFAULT_UPDATE_REPO = "Sucukdeluxe/real-debrid-downloader" DEFAULT_UPDATE_REPO = "Sucukdeluxe/real-debrid-downloader"
DEFAULT_RELEASE_ASSET = "Real-Debrid-Downloader-win64.zip" DEFAULT_RELEASE_ASSET = "Real-Debrid-Downloader-win64.zip"
DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"
REQUEST_RETRIES = 3 REQUEST_RETRIES = 3
RETRY_BACKOFF_SECONDS = 1.2 RETRY_BACKOFF_SECONDS = 1.2
RETRY_HTTP_STATUS = {408, 429, 500, 502, 503, 504} RETRY_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
INVALID_FILENAME_CHARS = '<>:"/\\|?*' INVALID_FILENAME_CHARS = '<>:"/\\|?*'
ARCHIVE_PASSWORDS = ("serienfans.org", "serienjunkies.net") ARCHIVE_PASSWORDS = ("serienfans.org", "serienjunkies.net")
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE) RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
PACKAGE_MARKER_RE = re.compile(r"^\s*#\s*package\s*:\s*(.+?)\s*$", re.IGNORECASE)
SPEED_MODE_CHOICES = ("global", "per_download")
EXTRACT_CONFLICT_CHOICES = ("overwrite", "skip", "rename", "ask")
CLEANUP_MODE_CHOICES = ("none", "trash", "delete")
SEVEN_ZIP_CANDIDATES = ( SEVEN_ZIP_CANDIDATES = (
"7z", "7z",
"7za", "7za",
@ -68,6 +79,26 @@ class ExtractJob:
source_files: list[Path] source_files: list[Path]
@dataclass
class DownloadPackage:
name: str
links: list[str]
CLEANUP_LABELS = {
"none": "keine Archive loeschen",
"trash": "Archive in den Papierkorb verschieben, wenn moeglich",
"delete": "Archive unwiderruflich loeschen",
}
CONFLICT_LABELS = {
"overwrite": "Datei ueberschreiben",
"skip": "Datei ueberspringen",
"rename": "neue Datei automatisch umbenennen",
"ask": "Nachfragen (im Hintergrund: umbenennen)",
}
def filename_from_url(url: str) -> str: def filename_from_url(url: str) -> str:
path = urlparse(url).path path = urlparse(url).path
if not path: if not path:
@ -318,12 +349,20 @@ def find_unrar_executable() -> str | None:
return find_executable(UNRAR_CANDIDATES) return find_executable(UNRAR_CANDIDATES)
def merge_directory(source_dir: Path, destination_dir: Path) -> None: def merge_directory(source_dir: Path, destination_dir: Path, conflict_mode: str = "rename") -> None:
destination_dir.mkdir(parents=True, exist_ok=True) destination_dir.mkdir(parents=True, exist_ok=True)
for item in source_dir.iterdir(): for item in source_dir.iterdir():
target = destination_dir / item.name target = destination_dir / item.name
if target.exists(): if target.exists():
target = next_available_path(target) if conflict_mode == "overwrite":
if target.is_dir():
shutil.rmtree(target, ignore_errors=True)
else:
target.unlink(missing_ok=True)
elif conflict_mode == "skip":
continue
else:
target = next_available_path(target)
shutil.move(str(item), str(target)) shutil.move(str(item), str(target))
@ -403,7 +442,8 @@ class DownloaderApp(tk.Tk):
self.extract_dir_var = tk.StringVar(value=str(Path.home() / "Downloads" / "RealDebrid" / "_entpackt")) self.extract_dir_var = tk.StringVar(value=str(Path.home() / "Downloads" / "RealDebrid" / "_entpackt"))
self.create_extract_subfolder_var = tk.BooleanVar(value=True) self.create_extract_subfolder_var = tk.BooleanVar(value=True)
self.hybrid_extract_var = tk.BooleanVar(value=True) self.hybrid_extract_var = tk.BooleanVar(value=True)
self.cleanup_after_extract_var = tk.BooleanVar(value=False) self.cleanup_mode_var = tk.StringVar(value="none")
self.extract_conflict_mode_var = tk.StringVar(value="overwrite")
self.max_parallel_var = tk.IntVar(value=4) self.max_parallel_var = tk.IntVar(value=4)
self.speed_limit_kbps_var = tk.IntVar(value=0) self.speed_limit_kbps_var = tk.IntVar(value=0)
self.speed_limit_mode_var = tk.StringVar(value="global") self.speed_limit_mode_var = tk.StringVar(value="global")
@ -422,6 +462,8 @@ class DownloaderApp(tk.Tk):
self.ui_queue: queue.Queue = queue.Queue() self.ui_queue: queue.Queue = queue.Queue()
self.row_map: dict[int, str] = {} self.row_map: dict[int, str] = {}
self.package_row_id: str | None = None self.package_row_id: str | None = None
self.package_contexts: list[dict] = []
self.settings_window: tk.Toplevel | None = None
self.speed_events: deque[tuple[float, int]] = deque() self.speed_events: deque[tuple[float, int]] = deque()
self.parallel_limit_lock = threading.Lock() self.parallel_limit_lock = threading.Lock()
self.current_parallel_limit = 4 self.current_parallel_limit = 4
@ -533,11 +575,11 @@ class DownloaderApp(tk.Tk):
variable=self.hybrid_extract_var, variable=self.hybrid_extract_var,
).grid(row=5, column=0, columnspan=3, sticky="w", pady=(6, 0)) ).grid(row=5, column=0, columnspan=3, sticky="w", pady=(6, 0))
ttk.Checkbutton( settings_row = ttk.Frame(output_frame)
output_frame, settings_row.grid(row=6, column=0, columnspan=3, sticky="ew", pady=(6, 0))
text="Archive nach erfolgreichem Entpacken loeschen", settings_row.columnconfigure(0, weight=1)
variable=self.cleanup_after_extract_var, ttk.Label(settings_row, text="Entpack-Settings wie JDownloader").grid(row=0, column=0, sticky="w")
).grid(row=6, column=0, columnspan=3, sticky="w", pady=(6, 0)) ttk.Button(settings_row, text="Settings", command=self._open_settings_window).grid(row=0, column=1, sticky="e")
ttk.Label( ttk.Label(
output_frame, output_frame,
@ -547,11 +589,19 @@ class DownloaderApp(tk.Tk):
links_frame = ttk.LabelFrame(root, text="Links (ein Link pro Zeile)", padding=10) links_frame = ttk.LabelFrame(root, text="Links (ein Link pro Zeile)", padding=10)
links_frame.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) links_frame.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
links_frame.columnconfigure(0, weight=1) links_frame.columnconfigure(0, weight=1)
links_frame.rowconfigure(0, weight=1) links_frame.rowconfigure(1, weight=1)
links_actions = ttk.Frame(links_frame)
links_actions.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8))
ttk.Button(links_actions, text="Links laden", command=self._load_links_from_file).pack(side="left")
ttk.Button(links_actions, text="DLC import", command=self._import_dlc_file).pack(side="left", padx=(8, 0))
ttk.Button(links_actions, text="Links speichern", command=self._save_links_to_file).pack(side="left", padx=(8, 0))
ttk.Button(links_actions, text="Links leeren", command=self._clear_links).pack(side="left", padx=(8, 0))
self.links_text = tk.Text(links_frame, height=14, wrap="none") self.links_text = tk.Text(links_frame, height=14, wrap="none")
self.links_text.grid(row=0, column=0, sticky="nsew") self.links_text.grid(row=1, column=0, sticky="nsew")
links_scroll = ttk.Scrollbar(links_frame, orient="vertical", command=self.links_text.yview) links_scroll = ttk.Scrollbar(links_frame, orient="vertical", command=self.links_text.yview)
links_scroll.grid(row=0, column=1, sticky="ns") links_scroll.grid(row=1, column=1, sticky="ns")
self.links_text.configure(yscrollcommand=links_scroll.set) self.links_text.configure(yscrollcommand=links_scroll.set)
actions_frame = ttk.Frame(root) actions_frame = ttk.Frame(root)
@ -563,9 +613,8 @@ class DownloaderApp(tk.Tk):
self.stop_button = ttk.Button(actions_frame, text="Stop", command=self.stop_downloads, state="disabled") self.stop_button = ttk.Button(actions_frame, text="Stop", command=self.stop_downloads, state="disabled")
self.stop_button.pack(side="left", padx=(8, 0)) self.stop_button.pack(side="left", padx=(8, 0))
ttk.Button(actions_frame, text="Links leeren", command=self._clear_all_lists).pack(side="left", padx=(8, 0)) ttk.Button(actions_frame, text="Fortschritt leeren", command=self._clear_progress_only).pack(side="left", padx=(8, 0))
ttk.Button(actions_frame, text="Links laden", command=self._load_links_from_file).pack(side="left", padx=(8, 0)) ttk.Button(actions_frame, text="Settings", command=self._open_settings_window).pack(side="left", padx=(8, 0))
ttk.Button(actions_frame, text="Links speichern", command=self._save_links_to_file).pack(side="left", padx=(8, 0))
ttk.Label(actions_frame, text="Parallel:").pack(side="left", padx=(18, 6)) ttk.Label(actions_frame, text="Parallel:").pack(side="left", padx=(18, 6))
ttk.Spinbox(actions_frame, from_=1, to=50, width=5, textvariable=self.max_parallel_var).pack(side="left") ttk.Spinbox(actions_frame, from_=1, to=50, width=5, textvariable=self.max_parallel_var).pack(side="left")
@ -576,7 +625,7 @@ class DownloaderApp(tk.Tk):
speed_mode_box = ttk.Combobox( speed_mode_box = ttk.Combobox(
actions_frame, actions_frame,
textvariable=self.speed_limit_mode_var, textvariable=self.speed_limit_mode_var,
values=("global", "per_download"), values=SPEED_MODE_CHOICES,
width=12, width=12,
state="readonly", state="readonly",
) )
@ -643,6 +692,114 @@ class DownloaderApp(tk.Tk):
if selected: if selected:
self.extract_dir_var.set(selected) self.extract_dir_var.set(selected)
@staticmethod
def _normalize_cleanup_mode(value: str) -> str:
mode = str(value or "none").strip().lower()
return mode if mode in CLEANUP_MODE_CHOICES else "none"
@staticmethod
def _normalize_extract_conflict_mode(value: str) -> str:
mode = str(value or "overwrite").strip().lower()
return mode if mode in EXTRACT_CONFLICT_CHOICES else "overwrite"
@staticmethod
def _cleanup_label(mode: str) -> str:
return CLEANUP_LABELS.get(mode, CLEANUP_LABELS["none"])
@staticmethod
def _cleanup_mode_from_label(label: str) -> str:
text = str(label or "").strip()
for mode, mode_label in CLEANUP_LABELS.items():
if text == mode_label:
return mode
return "none"
@staticmethod
def _conflict_label(mode: str) -> str:
return CONFLICT_LABELS.get(mode, CONFLICT_LABELS["overwrite"])
@staticmethod
def _conflict_mode_from_label(label: str) -> str:
text = str(label or "").strip()
for mode, mode_label in CONFLICT_LABELS.items():
if text == mode_label:
return mode
return "overwrite"
def _open_settings_window(self) -> None:
if self.settings_window and self.settings_window.winfo_exists():
self.settings_window.focus_set()
return
window = tk.Toplevel(self)
window.title("Settings")
window.transient(self)
window.grab_set()
window.geometry("760x300")
window.minsize(700, 260)
self.settings_window = window
root = ttk.Frame(window, padding=12)
root.pack(fill="both", expand=True)
root.columnconfigure(1, weight=1)
ttk.Label(root, text="Nach erfolgreichem Entpacken:").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=(0, 10))
cleanup_label_var = tk.StringVar(value=self._cleanup_label(self._normalize_cleanup_mode(self.cleanup_mode_var.get())))
cleanup_combo = ttk.Combobox(
root,
textvariable=cleanup_label_var,
values=tuple(CLEANUP_LABELS.values()),
state="readonly",
width=58,
)
cleanup_combo.grid(row=0, column=1, sticky="ew", pady=(0, 10))
ttk.Label(root, text="Wenn Datei bereits existiert:").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(0, 10))
conflict_label_var = tk.StringVar(value=self._conflict_label(self._normalize_extract_conflict_mode(self.extract_conflict_mode_var.get())))
conflict_combo = ttk.Combobox(
root,
textvariable=conflict_label_var,
values=tuple(CONFLICT_LABELS.values()),
state="readonly",
width=58,
)
conflict_combo.grid(row=1, column=1, sticky="ew", pady=(0, 10))
ttk.Label(
root,
text="Downloadlinks in Archiven nach erfolgreichem Entpacken entfernen?",
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(0, 12))
buttons = ttk.Frame(root)
buttons.grid(row=3, column=0, columnspan=2, sticky="e")
ttk.Button(
buttons,
text="Speichern",
command=lambda: self._save_settings_window(cleanup_label_var.get(), conflict_label_var.get()),
).pack(side="right")
ttk.Button(buttons, text="Abbrechen", command=self._close_settings_window).pack(side="right", padx=(0, 8))
window.protocol("WM_DELETE_WINDOW", self._close_settings_window)
def _save_settings_window(self, cleanup_label: str, conflict_label: str) -> None:
cleanup_mode = self._cleanup_mode_from_label(cleanup_label)
conflict_mode = self._conflict_mode_from_label(conflict_label)
self.cleanup_mode_var.set(cleanup_mode)
self.extract_conflict_mode_var.set(conflict_mode)
self._save_config()
self._close_settings_window()
def _close_settings_window(self) -> None:
if self.settings_window and self.settings_window.winfo_exists():
window = self.settings_window
try:
window.grab_release()
except Exception:
pass
self.settings_window = None
window.destroy()
def _clear_links(self) -> None: def _clear_links(self) -> None:
self.links_text.delete("1.0", "end") self.links_text.delete("1.0", "end")
@ -664,6 +821,7 @@ class DownloaderApp(tk.Tk):
self.table.delete(*self.table.get_children()) self.table.delete(*self.table.get_children())
self.row_map.clear() self.row_map.clear()
self.package_row_id = None self.package_row_id = None
self.package_contexts = []
def _set_links_text_lines(self, lines: list[str]) -> None: def _set_links_text_lines(self, lines: list[str]) -> None:
content = "\n".join(line for line in lines if line.strip()) content = "\n".join(line for line in lines if line.strip())
@ -697,14 +855,23 @@ class DownloaderApp(tk.Tk):
if not selected: if not selected:
return return
if self.package_row_id and self.package_row_id in selected: row_ids_to_remove: set[str] = set()
self._clear_all_lists()
return
links_to_remove: list[str] = [] links_to_remove: list[str] = []
row_ids_to_remove = set(selected)
for row_id in selected: for row_id in selected:
if self.package_row_id and self.table.parent(row_id) == self.package_row_id: if not self.table.exists(row_id):
continue
parent = self.table.parent(row_id)
if not parent:
row_ids_to_remove.add(row_id)
for child_id in self.table.get_children(row_id):
row_ids_to_remove.add(child_id)
link_text = str(self.table.item(child_id, "text")).strip()
if link_text:
links_to_remove.append(link_text)
else:
row_ids_to_remove.add(row_id)
link_text = str(self.table.item(row_id, "text")).strip() link_text = str(self.table.item(row_id, "text")).strip()
if link_text: if link_text:
links_to_remove.append(link_text) links_to_remove.append(link_text)
@ -720,14 +887,9 @@ class DownloaderApp(tk.Tk):
lines.remove(link) lines.remove(link)
self._set_links_text_lines(lines) self._set_links_text_lines(lines)
for index, mapped_row_id in list(self.row_map.items()): self.row_map.clear()
if mapped_row_id in row_ids_to_remove: self.package_row_id = None
self.row_map.pop(index, None) self.package_contexts = []
if self.package_row_id and self.table.exists(self.package_row_id):
if not self.table.get_children(self.package_row_id):
self.table.delete(self.package_row_id)
self.package_row_id = None
def _load_links_from_file(self) -> None: def _load_links_from_file(self) -> None:
file_path = filedialog.askopenfilename( file_path = filedialog.askopenfilename(
@ -776,6 +938,211 @@ class DownloaderApp(tk.Tk):
except Exception as exc: except Exception as exc:
messagebox.showerror("Fehler", f"Konnte Linkliste nicht speichern: {exc}") messagebox.showerror("Fehler", f"Konnte Linkliste nicht speichern: {exc}")
@staticmethod
def _unique_preserve_order(items: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for item in items:
key = item.strip()
if not key or key in seen:
continue
seen.add(key)
result.append(key)
return result
def _parse_packages_from_links_text(self, raw_text: str, default_package_name: str) -> list[DownloadPackage]:
packages: list[DownloadPackage] = []
current_name = default_package_name.strip()
current_links: list[str] = []
def flush_current() -> None:
nonlocal current_name, current_links
links = self._unique_preserve_order(current_links)
if not links:
current_links = []
return
inferred = infer_package_name_from_links(links)
package_name = sanitize_filename(current_name or inferred or f"Paket-{len(packages) + 1:03d}")
packages.append(DownloadPackage(name=package_name, links=links))
current_links = []
for line in raw_text.splitlines():
text = line.strip()
if not text:
continue
marker = PACKAGE_MARKER_RE.match(text)
if marker:
flush_current()
current_name = marker.group(1).strip()
continue
current_links.append(text)
flush_current()
return packages
def _set_packages_to_links_text(self, packages: list[DownloadPackage]) -> None:
lines: list[str] = []
for package in packages:
lines.append(f"# package: {package.name}")
lines.extend(package.links)
lines.append("")
content = "\n".join(lines).strip()
if content:
content += "\n"
self.links_text.delete("1.0", "end")
self.links_text.insert("1.0", content)
def _import_dlc_file(self) -> None:
file_path = filedialog.askopenfilename(
title="DLC importieren",
initialdir=str(Path.home() / "Desktop"),
filetypes=(("DLC Container", "*.dlc"), ("Alle Dateien", "*.*")),
)
if not file_path:
return
try:
packages = self._decrypt_dlc_file(Path(file_path))
except Exception as exc:
messagebox.showerror("DLC Import", f"DLC konnte nicht importiert werden: {exc}")
return
if not packages:
messagebox.showerror("DLC Import", "Keine Links im DLC gefunden")
return
self._set_packages_to_links_text(packages)
if len(packages) == 1:
self.package_name_var.set(packages[0].name)
else:
self.package_name_var.set("")
total_links = sum(len(package.links) for package in packages)
self.status_var.set(f"DLC importiert: {len(packages)} Paket(e), {total_links} Link(s)")
def _decrypt_dlc_file(self, file_path: Path) -> list[DownloadPackage]:
with file_path.open("rb") as handle:
response = requests.post(
DCRYPT_UPLOAD_URL,
files={"dlcfile": (file_path.name, handle, "application/octet-stream")},
timeout=120,
)
if not response.ok:
raise RuntimeError(parse_error_message(response))
payload = self._decode_dcrypt_payload(response.text)
if isinstance(payload, dict):
errors = payload.get("form_errors")
if isinstance(errors, dict) and errors:
details: list[str] = []
for value in errors.values():
if isinstance(value, list):
details.extend(str(item) for item in value)
else:
details.append(str(value))
raise RuntimeError("; ".join(details) if details else "DLC konnte nicht entschluesselt werden")
packages = self._extract_packages_from_payload(payload)
if not packages:
links = self._extract_urls_recursive(payload)
packages = self._group_links_by_inferred_name(links)
if not packages:
links = self._extract_urls_recursive(response.text)
packages = self._group_links_by_inferred_name(links)
return packages
def _decode_dcrypt_payload(self, response_text: str) -> object:
text = response_text.strip()
match = re.search(r"<textarea[^>]*>(.*?)</textarea>", text, flags=re.IGNORECASE | re.DOTALL)
if match:
text = html.unescape(match.group(1).strip())
if not text:
return ""
try:
return json.loads(text)
except json.JSONDecodeError:
return text
def _extract_urls_recursive(self, data: object) -> list[str]:
links: list[str] = []
if isinstance(data, str):
links.extend(re.findall(r"https?://[^\s\"'<>]+", data))
return self._unique_preserve_order(links)
if isinstance(data, dict):
for value in data.values():
links.extend(self._extract_urls_recursive(value))
return self._unique_preserve_order(links)
if isinstance(data, list):
for item in data:
links.extend(self._extract_urls_recursive(item))
return self._unique_preserve_order(links)
return []
def _extract_packages_from_payload(self, payload: object) -> list[DownloadPackage]:
discovered: list[DownloadPackage] = []
def walk(node: object, parent_name: str = "") -> None:
if isinstance(node, dict):
name = ""
for key in ("package", "package_name", "packagename", "name", "title"):
value = node.get(key)
if isinstance(value, str) and value.strip():
name = value.strip()
break
direct_links: list[str] = []
for key in ("links", "urls", "url", "downloads", "download"):
if key in node:
direct_links.extend(self._extract_urls_recursive(node.get(key)))
if direct_links:
package_name = sanitize_filename(name or parent_name or infer_package_name_from_links(direct_links) or "Paket")
discovered.append(DownloadPackage(name=package_name, links=self._unique_preserve_order(direct_links)))
next_parent = name or parent_name
for value in node.values():
walk(value, next_parent)
return
if isinstance(node, list):
for item in node:
walk(item, parent_name)
walk(payload)
grouped: dict[str, list[str]] = {}
for package in discovered:
grouped.setdefault(package.name, [])
grouped[package.name].extend(package.links)
result = [DownloadPackage(name=name, links=self._unique_preserve_order(links)) for name, links in grouped.items() if links]
return result
def _group_links_by_inferred_name(self, links: list[str]) -> list[DownloadPackage]:
unique_links = self._unique_preserve_order(links)
if not unique_links:
return []
grouped: dict[str, list[str]] = {}
for link in unique_links:
inferred = infer_package_name_from_links([link])
package_name = sanitize_filename(inferred or "Paket")
grouped.setdefault(package_name, []).append(link)
return [DownloadPackage(name=name, links=package_links) for name, package_links in grouped.items() if package_links]
@staticmethod @staticmethod
def _normalize_parallel_value(value: int) -> int: def _normalize_parallel_value(value: int) -> int:
return max(1, min(int(value), 50)) return max(1, min(int(value), 50))
@ -787,7 +1154,7 @@ class DownloaderApp(tk.Tk):
@staticmethod @staticmethod
def _normalize_speed_mode(value: str) -> str: def _normalize_speed_mode(value: str) -> str:
mode = str(value or "global").strip().lower() mode = str(value or "global").strip().lower()
return mode if mode in {"global", "per_download"} else "global" return mode if mode in SPEED_MODE_CHOICES else "global"
def _sync_parallel_limit(self, value: int) -> None: def _sync_parallel_limit(self, value: int) -> None:
normalized = self._normalize_parallel_value(value) normalized = self._normalize_parallel_value(value)
@ -1023,7 +1390,13 @@ class DownloaderApp(tk.Tk):
self.extract_dir_var.set(data.get("extract_dir", self.extract_dir_var.get())) self.extract_dir_var.set(data.get("extract_dir", self.extract_dir_var.get()))
self.create_extract_subfolder_var.set(bool(data.get("create_extract_subfolder", True))) self.create_extract_subfolder_var.set(bool(data.get("create_extract_subfolder", True)))
self.hybrid_extract_var.set(bool(data.get("hybrid_extract", True))) self.hybrid_extract_var.set(bool(data.get("hybrid_extract", True)))
self.cleanup_after_extract_var.set(bool(data.get("cleanup_after_extract", False))) cleanup_mode = data.get("cleanup_mode")
if cleanup_mode is None:
cleanup_mode = "delete" if bool(data.get("cleanup_after_extract", False)) else "none"
self.cleanup_mode_var.set(self._normalize_cleanup_mode(str(cleanup_mode)))
self.extract_conflict_mode_var.set(
self._normalize_extract_conflict_mode(str(data.get("extract_conflict_mode", "overwrite")))
)
try: try:
max_parallel = int(data.get("max_parallel", self.max_parallel_var.get())) max_parallel = int(data.get("max_parallel", self.max_parallel_var.get()))
except Exception: except Exception:
@ -1056,7 +1429,8 @@ class DownloaderApp(tk.Tk):
"extract_dir": self.extract_dir_var.get().strip(), "extract_dir": self.extract_dir_var.get().strip(),
"create_extract_subfolder": self.create_extract_subfolder_var.get(), "create_extract_subfolder": self.create_extract_subfolder_var.get(),
"hybrid_extract": self.hybrid_extract_var.get(), "hybrid_extract": self.hybrid_extract_var.get(),
"cleanup_after_extract": self.cleanup_after_extract_var.get(), "cleanup_mode": self._normalize_cleanup_mode(self.cleanup_mode_var.get()),
"extract_conflict_mode": self._normalize_extract_conflict_mode(self.extract_conflict_mode_var.get()),
"max_parallel": self.max_parallel_var.get(), "max_parallel": self.max_parallel_var.get(),
"speed_limit_kbps": self.speed_limit_kbps_var.get(), "speed_limit_kbps": self.speed_limit_kbps_var.get(),
"speed_limit_mode": self.speed_limit_mode_var.get(), "speed_limit_mode": self.speed_limit_mode_var.get(),
@ -1101,16 +1475,22 @@ class DownloaderApp(tk.Tk):
output_dir = Path(output_dir_raw) output_dir = Path(output_dir_raw)
raw_links = self.links_text.get("1.0", "end") raw_links = self.links_text.get("1.0", "end")
links = [line.strip() for line in raw_links.splitlines() if line.strip()] package_name_input = self.package_name_var.get().strip()
if not links: packages = self._parse_packages_from_links_text(raw_links, package_name_input)
if not packages:
messagebox.showerror("Fehler", "Bitte mindestens einen Link eintragen") messagebox.showerror("Fehler", "Bitte mindestens einen Link eintragen")
return return
if len(packages) == 1 and package_name_input:
packages[0].name = sanitize_filename(package_name_input)
total_links = sum(len(package.links) for package in packages)
try: try:
parallel_raw = int(self.max_parallel_var.get()) parallel_raw = int(self.max_parallel_var.get())
except Exception: except Exception:
parallel_raw = 4 parallel_raw = 4
max_parallel = min(self._normalize_parallel_value(parallel_raw), len(links)) max_parallel = min(self._normalize_parallel_value(parallel_raw), total_links)
self.max_parallel_var.set(max_parallel) self.max_parallel_var.set(max_parallel)
self._sync_parallel_limit(max_parallel) self._sync_parallel_limit(max_parallel)
@ -1120,31 +1500,53 @@ class DownloaderApp(tk.Tk):
self.speed_limit_mode_var.set(speed_mode) self.speed_limit_mode_var.set(speed_mode)
self._sync_speed_limit(speed_limit, speed_mode) self._sync_speed_limit(speed_limit, speed_mode)
detected_package = infer_package_name_from_links(links)
package_name_raw = self.package_name_var.get().strip() or detected_package or f"Paket-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
package_name = sanitize_filename(package_name_raw)
if not self.package_name_var.get().strip() and detected_package:
self.package_name_var.set(package_name)
package_dir = next_available_path(output_dir / package_name)
extract_target_dir: Path | None = None
hybrid_extract = False hybrid_extract = False
cleanup_after_extract = False cleanup_mode = "none"
extract_conflict_mode = "overwrite"
if self.auto_extract_var.get(): if self.auto_extract_var.get():
extract_root_raw = self.extract_dir_var.get().strip() extract_root_raw = self.extract_dir_var.get().strip()
extract_root = Path(extract_root_raw) if extract_root_raw else (output_dir / "_entpackt") extract_root = Path(extract_root_raw) if extract_root_raw else (output_dir / "_entpackt")
if self.create_extract_subfolder_var.get():
extract_target_dir = next_available_path(extract_root / package_dir.name)
else:
extract_target_dir = extract_root
hybrid_extract = bool(self.hybrid_extract_var.get()) hybrid_extract = bool(self.hybrid_extract_var.get())
cleanup_after_extract = bool(self.cleanup_after_extract_var.get()) cleanup_mode = self._normalize_cleanup_mode(self.cleanup_mode_var.get())
extract_conflict_mode = self._normalize_extract_conflict_mode(self.extract_conflict_mode_var.get())
package_jobs: list[dict] = []
package_dir_names: set[str] = set()
try: try:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
package_dir.mkdir(parents=True, exist_ok=True)
if extract_target_dir: for package in packages:
extract_target_dir.mkdir(parents=True, exist_ok=True) base_name = sanitize_filename(package.name) or f"Paket-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
candidate_name = base_name
suffix_index = 1
while candidate_name.lower() in package_dir_names:
candidate_name = f"{base_name} ({suffix_index})"
suffix_index += 1
package_dir_names.add(candidate_name.lower())
package_dir = next_available_path(output_dir / candidate_name)
package_dir.mkdir(parents=True, exist_ok=True)
extract_target_dir: Path | None = None
if self.auto_extract_var.get():
extract_root_raw = self.extract_dir_var.get().strip()
extract_root = Path(extract_root_raw) if extract_root_raw else (output_dir / "_entpackt")
if self.create_extract_subfolder_var.get():
extract_target_dir = next_available_path(extract_root / package_dir.name)
else:
extract_target_dir = extract_root
extract_target_dir.mkdir(parents=True, exist_ok=True)
package_jobs.append(
{
"name": candidate_name,
"links": package.links,
"package_dir": package_dir,
"extract_target_dir": extract_target_dir,
}
)
self._save_config() self._save_config()
except Exception as exc: except Exception as exc:
messagebox.showerror("Fehler", f"Konnte Zielordner nicht verwenden: {exc}") messagebox.showerror("Fehler", f"Konnte Zielordner nicht verwenden: {exc}")
@ -1152,7 +1554,8 @@ class DownloaderApp(tk.Tk):
self.table.delete(*self.table.get_children()) self.table.delete(*self.table.get_children())
self.row_map.clear() self.row_map.clear()
self.package_row_id = "package-row" self.package_row_id = None
self.package_contexts = []
with self.path_lock: with self.path_lock:
self.reserved_target_keys.clear() self.reserved_target_keys.clear()
with self.speed_limit_lock: with self.speed_limit_lock:
@ -1161,36 +1564,46 @@ class DownloaderApp(tk.Tk):
self.speed_events.clear() self.speed_events.clear()
self.speed_var.set("Geschwindigkeit: 0 B/s") self.speed_var.set("Geschwindigkeit: 0 B/s")
self.table.insert( for package_index, job in enumerate(package_jobs, start=1):
"", package_row_id = f"package-{package_index}"
"end",
iid=self.package_row_id,
text=package_dir.name,
values=("-", "Wartet", f"0/{len(links)}", "0 B/s", "0"),
open=True,
)
for index, link in enumerate(links, start=1):
row_id = f"row-{index}"
self.row_map[index] = row_id
self.table.insert( self.table.insert(
self.package_row_id, "",
"end", "end",
iid=row_id, iid=package_row_id,
text=link, text=str(job["name"]),
values=("-", "Wartet", "0%", "0 B/s", "0"), values=("-", "Wartet", f"0/{len(job['links'])}", "0 B/s", "0"),
open=True,
)
row_map: dict[int, str] = {}
for link_index, link in enumerate(job["links"], start=1):
row_id = f"{package_row_id}-link-{link_index}"
row_map[link_index] = row_id
self.table.insert(
package_row_id,
"end",
iid=row_id,
text=link,
values=("-", "Wartet", "0%", "0 B/s", "0"),
)
self.package_contexts.append(
{
"package_row_id": package_row_id,
"row_map": row_map,
"job": job,
}
) )
self.overall_progress_var.set(0.0) self.overall_progress_var.set(0.0)
self.status_var.set( self.status_var.set(f"Starte {len(package_jobs)} Paket(e) mit {total_links} Link(s), parallel: {max_parallel}")
f"Starte Paket '{package_dir.name}' mit {len(links)} Link(s), parallel: {max_parallel}"
)
self.stop_event.clear() self.stop_event.clear()
self.start_button.configure(state="disabled") self.start_button.configure(state="disabled")
self.stop_button.configure(state="normal") self.stop_button.configure(state="normal")
self.worker_thread = threading.Thread( self.worker_thread = threading.Thread(
target=self._download_worker, target=self._download_queue_worker,
args=(token, package_dir, links, extract_target_dir, max_parallel, hybrid_extract, cleanup_after_extract), args=(token, max_parallel, hybrid_extract, cleanup_mode, extract_conflict_mode, total_links),
daemon=True, daemon=True,
) )
self.worker_thread.start() self.worker_thread.start()
@ -1200,6 +1613,61 @@ class DownloaderApp(tk.Tk):
self.stop_event.set() self.stop_event.set()
self.status_var.set("Stop angefordert...") self.status_var.set("Stop angefordert...")
def _download_queue_worker(
self,
token: str,
max_parallel: int,
hybrid_extract: bool,
cleanup_mode: str,
extract_conflict_mode: str,
overall_total_links: int,
) -> None:
processed_offset = 0
package_total = len(self.package_contexts)
for package_index, context in enumerate(self.package_contexts, start=1):
if self.stop_event.is_set():
break
package_row_id = str(context["package_row_id"])
row_map = dict(context["row_map"])
job = dict(context["job"])
package_name = str(job["name"])
package_links = list(job["links"])
package_dir = Path(job["package_dir"])
extract_target_dir = Path(job["extract_target_dir"]) if job.get("extract_target_dir") else None
self.package_row_id = package_row_id
self.row_map = row_map
self._queue_package(status=f"Starte ({package_index}/{package_total})", progress=f"0/{len(package_links)}", retries="0")
self._queue_status(
f"Paket {package_index}/{package_total}: {package_name} ({len(package_links)} Links, parallel {self._active_parallel_limit(len(package_links))})"
)
processed, _, _, _ = self._download_worker(
token=token,
package_dir=package_dir,
links=package_links,
extract_target_dir=extract_target_dir,
initial_parallel=max_parallel,
hybrid_extract=hybrid_extract,
cleanup_mode=cleanup_mode,
extract_conflict_mode=extract_conflict_mode,
progress_offset=processed_offset,
overall_total_links=overall_total_links,
)
processed_offset += processed
if self.stop_event.is_set():
break
if self.stop_event.is_set():
self._queue_status("Queue gestoppt")
else:
self._queue_status("Alle Pakete abgeschlossen")
self.ui_queue.put(("controls", False))
def _download_worker( def _download_worker(
self, self,
token: str, token: str,
@ -1208,10 +1676,14 @@ class DownloaderApp(tk.Tk):
extract_target_dir: Path | None, extract_target_dir: Path | None,
initial_parallel: int, initial_parallel: int,
hybrid_extract: bool, hybrid_extract: bool,
cleanup_after_extract: bool, cleanup_mode: str,
) -> None: extract_conflict_mode: str,
progress_offset: int = 0,
overall_total_links: int | None = None,
) -> tuple[int, int, int, int]:
self._sync_parallel_limit(initial_parallel) self._sync_parallel_limit(initial_parallel)
total = len(links) total = len(links)
overall_total = overall_total_links if overall_total_links is not None else total
processed = 0 processed = 0
success = 0 success = 0
failed = 0 failed = 0
@ -1254,7 +1726,8 @@ class DownloaderApp(tk.Tk):
extract_target_dir, extract_target_dir,
extracted_job_keys, extracted_job_keys,
strict_complete=False, strict_complete=False,
cleanup_after_extract=cleanup_after_extract, cleanup_mode=cleanup_mode,
conflict_mode=extract_conflict_mode,
) )
extracted += add_extracted extracted += add_extracted
failed += add_failed failed += add_failed
@ -1269,7 +1742,7 @@ class DownloaderApp(tk.Tk):
failed += 1 failed += 1
finally: finally:
processed += 1 processed += 1
self._queue_overall(processed, total) self._queue_overall(progress_offset + processed, overall_total)
self._queue_package( self._queue_package(
status=f"Laufend: {success} ok, {failed} fehler", status=f"Laufend: {success} ok, {failed} fehler",
progress=f"{processed}/{total}", progress=f"{processed}/{total}",
@ -1288,7 +1761,8 @@ class DownloaderApp(tk.Tk):
extract_target_dir, extract_target_dir,
extracted_job_keys, extracted_job_keys,
strict_complete=True, strict_complete=True,
cleanup_after_extract=cleanup_after_extract, cleanup_mode=cleanup_mode,
conflict_mode=extract_conflict_mode,
) )
extracted += add_extracted extracted += add_extracted
failed += extract_failed failed += extract_failed
@ -1299,7 +1773,7 @@ class DownloaderApp(tk.Tk):
self._queue_status(f"Gestoppt. Fertig: {success}, Fehler: {failed}") self._queue_status(f"Gestoppt. Fertig: {success}, Fehler: {failed}")
self._queue_package(status="Gestoppt", progress=f"{processed}/{total}") self._queue_package(status="Gestoppt", progress=f"{processed}/{total}")
else: else:
self._queue_overall(processed, total) self._queue_overall(progress_offset + processed, overall_total)
if extract_target_dir: if extract_target_dir:
self._queue_status( self._queue_status(
f"Abgeschlossen. Fertig: {success}, Fehler: {failed}, Entpackt: {extracted}. Ziel: {extract_target_dir}" f"Abgeschlossen. Fertig: {success}, Fehler: {failed}, Entpackt: {extracted}. Ziel: {extract_target_dir}"
@ -1309,7 +1783,7 @@ class DownloaderApp(tk.Tk):
self._queue_status(f"Abgeschlossen. Fertig: {success}, Fehler: {failed}") self._queue_status(f"Abgeschlossen. Fertig: {success}, Fehler: {failed}")
self._queue_package(status=f"Fertig: {success} ok, {failed} fehler", progress=f"{processed}/{total}") self._queue_package(status=f"Fertig: {success} ok, {failed} fehler", progress=f"{processed}/{total}")
self.ui_queue.put(("controls", False)) return processed, success, failed, extracted
def _download_single_link(self, token: str, package_dir: Path, index: int, link: str) -> Path | None: def _download_single_link(self, token: str, package_dir: Path, index: int, link: str) -> Path | None:
if self.stop_event.is_set(): if self.stop_event.is_set():
@ -1351,7 +1825,8 @@ class DownloaderApp(tk.Tk):
extract_target_dir: Path, extract_target_dir: Path,
extracted_job_keys: set[str], extracted_job_keys: set[str],
strict_complete: bool, strict_complete: bool,
cleanup_after_extract: bool, cleanup_mode: str,
conflict_mode: str,
) -> tuple[int, int]: ) -> tuple[int, int]:
jobs, skipped_reason_count = self._collect_extract_jobs(downloaded_files, strict_complete) jobs, skipped_reason_count = self._collect_extract_jobs(downloaded_files, strict_complete)
pending_jobs = [job for job in jobs if job.key not in extracted_job_keys] pending_jobs = [job for job in jobs if job.key not in extracted_job_keys]
@ -1377,10 +1852,9 @@ class DownloaderApp(tk.Tk):
self._queue_status(f"Entpacke {job.archive_path.name} ...") self._queue_status(f"Entpacke {job.archive_path.name} ...")
try: try:
used_password = self._extract_archive(job.archive_path, extract_target_dir) used_password = self._extract_archive(job.archive_path, extract_target_dir, conflict_mode)
extracted_job_keys.add(job.key) extracted_job_keys.add(job.key)
if cleanup_after_extract: self._cleanup_archive_sources(job.source_files, cleanup_mode)
self._cleanup_archive_sources(job.source_files)
if used_password: if used_password:
self._queue_status(f"Entpackt: {job.archive_path.name} (Passwort: {used_password})") self._queue_status(f"Entpackt: {job.archive_path.name} (Passwort: {used_password})")
else: else:
@ -1392,17 +1866,31 @@ class DownloaderApp(tk.Tk):
return extracted, failed return extracted, failed
def _cleanup_archive_sources(self, source_files: list[Path]) -> None: def _cleanup_archive_sources(self, source_files: list[Path], cleanup_mode: str) -> None:
mode = self._normalize_cleanup_mode(cleanup_mode)
if mode == "none":
return
deleted = 0 deleted = 0
for file_path in source_files: for file_path in source_files:
try: try:
if file_path.exists(): if file_path.exists():
file_path.unlink(missing_ok=True) if mode == "trash" and send2trash is not None:
deleted += 1 send2trash(str(file_path))
deleted += 1
elif mode == "trash":
file_path.unlink(missing_ok=True)
deleted += 1
elif mode == "delete":
file_path.unlink(missing_ok=True)
deleted += 1
except Exception: except Exception:
continue continue
if deleted: if deleted:
self._queue_status(f"Cleanup: {deleted} Archivdatei(en) geloescht") if mode == "trash" and send2trash is not None:
self._queue_status(f"Cleanup: {deleted} Archivdatei(en) in Papierkorb verschoben")
else:
self._queue_status(f"Cleanup: {deleted} Archivdatei(en) geloescht")
def _collect_extract_jobs(self, downloaded_files: list[Path], strict_complete: bool) -> tuple[list[ExtractJob], int]: def _collect_extract_jobs(self, downloaded_files: list[Path], strict_complete: bool) -> tuple[list[ExtractJob], int]:
jobs: list[ExtractJob] = [] jobs: list[ExtractJob] = []
@ -1469,23 +1957,23 @@ class DownloaderApp(tk.Tk):
return jobs, skipped return jobs, skipped
def _extract_archive(self, archive_path: Path, extract_target_dir: Path) -> str | None: def _extract_archive(self, archive_path: Path, extract_target_dir: Path, conflict_mode: str) -> str | None:
suffix = archive_path.suffix.lower() suffix = archive_path.suffix.lower()
if suffix == ".zip": if suffix == ".zip":
return self._extract_zip_archive(archive_path, extract_target_dir) return self._extract_zip_archive(archive_path, extract_target_dir, conflict_mode)
if suffix == ".rar": if suffix == ".rar":
if self.seven_zip_path: if self.seven_zip_path:
return self._extract_with_7zip(archive_path, extract_target_dir) return self._extract_with_7zip(archive_path, extract_target_dir, conflict_mode)
return self._extract_with_unrar(archive_path, extract_target_dir) return self._extract_with_unrar(archive_path, extract_target_dir, conflict_mode)
if suffix == ".7z": if suffix == ".7z":
return self._extract_with_7zip(archive_path, extract_target_dir) return self._extract_with_7zip(archive_path, extract_target_dir, conflict_mode)
raise RuntimeError("Archivformat wird nicht unterstuetzt") raise RuntimeError("Archivformat wird nicht unterstuetzt")
def _extract_zip_archive(self, archive_path: Path, extract_target_dir: Path) -> str | None: def _extract_zip_archive(self, archive_path: Path, extract_target_dir: Path, conflict_mode: str) -> str | None:
last_error: Exception | None = None last_error: Exception | None = None
for password in (None, *ARCHIVE_PASSWORDS): for password in (None, *ARCHIVE_PASSWORDS):
if self.stop_event.is_set(): if self.stop_event.is_set():
@ -1504,14 +1992,14 @@ class DownloaderApp(tk.Tk):
with zipfile.ZipFile(archive_path) as archive: with zipfile.ZipFile(archive_path) as archive:
archive.extractall(path=temp_path, pwd=password.encode("utf-8") if password else None) archive.extractall(path=temp_path, pwd=password.encode("utf-8") if password else None)
merge_directory(temp_path, extract_target_dir) merge_directory(temp_path, extract_target_dir, conflict_mode)
return password return password
except zipfile.BadZipFile as exc: except zipfile.BadZipFile as exc:
raise RuntimeError("ZIP-Datei ist defekt oder ungueltig") from exc raise RuntimeError("ZIP-Datei ist defekt oder ungueltig") from exc
except NotImplementedError as exc: except NotImplementedError as exc:
if self.seven_zip_path: if self.seven_zip_path:
return self._extract_with_7zip(archive_path, extract_target_dir) return self._extract_with_7zip(archive_path, extract_target_dir, conflict_mode)
last_error = exc last_error = exc
continue continue
except Exception as exc: except Exception as exc:
@ -1521,7 +2009,7 @@ class DownloaderApp(tk.Tk):
raise RuntimeError("Kein passendes ZIP-Passwort gefunden") from last_error raise RuntimeError("Kein passendes ZIP-Passwort gefunden") from last_error
def _extract_with_7zip(self, archive_path: Path, extract_target_dir: Path) -> str | None: def _extract_with_7zip(self, archive_path: Path, extract_target_dir: Path, conflict_mode: str) -> str | None:
if not self.seven_zip_path: if not self.seven_zip_path:
raise RuntimeError("Fuer 7Z wird 7-Zip (7z.exe) benoetigt") raise RuntimeError("Fuer 7Z wird 7-Zip (7z.exe) benoetigt")
@ -1547,7 +2035,7 @@ class DownloaderApp(tk.Tk):
raise RuntimeError("Entpacken hat zu lange gedauert") from exc raise RuntimeError("Entpacken hat zu lange gedauert") from exc
if result.returncode == 0: if result.returncode == 0:
merge_directory(Path(temp_dir), extract_target_dir) merge_directory(Path(temp_dir), extract_target_dir, conflict_mode)
return password return password
output = f"{result.stdout}\n{result.stderr}".strip() output = f"{result.stdout}\n{result.stderr}".strip()
@ -1557,7 +2045,7 @@ class DownloaderApp(tk.Tk):
raise RuntimeError(last_output or "Kein passendes Archiv-Passwort gefunden") raise RuntimeError(last_output or "Kein passendes Archiv-Passwort gefunden")
def _extract_with_unrar(self, archive_path: Path, extract_target_dir: Path) -> str | None: def _extract_with_unrar(self, archive_path: Path, extract_target_dir: Path, conflict_mode: str) -> str | None:
if not self.unrar_path: if not self.unrar_path:
raise RuntimeError("Fuer RAR wird WinRAR UnRAR.exe oder 7-Zip benoetigt") raise RuntimeError("Fuer RAR wird WinRAR UnRAR.exe oder 7-Zip benoetigt")
@ -1583,7 +2071,7 @@ class DownloaderApp(tk.Tk):
raise RuntimeError("Entpacken hat zu lange gedauert") from exc raise RuntimeError("Entpacken hat zu lange gedauert") from exc
if result.returncode == 0: if result.returncode == 0:
merge_directory(Path(temp_dir), extract_target_dir) merge_directory(Path(temp_dir), extract_target_dir, conflict_mode)
return password return password
output = f"{result.stdout}\n{result.stderr}".strip() output = f"{result.stdout}\n{result.stderr}".strip()
@ -1811,7 +2299,6 @@ class DownloaderApp(tk.Tk):
if column_index is not None: if column_index is not None:
values[column_index] = value values[column_index] = value
self.table.item(row_id, values=values) self.table.item(row_id, values=values)
self.table.see(row_id)
elif kind == "package": elif kind == "package":
updates = event[1] updates = event[1]

View File

@ -1,2 +1,3 @@
requests>=2.31.0 requests>=2.31.0
pyzipper>=0.3.6 pyzipper>=0.3.6
send2trash>=1.8.2