diff --git a/jd-webgui/app.py b/jd-webgui/app.py index ac73500..974363f 100644 --- a/jd-webgui/app.py +++ b/jd-webgui/app.py @@ -3,17 +3,17 @@ from __future__ import annotations import base64 import hashlib +import json import os import re import shlex import subprocess import threading import time +import urllib.parse +import urllib.request from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple -import json -import urllib.request -import urllib.parse from myjdapi import Myjdapi import paramiko @@ -33,23 +33,10 @@ 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") -# Optional: getrennte Ziele für Filme/Serien 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("/") -# Fallback-Ziel (wenn movies/series nicht gesetzt) -JELLYFIN_DEST_DIR = os.environ.get("JELLYFIN_DEST_DIR", "/srv/media/movies/inbox").rstrip("/") - -# Auth (optional) -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")) - -# JDownloader speichert im Container nach /output -JD_OUTPUT_PATH = "/output" - -URL_RE = re.compile(r"^https?://", re.I) 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" @@ -60,17 +47,25 @@ TMDB_LANGUAGE = os.environ.get("TMDB_LANGUAGE", "de-DE") 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", "/tmp/md5").rstrip("/") +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")) + +# JDownloader writes here inside container +JD_OUTPUT_PATH = "/output" + +URL_RE = re.compile(r"^https?://", re.I) -# gängige Videoformate VIDEO_EXTS = { ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv", - ".3gp", ".3g2" + ".3gp", ".3g2", } IGNORE_EXTS = {".part", ".tmp", ".crdownload"} -# Serien-Heuristik (S01E02 etc.) SERIES_RE = re.compile(r"(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,2})(?:[^a-z0-9]|$)", re.IGNORECASE) app = FastAPI() @@ -127,35 +122,67 @@ jobs: Dict[str, Job] = {} lock = threading.Lock() # ============================================================ -# Helpers +# Core helpers # ============================================================ def ensure_env(): missing = [] for k, v in [ ("MYJD_EMAIL", MYJD_EMAIL), ("MYJD_PASSWORD", MYJD_PASSWORD), - ("MYJD_DEVICE", MYJD_DEVICE), ("JELLYFIN_USER", JELLYFIN_USER), ("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY), ]: if not v: missing.append(k) - # Zielverzeichnisse: entweder MOVIES/SERIES oder DEST + if not (JELLYFIN_DEST_DIR or (JELLYFIN_MOVIES_DIR and JELLYFIN_SERIES_DIR)): missing.append("JELLYFIN_DEST_DIR or (JELLYFIN_MOVIES_DIR+JELLYFIN_SERIES_DIR)") - if missing: - raise RuntimeError("Missing env vars: " + ", ".join(missing)) + if JELLYFIN_LIBRARY_REFRESH and not (JELLYFIN_API_BASE and JELLYFIN_API_KEY): missing.append("JELLYFIN_API_BASE+JELLYFIN_API_KEY (required when JELLYFIN_LIBRARY_REFRESH=true)") + if missing: + raise RuntimeError("Missing env vars: " + ", ".join(missing)) + def get_device(): + """ + Connects to MyJDownloader and returns a device. + If MYJD_DEVICE is empty or not found, falls back to the first available device. + """ jd = Myjdapi() jd.connect(MYJD_EMAIL, MYJD_PASSWORD) jd.update_devices() - dev = jd.get_device(MYJD_DEVICE) - if dev is None: - raise RuntimeError(f"MyJDownloader device not found: {MYJD_DEVICE}") - return dev + + devices = getattr(jd, "devices", None) or [] + if not devices: + raise RuntimeError("No MyJDownloader devices available (is JDownloader online/logged in?)") + + wanted = (MYJD_DEVICE or "").strip() + if wanted: + try: + return jd.get_device(wanted) + except Exception: + pass + + # Prefer a device that looks like JD + for d in devices: + n = (d.get("name") or "").strip() + if not n: + continue + nl = n.lower() + if "jdownloader" in nl or nl in {"jd", "jd2"}: + try: + return jd.get_device(n) + except Exception: + pass + + # Otherwise pick first device by name + for d in devices: + n = (d.get("name") or "").strip() + if n: + return jd.get_device(n) + + raise RuntimeError("MyJDownloader devices list had no usable names") def is_video_file(path: str) -> bool: name = os.path.basename(path).lower() @@ -179,12 +206,7 @@ def write_md5_sidecar(file_path: str, md5_hex: str) -> str: f.write(f"{md5_hex} {base}\n") return md5_path - def ffprobe_ok(path: str) -> bool: - """ - Validiert, dass die Datei wirklich ein Video ist (Container/Streams lesbar). - Erfordert ffprobe im Container (kommt über Dockerfile). - """ try: cp = subprocess.run( ["ffprobe", "-v", "error", "-show_streams", "-select_streams", "v:0", path], @@ -197,6 +219,9 @@ def ffprobe_ok(path: str) -> bool: except Exception: return False +# ============================================================ +# SSH/SFTP +# ============================================================ def ssh_connect() -> paramiko.SSHClient: ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -239,11 +264,38 @@ def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: raise RuntimeError("Remote md5sum returned empty output") return out.split()[0] +# ============================================================ +# TMDB & naming +# ============================================================ +def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(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}" + data = _http_get_json(url) + 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}" + data = _http_get_json(url) + results = data.get("results") or [] + return results[0] if results else None + +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) + def pick_library_target(library_choice: str, filename: str, package_name: str) -> str: - """ - - library_choice: movies|series|auto - - auto: heuristic SxxEyy in filename or package - """ if library_choice not in {"movies", "series", "auto"}: library_choice = "auto" @@ -258,9 +310,71 @@ def pick_library_target(library_choice: str, filename: str, package_name: str) - if library_choice == "series" and JELLYFIN_SERIES_DIR: return JELLYFIN_SERIES_DIR - # fallback return 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) + base_target = pick_library_target(job_library, filename, package_name) + + m = SERIES_RE.search(filename) or SERIES_RE.search(package_name or "") + is_series = (job_library == "series") or (job_library == "auto" and m) + + if is_series: + show_query = package_name or os.path.splitext(filename)[0] + 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 + episode = int(m.group(2)) if m else 1 + + if CREATE_SERIES_FOLDERS: + remote_dir = f"{base_target}/{show_name}/Season {season:02d}" + else: + remote_dir = base_target + + ext = os.path.splitext(filename)[1] + remote_filename = f"{show_name} - S{season:02d}E{episode:02d}{ext}" + 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 + 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 + + title_safe = sanitize_name(title) if title else sanitize_name(movie_query) + year_safe = year if year else "" + + if CREATE_MOVIE_FOLDER: + folder = f"{title_safe} ({year_safe})".strip() if year_safe else title_safe + remote_dir = f"{base_target}/{folder}" + else: + remote_dir = base_target + + ext = os.path.splitext(filename)[1] + remote_filename = f"{title_safe} ({year_safe}){ext}".strip() if year_safe else f"{title_safe}{ext}" + return remote_dir, remote_filename + +# ============================================================ +# Jellyfin refresh (optional) +# ============================================================ +def jellyfin_refresh_library(): + if not (JELLYFIN_API_BASE and JELLYFIN_API_KEY): + return + 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") + with urllib.request.urlopen(req, timeout=20) as r: + _ = r.read() + return + except Exception: + continue + +# ============================================================ +# JDownloader queries/cleanup (best effort) +# ============================================================ 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], @@ -298,7 +412,6 @@ def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[ base = save_to if isinstance(save_to, str) else JD_OUTPUT_PATH paths.append(os.path.join(base, name)) - # dedupe out, seen = [], set() for p in paths: if p not in seen: @@ -307,13 +420,9 @@ def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[ return out def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]: - """ - Best effort removal. Wrapper/API version differences exist. - """ link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] pkg_ids = list(pkg_map.keys()) - # Try several known method names & payload styles candidates = [ ("downloads", "removeLinks"), ("downloads", "remove_links"), @@ -339,111 +448,13 @@ def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict continue for payload in payloads: try: - meth([payload]) # most wrappers expect list + meth([payload]) return None except Exception: continue return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)." -def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: - req = urllib.request.Request(url, headers=headers or {}) - with urllib.request.urlopen(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}" - data = _http_get_json(url) - 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}" - data = _http_get_json(url) - results = data.get("results") or [] - return results[0] if results else None - -def sanitize_name(name: str) -> str: - # Windows/SMB safe - bad = '<>:"/\\\\|?*' - out = "".join("_" if c in bad else c for c in name).strip() - return re.sub(r"\s+", " ", out) - -def build_remote_paths(job_library: str, package_name: str, local_file: str) -> Tuple[str, str]: - """ - Returns: (remote_dir, remote_filename) - """ - filename = os.path.basename(local_file) - base_target = pick_library_target(job_library, filename, package_name) # nutzt env movies/series/fallback - - # Serien-Erkennung - m = SERIES_RE.search(filename) or SERIES_RE.search(package_name or "") - is_series = (job_library == "series") or (job_library == "auto" and m) - - if is_series: - # Show-Name via TMDB (optional) - show_query = package_name or os.path.splitext(filename)[0] - 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 - # Remote dir: /Serien/Show/Season 01 - if CREATE_SERIES_FOLDERS: - remote_dir = f"{base_target}/{show_name}/Season {season:02d}" - else: - remote_dir = base_target - - # Dateiname bleibt, oder optional: Show - S01E02.ext - ext = os.path.splitext(filename)[1] - if m: - remote_filename = f"{show_name} - S{season:02d}E{int(m.group(2)):02d}{ext}" - else: - remote_filename = filename - return remote_dir, remote_filename - - # Movie: TMDB (optional) - movie_query = package_name or os.path.splitext(filename)[0] - 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 - - title_safe = sanitize_name(title) if title else sanitize_name(movie_query) - year_safe = year if year else "" - - # Ordner pro Film - if CREATE_MOVIE_FOLDER: - folder = f"{title_safe} ({year_safe})".strip() if year_safe else title_safe - remote_dir = f"{base_target}/{folder}" - else: - remote_dir = base_target - - ext = os.path.splitext(filename)[1] - remote_filename = f"{title_safe} ({year_safe}){ext}".strip() if year_safe else f"{title_safe}{ext}" - return remote_dir, remote_filename - -def jellyfin_refresh_library(): - if not (JELLYFIN_API_BASE and JELLYFIN_API_KEY): - return - # Jellyfin akzeptiert Token in Headern; /Library/Refresh bzw /library/refresh werden je nach Version genutzt. :contentReference[oaicite:0]{index=0} - 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") - with urllib.request.urlopen(req, timeout=20) as r: - _ = r.read() - return - except Exception: - continue - - # ============================================================ # Worker # ============================================================ @@ -485,12 +496,11 @@ def worker(jobid: str): job.message = "Keine Video-Datei gefunden (Whitelist)." return - # ffprobe validation (only keep valid videos) 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 (oder ffprobe fehlt)." + job.message = "ffprobe: keine gültige Video-Datei." return with lock: @@ -500,26 +510,21 @@ def worker(jobid: str): ssh = ssh_connect() try: for f in valid_videos: - fn = os.path.basename(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) remote_file = f"{remote_dir}/{remote_name}" remote_md5f = remote_file + ".md5" - - # MD5 local - md5_hex = md5_file(f) - md5_path = write_md5_sidecar(f, md5_hex) - - # Upload file + md5 sftp_upload(ssh, f, remote_file) sftp_upload(ssh, md5_path, remote_md5f) - # Verify remote remote_md5 = remote_md5sum(ssh, remote_file) if remote_md5.lower() != md5_hex.lower(): - raise RuntimeError(f"MD5 mismatch for {fn}: local={md5_hex} remote={remote_md5}") + raise RuntimeError(f"MD5 mismatch for {os.path.basename(f)}: local={md5_hex} remote={remote_md5}") - # Cleanup local after successful verify + # Cleanup local try: os.remove(f) except Exception: @@ -532,12 +537,11 @@ def worker(jobid: str): finally: ssh.close() + jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map) + if JELLYFIN_LIBRARY_REFRESH: jellyfin_refresh_library() - # Cleanup JD package/links (best effort) - jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map) - with lock: job.status = "finished" job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.") @@ -553,10 +557,15 @@ def worker(jobid: str): # ============================================================ # Web # ============================================================ +@app.get("/favicon.ico") +def favicon(): + return HTMLResponse(status_code=204) + def render_page(error: str = "") -> str: rows = "" with lock: job_list = list(jobs.values())[::-1] + for j in job_list: rows += ( f""