Add JDownloader-style settings UI and DLC package queue import
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
fdfe390d0e
commit
da18e3847e
12
README.md
12
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
pyzipper>=0.3.6
|
pyzipper>=0.3.6
|
||||||
|
send2trash>=1.8.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user