diff --git a/jd-webgui/app.py b/jd-webgui/app.py index 189a3c4..65400e1 100644 --- a/jd-webgui/app.py +++ b/jd-webgui/app.py @@ -4,51 +4,50 @@ from __future__ import annotations import base64 import hashlib import hmac -import html as html_mod import ipaddress import json import os import re -import shlex import socket import subprocess import threading import time import urllib.parse import urllib.request -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple -from myjdapi import Myjdapi import paramiko from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from fastapi.middleware.base import BaseHTTPMiddleware +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles +from myjdapi import Myjdapi # ============================================================ -# Environment +# Config from environment # ============================================================ -MYJD_EMAIL = os.environ.get("MYJD_EMAIL", "") +MYJD_EMAIL = os.environ.get("MYJD_EMAIL", "") MYJD_PASSWORD = os.environ.get("MYJD_PASSWORD", "") -MYJD_DEVICE = os.environ.get("MYJD_DEVICE", "") +MYJD_DEVICE = os.environ.get("MYJD_DEVICE", "") -JELLYFIN_HOST = os.environ.get("JELLYFIN_HOST", "192.168.1.1") -JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22")) -JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "") +JELLYFIN_HOST = os.environ.get("JELLYFIN_HOST", "192.168.1.1") +JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22")) +JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "") JELLYFIN_SSH_KEY = os.environ.get("JELLYFIN_SSH_KEY", "/ssh/id_ed25519") JELLYFIN_MOVIES_DIR = os.environ.get("JELLYFIN_MOVIES_DIR", "").rstrip("/") JELLYFIN_SERIES_DIR = os.environ.get("JELLYFIN_SERIES_DIR", "").rstrip("/") -JELLYFIN_DEST_DIR = os.environ.get("JELLYFIN_DEST_DIR", "/jellyfin/Filme").rstrip("/") +JELLYFIN_DEST_DIR = os.environ.get("JELLYFIN_DEST_DIR", "/jellyfin/Filme").rstrip("/") -JELLYFIN_API_BASE = os.environ.get("JELLYFIN_API_BASE", "").rstrip("/") -JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") +JELLYFIN_API_BASE = os.environ.get("JELLYFIN_API_BASE", "").rstrip("/") +JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") JELLYFIN_LIBRARY_REFRESH = os.environ.get("JELLYFIN_LIBRARY_REFRESH", "false").lower() == "true" -TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") +TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") TMDB_LANGUAGE = os.environ.get("TMDB_LANGUAGE", "de-DE") -CREATE_MOVIE_FOLDER = os.environ.get("CREATE_MOVIE_FOLDER", "true").lower() == "true" +CREATE_MOVIE_FOLDER = os.environ.get("CREATE_MOVIE_FOLDER", "true").lower() == "true" CREATE_SERIES_FOLDERS = os.environ.get("CREATE_SERIES_FOLDERS", "true").lower() == "true" MD5_DIR = os.environ.get("MD5_DIR", "/md5").rstrip("/") @@ -56,29 +55,17 @@ MD5_DIR = os.environ.get("MD5_DIR", "/md5").rstrip("/") BASIC_AUTH_USER = os.environ.get("BASIC_AUTH_USER", "") BASIC_AUTH_PASS = os.environ.get("BASIC_AUTH_PASS", "") -POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5")) -MIN_VIDEO_SIZE_MB = int(os.environ.get("MIN_VIDEO_SIZE_MB", "200")) -MIN_VIDEO_BYTES = MIN_VIDEO_SIZE_MB * 1024 * 1024 +POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5")) +MIN_VIDEO_SIZE_MB = int(os.environ.get("MIN_VIDEO_SIZE_MB", "200")) +MIN_VIDEO_BYTES = MIN_VIDEO_SIZE_MB * 1024 * 1024 # JDownloader writes here inside container -JD_OUTPUT_PATH = "/output" +JD_OUTPUT_PATH = "/output" PROXY_EXPORT_PATH = os.environ.get("PROXY_EXPORT_PATH", "/output/jd-proxies.jdproxies") -LOG_BUFFER_LIMIT = int(os.environ.get("LOG_BUFFER_LIMIT", "500")) +LOG_BUFFER_LIMIT = int(os.environ.get("LOG_BUFFER_LIMIT", "500")) URL_RE = re.compile(r"^https?://", re.I) -NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) - -def esc(s: str) -> str: - """HTML-escape a string to prevent XSS.""" - return html_mod.escape(str(s), quote=True) - -def mask_secret(value: str, visible: int = 4) -> str: - """Mask a secret string, showing only the last `visible` characters.""" - if len(value) <= visible: - return "***" - return "***" + value[-visible:] - VIDEO_EXTS = { ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv", @@ -100,73 +87,127 @@ def _auth_enabled() -> bool: def _check_basic_auth(req: Request) -> bool: if not _auth_enabled(): return True - hdr = req.headers.get("authorization", "") - if not hdr.lower().startswith("basic "): + auth = req.headers.get("authorization", "") + if not auth.lower().startswith("basic "): return False - b64 = hdr.split(" ", 1)[1].strip() try: - raw = base64.b64decode(b64).decode("utf-8", "replace") + decoded = base64.b64decode(auth[6:]).decode("utf-8", "replace") + user, _, pw = decoded.partition(":") except Exception: return False - if ":" not in raw: - return False - user, pw = raw.split(":", 1) return hmac.compare_digest(user, BASIC_AUTH_USER) and hmac.compare_digest(pw, BASIC_AUTH_PASS) -def _auth_challenge() -> HTMLResponse: - return HTMLResponse( - "Authentication required", - status_code=401, - headers={"WWW-Authenticate": 'Basic realm="jd-webgui"'}, - ) - @app.middleware("http") async def basic_auth_middleware(request: Request, call_next): if not _check_basic_auth(request): - return _auth_challenge() + return HTMLResponse( + content="Unauthorized", + status_code=401, + headers={"WWW-Authenticate": 'Basic realm="jd-webgui"'}, + ) return await call_next(request) # ============================================================ -# Models / State +# Logging # ============================================================ +_log_lock = threading.Lock() +_conn_log: list[str] = [] + +def log_connection(msg: str): + ts = time.strftime("%Y-%m-%d %H:%M:%S") + entry = f"[{ts}] {msg}" + with _log_lock: + _conn_log.append(entry) + if len(_conn_log) > LOG_BUFFER_LIMIT: + _conn_log.pop(0) + +# ============================================================ +# SSRF protection +# ============================================================ +def _is_ssrf_target(url: str) -> bool: + try: + host = urllib.parse.urlparse(url).hostname or "" + try: + addr = ipaddress.ip_address(host) + except ValueError: + try: + host = socket.gethostbyname(host) + addr = ipaddress.ip_address(host) + except Exception: + return False + return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved + except Exception: + return False + +# ============================================================ +# No-proxy opener (bypasses any system proxy) +# ============================================================ +NO_PROXY_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({}) +) + +def check_url_reachable(url: str) -> Optional[str]: + if _is_ssrf_target(url): + return "URL zeigt auf eine interne/private Adresse (nicht erlaubt)" + try: + req = urllib.request.Request(url, method="HEAD") + with NO_PROXY_OPENER.open(req, timeout=10) as resp: + _ = resp.status + return None + except urllib.error.HTTPError as e: + if e.code < 500: + return None + return f"HTTP {e.code}" + except Exception as e: + return str(e) + +# ============================================================ +# Proxy list fetching with cache + size limit +# ============================================================ +_PROXY_FETCH_LIMIT = 2 * 1024 * 1024 # 2 MB cap +_proxy_cache: Dict[str, Tuple[float, str]] = {} +_PROXY_CACHE_TTL = 300.0 # 5 minutes + +def fetch_proxy_list(url: str) -> str: + now = time.time() + cached_ts, cached_text = _proxy_cache.get(url, (0.0, "")) + if cached_text and now - cached_ts < _PROXY_CACHE_TTL: + return cached_text + req = urllib.request.Request(url) + log_connection(f"HTTP GET {url} (no-proxy)") + with NO_PROXY_OPENER.open(req, timeout=20) as resp: + text = resp.read(_PROXY_FETCH_LIMIT).decode("utf-8", "replace") + if "\n" not in text and re.search(r"\s", text): + text = re.sub(r"\s+", "\n", text.strip()) + _proxy_cache[url] = (now, text) + return text + +# ============================================================ +# Job state +# ============================================================ +lock = threading.Lock() +jobs: Dict[str, "Job"] = {} + @dataclass class Job: id: str url: str package_name: str - library: str # movies|series|auto - status: str # queued|collecting|downloading|upload|finished|failed - message: str + library: str + status: str = "queued" + message: str = "" progress: float = 0.0 cancel_requested: bool = False -jobs: Dict[str, Job] = {} -lock = threading.Lock() -log_lock = threading.Lock() -connection_logs: List[str] = [] - -def log_connection(message: str) -> None: - timestamp = time.strftime("%Y-%m-%d %H:%M:%S") - line = f"[{timestamp}] {message}" - with log_lock: - connection_logs.append(line) - if len(connection_logs) > LOG_BUFFER_LIMIT: - excess = len(connection_logs) - LOG_BUFFER_LIMIT - del connection_logs[:excess] - -def get_connection_logs() -> str: - with log_lock: - return "\n".join(connection_logs) - # ============================================================ # Core helpers # ============================================================ def ensure_env(): missing = [] for k, v in [ - ("MYJD_EMAIL", MYJD_EMAIL), - ("MYJD_PASSWORD", MYJD_PASSWORD), - ("JELLYFIN_USER", JELLYFIN_USER), + ("MYJD_EMAIL", MYJD_EMAIL), + ("MYJD_PASSWORD", MYJD_PASSWORD), + ("JELLYFIN_USER", JELLYFIN_USER), ("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY), ]: if not v: @@ -181,6 +222,20 @@ def ensure_env(): if missing: raise RuntimeError("Missing env vars: " + ", ".join(missing)) + # Validate SSH key path + key = JELLYFIN_SSH_KEY + if os.path.isdir(key): + raise RuntimeError( + f"JELLYFIN_SSH_KEY '{key}' ist ein Verzeichnis, keine Datei. " + "Pruefe den SSH_KEY_PATH in Dockhand: er muss auf die Schluessel-DATEI zeigen " + "(z. B. /root/.ssh/id_ed25519), nicht auf ein Verzeichnis." + ) + if not os.path.isfile(key): + raise RuntimeError( + f"JELLYFIN_SSH_KEY '{key}' existiert nicht im Container. " + "Pruefe den SSH_KEY_PATH in Dockhand und ob die Datei auf dem Host vorhanden ist." + ) + def get_device(): jd = Myjdapi() log_connection(f"MyJDownloader connect as {MYJD_EMAIL or 'unknown'}") @@ -201,124 +256,19 @@ def get_device(): for d in devs: if (d.get("name") or "") == wanted: return d - for d in devs: - if (d.get("name") or "").lower() == wanted.lower(): - return d - return None return devs[0] if devs else None - d = pick() - if not d: - time.sleep(2) - continue + chosen = pick() + if chosen and chosen.get("status", "").upper() == "ONLINE": + jd.set_device(chosen) + return jd.get_device() + time.sleep(2) - status = (d.get("status") or "").upper() - if status not in {"ONLINE", "CONNECTED"}: - # just a warning; do not fail - print(f"[WARN] Device status is {status}, continuing anyway...") - return jd.get_device(d["name"]) + raise RuntimeError( + f"Kein JDownloader-Geraet gefunden/online. " + f"Gesucht: '{wanted or 'beliebig'}'. Gefunden: {last}" + ) - - - # no ONLINE device after waiting - if last: - names = [(x.get("name"), x.get("status")) for x in last] - raise RuntimeError(f"MyJDownloader device not ONLINE yet. Devices: {names}") - raise RuntimeError("No MyJDownloader devices available (JD online/logged in?)") - - - -def is_video_file(path: str) -> bool: - name = os.path.basename(path).lower() - _, ext = os.path.splitext(name) - if ext in IGNORE_EXTS: - return False - return ext in VIDEO_EXTS - -DEMO_PATTERNS = {"big_buck_bunny", "bigbuckbunny", "big buck bunny", "bbb_sunflower"} - -def is_demo_link(name: str) -> bool: - """Detect JDownloader demo/fallback videos (e.g. Big Buck Bunny).""" - lower = name.lower().replace("-", "_").replace(".", " ") - return any(pat in lower for pat in DEMO_PATTERNS) - -def _is_ssrf_target(url: str) -> bool: - """Return True if the URL resolves to a private/loopback address (SSRF protection).""" - try: - host = urllib.parse.urlparse(url).hostname or "" - try: - addr = ipaddress.ip_address(host) - except ValueError: - try: - host = socket.gethostbyname(host) - addr = ipaddress.ip_address(host) - except Exception: - return False - return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved - except Exception: - return False - -def check_url_reachable(url: str) -> Optional[str]: - """Try a HEAD request to verify the URL is reachable. Returns error string or None.""" - if _is_ssrf_target(url): - return "URL zeigt auf eine interne/private Adresse (nicht erlaubt)" - try: - req = urllib.request.Request(url, method="HEAD") - req.add_header("User-Agent", "Mozilla/5.0") - with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status >= 400: - return f"URL antwortet mit HTTP {resp.status}" - except urllib.error.HTTPError as e: - return f"URL nicht erreichbar: HTTP {e.code}" - except urllib.error.URLError as e: - return f"URL nicht erreichbar: {e.reason}" - except Exception as e: - return f"URL-Check fehlgeschlagen: {e}" - return None - -def md5_file(path: str) -> str: - h = hashlib.md5() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() - -def write_md5_sidecar(file_path: str, md5_hex: str) -> str: - base = os.path.basename(file_path) - candidates = [MD5_DIR, "/tmp/md5"] - last_err: Optional[Exception] = None - - for target in candidates: - try: - os.makedirs(target, exist_ok=True) - md5_path = os.path.join(target, base + ".md5") - with open(md5_path, "w", encoding="utf-8") as f: - f.write(f"{md5_hex} {base}\n") - return md5_path - except PermissionError as exc: - last_err = exc - continue - - if last_err: - raise last_err - raise RuntimeError("Failed to write MD5 sidecar file.") - -def ffprobe_ok(path: str) -> bool: - try: - cp = subprocess.run( - ["ffprobe", "-v", "error", "-show_streams", "-select_streams", "v:0", path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=60, - ) - return cp.returncode == 0 and "codec_type=video" in (cp.stdout or "") - except Exception: - return False - -# ============================================================ -# SSH/SFTP -# ============================================================ SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/ssh/known_hosts") def ssh_connect() -> paramiko.SSHClient: @@ -352,216 +302,109 @@ def sftp_mkdirs(sftp: paramiko.SFTPClient, remote_dir: str): def sftp_upload(ssh: paramiko.SSHClient, local_path: str, remote_path: str): sftp = ssh.open_sftp() try: - log_connection(f"SFTP upload {local_path} -> {remote_path}") - sftp_mkdirs(sftp, os.path.dirname(remote_path)) + remote_dir = "/".join(remote_path.split("/")[:-1]) + if remote_dir: + sftp_mkdirs(sftp, remote_dir) sftp.put(local_path, remote_path) + log_connection(f"SFTP upload {os.path.basename(local_path)} -> {remote_path}") finally: sftp.close() def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: - quoted = shlex.quote(remote_path) - cmd = f"md5sum {quoted}" - log_connection(f"SSH exec {cmd}") - stdin, stdout, stderr = ssh.exec_command(cmd, timeout=120) - out = stdout.read().decode("utf-8", "replace").strip() - err = stderr.read().decode("utf-8", "replace").strip() - if err and not out: - raise RuntimeError(f"Remote md5sum failed: {err}") + _, stdout, stderr = ssh.exec_command(f"md5sum {remote_path!r}") + out = stdout.read().decode().strip() + err = stderr.read().decode().strip() if not out: - raise RuntimeError("Remote md5sum returned empty output") + raise RuntimeError(f"md5sum on remote failed: {err}") return out.split()[0] -# ============================================================ -# TMDB & naming -# ============================================================ -def _sanitize_url_for_log(url: str) -> str: - """Remove sensitive query params (api_key) from URLs before logging.""" - parsed = urllib.parse.urlparse(url) - params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - for key in ("api_key", "apikey", "token"): - if key in params: - params[key] = ["***"] - safe_query = urllib.parse.urlencode(params, doseq=True) - return urllib.parse.urlunparse(parsed._replace(query=safe_query)) +def md5_file(path: str) -> str: + h = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() -def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: - req = urllib.request.Request(url, headers=headers or {}) - log_connection(f"HTTP GET {_sanitize_url_for_log(url)} (no-proxy)") - with NO_PROXY_OPENER.open(req, timeout=20) as r: - return json.loads(r.read().decode("utf-8", "replace")) - -def tmdb_search_movie(query: str) -> Optional[Dict[str, Any]]: - if not TMDB_API_KEY or not query.strip(): - return None - q = urllib.parse.quote(query.strip()) - url = f"https://api.themoviedb.org/3/search/movie?api_key={TMDB_API_KEY}&language={urllib.parse.quote(TMDB_LANGUAGE)}&query={q}" - try: - data = _http_get_json(url) - except Exception: - return None - results = data.get("results") or [] - return results[0] if results else None - -def tmdb_search_tv(query: str) -> Optional[Dict[str, Any]]: - if not TMDB_API_KEY or not query.strip(): - return None - q = urllib.parse.quote(query.strip()) - url = f"https://api.themoviedb.org/3/search/tv?api_key={TMDB_API_KEY}&language={urllib.parse.quote(TMDB_LANGUAGE)}&query={q}" - try: - data = _http_get_json(url) - except Exception: - return None - results = data.get("results") or [] - return results[0] if results else None +def write_md5_sidecar(video_path: str, md5_hex: str) -> str: + sidecar = os.path.join(MD5_DIR, os.path.basename(video_path) + ".md5") + os.makedirs(MD5_DIR, exist_ok=True) + with open(sidecar, "w") as f: + f.write(f"{md5_hex} {os.path.basename(video_path)}\n") + return sidecar def sanitize_name(name: str) -> str: - bad = '<>:"/\\|?*' - out = "".join("_" if c in bad else c for c in name).strip() - return re.sub(r"\s+", " ", out) + name = re.sub(r'[<>:"/\\|?*]', "", name) + name = re.sub(r"\s+", " ", name).strip() + return name or "Unknown" -def format_proxy_lines(raw: str, scheme: str) -> str: - """ - Takes raw lines (ip:port or scheme://ip:port) and outputs normalized lines: - scheme://ip:port (one per line). Ignores empty lines and comments. - """ - scheme = scheme.strip().lower() - if scheme not in {"socks5", "socks4", "http"}: - raise ValueError("Unsupported proxy scheme") +def ffprobe_ok(path: str) -> bool: + try: + result = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path], + capture_output=True, timeout=30, + ) + return result.returncode == 0 + except Exception: + return False - out = [] - for line in (raw or "").splitlines(): - s = line.strip() - if not s or s.startswith("#"): - continue +def is_video_file(path: str) -> bool: + name = os.path.basename(path).lower() + _, ext = os.path.splitext(name) + if ext in IGNORE_EXTS: + return False + return ext in VIDEO_EXTS - if "://" in s: - s = s.split("://", 1)[1].strip() +DEMO_PATTERNS = {"big_buck_bunny", "bigbuckbunny", "big buck bunny", "bbb_sunflower"} - if ":" not in s: - continue +def is_demo_link(name: str) -> bool: + """Detect JDownloader demo/fallback videos (e.g. Big Buck Bunny).""" + lower = name.lower().replace("-", "_").replace(".", " ") + return any(p in lower for p in DEMO_PATTERNS) - host, port = s.rsplit(":", 1) - host = host.strip() - port = port.strip() +# ============================================================ +# TMDB helpers +# ============================================================ +def tmdb_request(path: str, params: Dict[str, str]) -> Any: + params = {**params, "api_key": TMDB_API_KEY, "language": TMDB_LANGUAGE} + qs = urllib.parse.urlencode(params) + url = f"https://api.themoviedb.org/3{path}?{qs}" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with NO_PROXY_OPENER.open(req, timeout=10) as resp: + return json.loads(resp.read()) - if not host or not port.isdigit(): - continue +def tmdb_search_movie(query: str) -> Optional[Dict[str, Any]]: + if not TMDB_API_KEY or not query: + return None + try: + data = tmdb_request("/search/movie", {"query": query}) + results = data.get("results") or [] + return results[0] if results else None + except Exception: + return None - out.append(f"{scheme}://{host}:{port}") - - seen = set() - dedup = [] - for x in out: - if x not in seen: - seen.add(x) - dedup.append(x) - - return "\n".join(dedup) - -_PROXY_FETCH_LIMIT = 2 * 1024 * 1024 # 2 MB cap to prevent memory exhaustion -_proxy_cache: Dict[str, Tuple[float, str]] = {} -_PROXY_CACHE_TTL = 300.0 # 5 minutes - -def fetch_proxy_list(url: str) -> str: - now = time.time() - cached_ts, cached_text = _proxy_cache.get(url, (0.0, "")) - if cached_text and now - cached_ts < _PROXY_CACHE_TTL: - return cached_text - req = urllib.request.Request(url) - log_connection(f"HTTP GET {url} (no-proxy)") - with NO_PROXY_OPENER.open(req, timeout=20) as resp: - text = resp.read(_PROXY_FETCH_LIMIT).decode("utf-8", "replace") - if "\n" not in text and re.search(r"\s", text): - text = re.sub(r"\s+", "\n", text.strip()) - _proxy_cache[url] = (now, text) - return text - -def build_jdproxies_payload(text: str) -> Dict[str, Any]: - if not text.strip(): - raise ValueError("Keine Proxy-Einträge zum Speichern.") - entries: List[Dict[str, Any]] = [] - type_map = { - "socks5": "SOCKS5", - "socks4": "SOCKS4", - "http": "HTTP", - } - entries.append({ - "filter": None, - "proxy": { - "address": None, - "password": None, - "port": 80, - "type": "NONE", - "username": None, - "connectMethodPrefered": False, - "preferNativeImplementation": False, - "resolveHostName": False, - }, - "enabled": True, - "pac": False, - "rangeRequestsSupported": True, - "reconnectSupported": True, - }) - for line in text.splitlines(): - s = line.strip() - if not s: - continue - parsed = urllib.parse.urlparse(s) - if not parsed.scheme or not parsed.hostname or parsed.port is None: - continue - proxy_type = type_map.get(parsed.scheme.lower()) - if not proxy_type: - continue - entries.append({ - "filter": None, - "proxy": { - "address": parsed.hostname, - "password": None, - "port": int(parsed.port), - "type": proxy_type, - "username": None, - "connectMethodPrefered": False, - "preferNativeImplementation": False, - "resolveHostName": False, - }, - "enabled": True, - "pac": False, - "rangeRequestsSupported": True, - "reconnectSupported": False, - }) - if not entries: - raise ValueError("Keine gültigen Proxy-Einträge gefunden.") - return {"customProxyList": entries} - -def save_proxy_export(text: str) -> str: - payload = build_jdproxies_payload(text) - export_path = PROXY_EXPORT_PATH - export_dir = os.path.dirname(export_path) - if export_dir: - os.makedirs(export_dir, exist_ok=True) - if os.path.exists(export_path): - os.remove(export_path) - with open(export_path, "w", encoding="utf-8") as handle: - handle.write(json.dumps(payload, indent=2)) - handle.write("\n") - return export_path +def tmdb_search_tv(query: str) -> Optional[Dict[str, Any]]: + if not TMDB_API_KEY or not query: + return None + try: + data = tmdb_request("/search/tv", {"query": query}) + results = data.get("results") or [] + return results[0] if results else None + except Exception: + return None +# ============================================================ +# Library / path helpers +# ============================================================ def pick_library_target(library_choice: str, filename: str, package_name: str) -> str: - if library_choice not in {"movies", "series", "auto"}: - library_choice = "auto" - - if library_choice == "auto": - if SERIES_RE.search(filename) or SERIES_RE.search(package_name or ""): - library_choice = "series" - else: - library_choice = "movies" - - if library_choice == "movies" and JELLYFIN_MOVIES_DIR: - return JELLYFIN_MOVIES_DIR - if library_choice == "series" and JELLYFIN_SERIES_DIR: - return JELLYFIN_SERIES_DIR - - return JELLYFIN_DEST_DIR + if library_choice == "movies": + return JELLYFIN_MOVIES_DIR or JELLYFIN_DEST_DIR + if library_choice == "series": + return JELLYFIN_SERIES_DIR or JELLYFIN_DEST_DIR + # auto + if SERIES_RE.search(filename) or SERIES_RE.search(package_name or ""): + return JELLYFIN_SERIES_DIR or JELLYFIN_DEST_DIR + return JELLYFIN_MOVIES_DIR or JELLYFIN_DEST_DIR def build_remote_paths(job_library: str, package_name: str, local_file: str) -> Tuple[str, str]: filename = os.path.basename(local_file) @@ -575,7 +418,7 @@ def build_remote_paths(job_library: str, package_name: str, local_file: str) -> tv = tmdb_search_tv(show_query) if TMDB_API_KEY else None show_name = sanitize_name(tv["name"]) if tv and tv.get("name") else sanitize_name(show_query) - season = int(m.group(1)) if m else 1 + season = int(m.group(1)) if m else 1 episode = int(m.group(2)) if m else 1 if CREATE_SERIES_FOLDERS: @@ -588,16 +431,16 @@ def build_remote_paths(job_library: str, package_name: str, local_file: str) -> return remote_dir, remote_filename movie_query = package_name or os.path.splitext(filename)[0] - mv = tmdb_search_movie(movie_query) if TMDB_API_KEY else None + mv = tmdb_search_movie(movie_query) if TMDB_API_KEY else None title = mv.get("title") if mv else None - date = mv.get("release_date") if mv else None - year = date[:4] if isinstance(date, str) and len(date) >= 4 else None + date = mv.get("release_date") if mv else None + year = date[:4] if isinstance(date, str) and len(date) >= 4 else None title_safe = sanitize_name(title) if title else sanitize_name(movie_query) - year_safe = year if year else "" + year_safe = year if year else "" if CREATE_MOVIE_FOLDER: - folder = f"{title_safe} ({year_safe})".strip() if year_safe else title_safe + folder = f"{title_safe} ({year_safe})".strip() if year_safe else title_safe remote_dir = f"{base_target}/{folder}" else: remote_dir = base_target @@ -615,44 +458,108 @@ def jellyfin_refresh_library(): headers = {"X-MediaBrowser-Token": JELLYFIN_API_KEY} for path in ("/Library/Refresh", "/library/refresh"): try: - url = JELLYFIN_API_BASE + path - req = urllib.request.Request(url, headers=headers, method="POST") - log_connection(f"HTTP POST {url} (no-proxy)") - with NO_PROXY_OPENER.open(req, timeout=20) as r: - _ = r.read() + req = urllib.request.Request( + JELLYFIN_API_BASE + path, + method="POST", + headers=headers, + ) + with NO_PROXY_OPENER.open(req, timeout=15): + pass + log_connection(f"Jellyfin library refresh triggered via {path}") return - except Exception: - continue + except Exception as e: + log_connection(f"Jellyfin refresh {path} failed: {e}") # ============================================================ -# JDownloader queries/cleanup (best effort) +# JD cancel / cleanup helpers +# ============================================================ +def call_raw_jd_api(dev, endpoints: List[str], payloads: List[Dict[str, Any]]) -> bool: + method_candidates = ["action", "call", "api", "request"] + for method_name in method_candidates: + method = getattr(dev, method_name, None) + if method is None: + continue + for ep, pl in zip(endpoints, payloads): + try: + method(ep, pl) + return True + except Exception: + pass + return False + +def cancel_job(dev, jobid: str) -> str: + links, pkg_map = query_links_and_packages(dev, jobid) + link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] + pkg_ids = [p for p in pkg_map] + + msgs = [] + for ep, pl in [ + ("downloads/remove_links", {"linkIds": link_ids, "packageIds": []}), + ("downloadcontroller/remove_links", {"linkIds": link_ids, "packageIds": []}), + ]: + try: + call_raw_jd_api(dev, [ep], [pl]) + except Exception: + pass + + for ep, pl in [ + ("downloads/remove_links", {"linkIds": [], "packageIds": pkg_ids}), + ("downloadcontroller/remove_links", {"linkIds": [], "packageIds": pkg_ids}), + ]: + try: + call_raw_jd_api(dev, [ep], [pl]) + except Exception: + pass + + return " ".join(msgs) if msgs else "Download abgebrochen." + +def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> str: + link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] + pkg_ids = list(pkg_map.keys()) + try: + dev.downloads.remove_links(link_ids=link_ids, package_ids=pkg_ids) + return "JDownloader: Paket/Links entfernt." + except Exception: + pass + try: + call_raw_jd_api( + dev, + ["downloads/remove_links", "downloadcontroller/remove_links"], + [{"linkIds": link_ids, "packageIds": pkg_ids}] * 2, + ) + return "JDownloader: Paket/Links entfernt (raw API)." + except Exception as e: + return f"JDownloader-Cleanup fehlgeschlagen: {e}" + +# ============================================================ +# Download monitoring helpers # ============================================================ def query_links_and_packages(dev, jobid: str) -> Tuple[List[Dict[str, Any]], Dict[Any, Dict[str, Any]]]: links = dev.downloads.query_links([{ - "jobUUIDs": [int(jobid)] if jobid.isdigit() else [jobid], + "jobUUIDs": [int(jobid)] if jobid.isdigit() else [jobid], "maxResults": -1, - "startAt": 0, - "name": True, - "finished": True, - "running": True, + "startAt": 0, + "name": True, + "finished": True, + "running": True, "bytesLoaded": True, - "bytesTotal": True, - "bytes": True, - "totalBytes": True, - "status": True, + "bytesTotal": True, + "bytes": True, + "totalBytes": True, + "status": True, "packageUUID": True, - "uuid": True, + "uuid": True, }]) pkg_ids = sorted({l.get("packageUUID") for l in links if l.get("packageUUID") is not None}) pkgs = dev.downloads.query_packages([{ "packageUUIDs": pkg_ids, - "maxResults": -1, - "startAt": 0, - "saveTo": True, - "uuid": True, - "finished": True, - "running": True, + "maxResults": -1, + "startAt": 0, + "saveTo": True, + "uuid": True, + "finished": True, + "running": True, }]) if pkg_ids else [] pkg_map = {p.get("uuid"): p for p in pkgs} return links, pkg_map @@ -663,9 +570,9 @@ def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[ name = l.get("name") if not name: continue - pkg = pkg_map.get(l.get("packageUUID")) + pkg = pkg_map.get(l.get("packageUUID")) save_to = pkg.get("saveTo") if pkg else None - base = save_to if isinstance(save_to, str) else JD_OUTPUT_PATH + base = save_to if isinstance(save_to, str) else JD_OUTPUT_PATH paths.append(os.path.join(base, name)) out, seen = [], set() @@ -675,158 +582,18 @@ def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[ out.append(p) return out -def call_raw_jd_api(dev, endpoints: List[str], payloads: List[Dict[str, Any]]) -> bool: - method_candidates = ["action", "call", "api", "request"] - for method_name in method_candidates: - method = getattr(dev, method_name, None) - if method is None: - continue - for endpoint in endpoints: - for payload in payloads: - try: - method(endpoint, payload) - return True - except TypeError: - try: - method(endpoint, params=payload) - return True - except TypeError: - try: - method(endpoint, data=payload) - return True - except Exception: - continue - except Exception: - continue - return False - -def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]: - link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] - pkg_ids = list(pkg_map.keys()) - - candidates = [ - ("downloads", "removeLinks"), - ("downloads", "remove_links"), - ("downloads", "deleteLinks"), - ("downloads", "delete_links"), - ("downloadcontroller", "removeLinks"), - ("downloadcontroller", "remove_links"), - ] - - payloads = [ - {"linkUUIDs": link_ids, "packageUUIDs": pkg_ids}, - {"linkIds": link_ids, "packageIds": pkg_ids}, - {"linkUUIDs": link_ids}, - {"packageUUIDs": pkg_ids}, - ] - - for ns, fn in candidates: - obj = getattr(dev, ns, None) - if obj is None: - continue - meth = getattr(obj, fn, None) - if meth is None: - continue - for payload in payloads: - try: - meth([payload]) - return None - except Exception: - continue - - endpoint_candidates = [ - "downloads/removeLinks", - "downloadsV2/removeLinks", - "downloadcontroller/removeLinks", - ] - if call_raw_jd_api(dev, endpoint_candidates, payloads): - return None - - return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)." - -def try_cancel_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]: - link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] - pkg_ids = list(pkg_map.keys()) - - candidates = [ - ("downloads", "removeLinks"), - ("downloads", "remove_links"), - ("downloads", "deleteLinks"), - ("downloads", "delete_links"), - ("downloadcontroller", "removeLinks"), - ("downloadcontroller", "remove_links"), - ] - - payloads = [ - {"linkUUIDs": link_ids, "packageUUIDs": pkg_ids, "deleteFiles": True}, - {"linkIds": link_ids, "packageIds": pkg_ids, "deleteFiles": True}, - {"linkUUIDs": link_ids, "deleteFiles": True}, - {"packageUUIDs": pkg_ids, "deleteFiles": True}, - {"linkUUIDs": link_ids, "packageUUIDs": pkg_ids, "removeFiles": True}, - {"linkIds": link_ids, "packageIds": pkg_ids, "removeFiles": True}, - ] - - for ns, fn in candidates: - obj = getattr(dev, ns, None) - if obj is None: - continue - meth = getattr(obj, fn, None) - if meth is None: - continue - for payload in payloads: - try: - meth([payload]) - return None - except Exception: - continue - - endpoint_candidates = [ - "downloads/removeLinks", - "downloadsV2/removeLinks", - "downloadcontroller/removeLinks", - ] - if call_raw_jd_api(dev, endpoint_candidates, payloads): - return None - - return "JDownloader-API: Abbrechen fehlgeschlagen (Wrapper-Methoden nicht vorhanden)." - -def cancel_job(dev, jobid: str) -> Optional[str]: - links, pkg_map = query_links_and_packages(dev, jobid) - local_paths = local_paths_from_links(links, pkg_map) - for path in local_paths: - try: - if os.path.isfile(path): - os.remove(path) - except Exception: - pass - try: - sidecar = os.path.join(MD5_DIR, os.path.basename(path) + ".md5") - if os.path.isfile(sidecar): - os.remove(sidecar) - except Exception: - pass - return try_cancel_from_jd(dev, links, pkg_map) - def calculate_progress(links: List[Dict[str, Any]]) -> float: - total = 0 + total = 0 loaded = 0 - for link in links: - bytes_total = link.get("bytesTotal") - if bytes_total is None: - bytes_total = link.get("totalBytes") - if bytes_total is None: - bytes_total = link.get("bytes") - bytes_loaded = link.get("bytesLoaded") - if bytes_total is None or bytes_loaded is None: + for l in links: + if l.get("finished"): + bytes_total = l.get("bytesTotal") or l.get("totalBytes") or 0 + total += bytes_total + loaded += bytes_total continue - try: - bytes_total = int(bytes_total) - bytes_loaded = int(bytes_loaded) - except (TypeError, ValueError): - continue - if bytes_total <= 0: - continue - total += bytes_total + bytes_total = l.get("bytesTotal") or l.get("totalBytes") or 0 + bytes_loaded = l.get("bytesLoaded") or l.get("bytes") or 0 + total += bytes_total loaded += min(bytes_loaded, bytes_total) if total <= 0: return 0.0 @@ -851,25 +618,25 @@ def _filter_linkgrabber(dev, jobid: str) -> Tuple[int, int]: links = [] try: links = dev.linkgrabber.query_links([{ - "jobUUIDs": [int(jobid)] if str(jobid).isdigit() else [jobid], + "jobUUIDs": [int(jobid)] if str(jobid).isdigit() else [jobid], "maxResults": -1, - "startAt": 0, - "name": True, - "size": True, - "uuid": True, + "startAt": 0, + "name": True, + "size": True, + "uuid": True, "packageUUID": True, }]) or [] except Exception: pass to_remove_ids = [] - keep_ids = [] - keep_pkg_ids = set() + keep_ids = [] + keep_pkg_ids = set() for link in links: - name = link.get("name", "") - size = link.get("size", -1) - _, ext = os.path.splitext(name.lower()) - is_video = ext in VIDEO_EXTS + name = link.get("name", "") + size = link.get("size", -1) + _, ext = os.path.splitext(name.lower()) + is_video = ext in VIDEO_EXTS big_enough = size < 0 or size >= MIN_VIDEO_BYTES if is_video and big_enough: keep_ids.append(link.get("uuid")) @@ -908,14 +675,14 @@ def worker(jobid: str): with lock: job = jobs.get(jobid) if job: - job.status = "collecting" + job.status = "collecting" job.message = f"Filtere Links (nur Videos \u2265 {MIN_VIDEO_SIZE_MB} MB)\u2026" accepted, rejected = _filter_linkgrabber(dev, jobid) with lock: job = jobs.get(jobid) if job: if accepted == 0: - job.status = "failed" + job.status = "failed" job.message = ( f"Keine Video-Dateien \u2265 {MIN_VIDEO_SIZE_MB} MB gefunden " f"({rejected} Link(s) verworfen)." @@ -932,7 +699,7 @@ def worker(jobid: str): if job.cancel_requested: cancel_msg = cancel_job(dev, jobid) with lock: - job.status = "canceled" + job.status = "canceled" job.message = cancel_msg or "Download abgebrochen und Dateien entfernt." job.progress = 0.0 return @@ -941,8 +708,8 @@ def worker(jobid: str): if not links: with lock: - job.status = "collecting" - job.message = "Warte auf Link-Crawler…" + job.status = "collecting" + job.message = "Warte auf Link-Crawler\u2026" job.progress = 0.0 time.sleep(POLL_SECONDS) continue @@ -951,8 +718,8 @@ def worker(jobid: str): if all_demo and not is_demo_link(job.url): cancel_msg = cancel_job(dev, jobid) with lock: - job.status = "failed" - base_msg = "JDownloader lieferte das Demo-Video Big Buck Bunny statt des gewünschten Links." + job.status = "failed" + base_msg = "JDownloader lieferte das Demo-Video Big Buck Bunny statt des gew\u00fcnschten Links." job.message = f"{base_msg} {cancel_msg}" if cancel_msg else base_msg job.progress = 0.0 return @@ -961,9 +728,9 @@ def worker(jobid: str): if not all_finished: progress = calculate_progress(links) with lock: - job.status = "downloading" - done = sum(1 for l in links if l.get("finished")) - job.message = f"Download läuft… ({done}/{len(links)} fertig)" + job.status = "downloading" + done = sum(1 for l in links if l.get("finished")) + job.message = f"Download l\u00e4uft\u2026 ({done}/{len(links)} fertig)" job.progress = progress time.sleep(POLL_SECONDS) continue @@ -973,7 +740,7 @@ def worker(jobid: str): if not video_files: with lock: - job.status = "failed" + job.status = "failed" job.message = "Keine Video-Datei gefunden (Whitelist)." job.progress = 0.0 return @@ -981,8 +748,8 @@ def worker(jobid: str): valid_videos = [p for p in video_files if ffprobe_ok(p)] if not valid_videos: with lock: - job.status = "failed" - job.message = "ffprobe: keine gültige Video-Datei." + job.status = "failed" + job.message = "ffprobe: keine g\u00fcltige Video-Datei." job.progress = 0.0 return @@ -991,8 +758,8 @@ def worker(jobid: str): if pkg_base: renamed = [] for idx, f in enumerate(valid_videos): - ext = os.path.splitext(f)[1] - suffix = f".part{idx + 1}" if len(valid_videos) > 1 else "" + ext = os.path.splitext(f)[1] + suffix = f".part{idx + 1}" if len(valid_videos) > 1 else "" new_path = os.path.join(os.path.dirname(f), f"{pkg_base}{suffix}{ext}") try: os.rename(f, new_path) @@ -1002,14 +769,14 @@ def worker(jobid: str): valid_videos = renamed with lock: - job.status = "upload" - job.message = f"Download fertig. MD5/Upload/Verify für {len(valid_videos)} Datei(en)…" + job.status = "upload" + job.message = f"Download fertig. MD5/Upload/Verify f\u00fcr {len(valid_videos)} Datei(en)\u2026" job.progress = 100.0 ssh = ssh_connect() try: for f in valid_videos: - md5_hex = md5_file(f) + md5_hex = md5_file(f) md5_path = write_md5_sidecar(f, md5_hex) remote_dir, remote_name = build_remote_paths(job.library, job.package_name, f) @@ -1042,7 +809,7 @@ def worker(jobid: str): jellyfin_refresh_library() with lock: - job.status = "finished" + job.status = "finished" job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.") job.progress = 100.0 return @@ -1051,7 +818,7 @@ def worker(jobid: str): with lock: job = jobs.get(jobid) if job: - job.status = "failed" + job.status = "failed" job.message = str(e) job.progress = 0.0 @@ -1064,268 +831,260 @@ def favicon(): @app.get("/jobs", response_class=HTMLResponse) def jobs_get(): - return HTMLResponse(render_job_rows()) - -@app.get("/logs", response_class=HTMLResponse) -def logs_get(): - return HTMLResponse(render_logs_page()) - -@app.get("/logs/data", response_class=PlainTextResponse) -def logs_data(): - return PlainTextResponse(get_connection_logs()) - -def render_job_rows() -> str: - rows = "" with lock: - job_list = list(jobs.values())[::-1] + job_list = list(jobs.values()) + with _log_lock: + log_lines = list(_conn_log) + return HTMLResponse(render_jobs_page(job_list, log_lines)) - for j in job_list: - progress_pct = f"{j.progress:.1f}%" - progress_html = ( - f"
{esc(j.id)}{esc(error)}
" if error else "" - auth_note = "aktiv" if _auth_enabled() else "aus" - return f""" - - - - -| Paket | URL | Status | Info | Fortschritt | Aktion | +
|---|
{log_html}
+
+"""
-
- Auth: {auth_note} |
- JD Output: {JD_OUTPUT_PATH} |
- Video-Whitelist: {", ".join(sorted(VIDEO_EXTS))}
-
| JobID | URL | Paket | Ziel | Status |
|---|
" + esc(error) + "
" if error else ""} + {"" + esc(message) + "
" if message else ""} -def render_nav(active: str) -> str: - def link(label: str, href: str, key: str) -> str: - style = "font-weight:700;" if active == key else "" - return f"{label}" - return ( - "Verbindungs-Debugger (Echtzeit). Letzte {LOG_BUFFER_LIMIT} Einträge.
- - - - """ +{esc(error)}
" if error else "" - msg_html = f"{esc(message)}
" if message else "" - return f""" - - - - -" + esc(out_text) + "" if out_text else ""} -
Format: socks5://IP:PORT, socks4://IP:PORT. Keine Prüfung/Validierung.
Speichert die Liste als .jdproxies im Container, z. B. zum Import in JDownloader → Verbindungsmanager → Importieren.
Aktueller Pfad: {esc(export_path or PROXY_EXPORT_PATH)}