diff --git a/jd-webgui/app.py b/jd-webgui/app.py index faac444..ac73500 100644 --- a/jd-webgui/app.py +++ b/jd-webgui/app.py @@ -11,6 +11,9 @@ import threading import time 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 @@ -47,6 +50,17 @@ POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5")) 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" + +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_SERIES_FOLDERS = os.environ.get("CREATE_SERIES_FOLDERS", "true").lower() == "true" + +MD5_DIR = os.environ.get("MD5_DIR", "/tmp/md5").rstrip("/") # gängige Videoformate VIDEO_EXTS = { @@ -131,6 +145,8 @@ def ensure_env(): 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)") def get_device(): jd = Myjdapi() @@ -156,11 +172,14 @@ def md5_file(path: str) -> str: return h.hexdigest() def write_md5_sidecar(file_path: str, md5_hex: str) -> str: - md5_path = file_path + ".md5" + os.makedirs(MD5_DIR, exist_ok=True) + base = os.path.basename(file_path) + md5_path = os.path.join(MD5_DIR, base + ".md5") with open(md5_path, "w", encoding="utf-8") as f: - f.write(f"{md5_hex} {os.path.basename(file_path)}\n") + 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). @@ -327,6 +346,104 @@ def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict 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 # ============================================================ @@ -384,10 +501,11 @@ def worker(jobid: str): try: for f in valid_videos: fn = os.path.basename(f) - target_dir = pick_library_target(job.library, fn, job.package_name) - remote_file = f"{target_dir}/{fn}" + 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) @@ -414,6 +532,9 @@ def worker(jobid: str): finally: ssh.close() + if JELLYFIN_LIBRARY_REFRESH: + jellyfin_refresh_library() + # Cleanup JD package/links (best effort) jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map)