Refactor app.py for better readability and structure
Refactor environment variable handling and improve code structure.
This commit is contained in:
327
jd-webgui/app.py
327
jd-webgui/app.py
@@ -3,17 +3,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
import json
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from myjdapi import Myjdapi
|
from myjdapi import Myjdapi
|
||||||
import paramiko
|
import paramiko
|
||||||
@@ -33,23 +33,10 @@ JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22"))
|
|||||||
JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "")
|
JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "")
|
||||||
JELLYFIN_SSH_KEY = os.environ.get("JELLYFIN_SSH_KEY", "/ssh/id_ed25519")
|
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_MOVIES_DIR = os.environ.get("JELLYFIN_MOVIES_DIR", "").rstrip("/")
|
||||||
JELLYFIN_SERIES_DIR = os.environ.get("JELLYFIN_SERIES_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_BASE = os.environ.get("JELLYFIN_API_BASE", "").rstrip("/")
|
||||||
JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "")
|
JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "")
|
||||||
JELLYFIN_LIBRARY_REFRESH = os.environ.get("JELLYFIN_LIBRARY_REFRESH", "false").lower() == "true"
|
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_MOVIE_FOLDER = os.environ.get("CREATE_MOVIE_FOLDER", "true").lower() == "true"
|
||||||
CREATE_SERIES_FOLDERS = os.environ.get("CREATE_SERIES_FOLDERS", "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 = {
|
VIDEO_EXTS = {
|
||||||
".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm",
|
".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm",
|
||||||
".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv",
|
".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv",
|
||||||
".3gp", ".3g2"
|
".3gp", ".3g2",
|
||||||
}
|
}
|
||||||
IGNORE_EXTS = {".part", ".tmp", ".crdownload"}
|
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)
|
SERIES_RE = re.compile(r"(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,2})(?:[^a-z0-9]|$)", re.IGNORECASE)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -127,35 +122,67 @@ jobs: Dict[str, Job] = {}
|
|||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Helpers
|
# Core helpers
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def ensure_env():
|
def ensure_env():
|
||||||
missing = []
|
missing = []
|
||||||
for k, v in [
|
for k, v in [
|
||||||
("MYJD_EMAIL", MYJD_EMAIL),
|
("MYJD_EMAIL", MYJD_EMAIL),
|
||||||
("MYJD_PASSWORD", MYJD_PASSWORD),
|
("MYJD_PASSWORD", MYJD_PASSWORD),
|
||||||
("MYJD_DEVICE", MYJD_DEVICE),
|
|
||||||
("JELLYFIN_USER", JELLYFIN_USER),
|
("JELLYFIN_USER", JELLYFIN_USER),
|
||||||
("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY),
|
("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY),
|
||||||
]:
|
]:
|
||||||
if not v:
|
if not v:
|
||||||
missing.append(k)
|
missing.append(k)
|
||||||
# Zielverzeichnisse: entweder MOVIES/SERIES oder DEST
|
|
||||||
if not (JELLYFIN_DEST_DIR or (JELLYFIN_MOVIES_DIR and JELLYFIN_SERIES_DIR)):
|
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)")
|
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):
|
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)")
|
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():
|
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 = Myjdapi()
|
||||||
jd.connect(MYJD_EMAIL, MYJD_PASSWORD)
|
jd.connect(MYJD_EMAIL, MYJD_PASSWORD)
|
||||||
jd.update_devices()
|
jd.update_devices()
|
||||||
dev = jd.get_device(MYJD_DEVICE)
|
|
||||||
if dev is None:
|
devices = getattr(jd, "devices", None) or []
|
||||||
raise RuntimeError(f"MyJDownloader device not found: {MYJD_DEVICE}")
|
if not devices:
|
||||||
return dev
|
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:
|
def is_video_file(path: str) -> bool:
|
||||||
name = os.path.basename(path).lower()
|
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")
|
f.write(f"{md5_hex} {base}\n")
|
||||||
return md5_path
|
return md5_path
|
||||||
|
|
||||||
|
|
||||||
def ffprobe_ok(path: str) -> bool:
|
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:
|
try:
|
||||||
cp = subprocess.run(
|
cp = subprocess.run(
|
||||||
["ffprobe", "-v", "error", "-show_streams", "-select_streams", "v:0", path],
|
["ffprobe", "-v", "error", "-show_streams", "-select_streams", "v:0", path],
|
||||||
@@ -197,6 +219,9 @@ def ffprobe_ok(path: str) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SSH/SFTP
|
||||||
|
# ============================================================
|
||||||
def ssh_connect() -> paramiko.SSHClient:
|
def ssh_connect() -> paramiko.SSHClient:
|
||||||
ssh = paramiko.SSHClient()
|
ssh = paramiko.SSHClient()
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
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")
|
raise RuntimeError("Remote md5sum returned empty output")
|
||||||
return out.split()[0]
|
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:
|
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"}:
|
if library_choice not in {"movies", "series", "auto"}:
|
||||||
library_choice = "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:
|
if library_choice == "series" and JELLYFIN_SERIES_DIR:
|
||||||
return JELLYFIN_SERIES_DIR
|
return JELLYFIN_SERIES_DIR
|
||||||
|
|
||||||
# fallback
|
|
||||||
return JELLYFIN_DEST_DIR
|
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]]]:
|
def query_links_and_packages(dev, jobid: str) -> Tuple[List[Dict[str, Any]], Dict[Any, Dict[str, Any]]]:
|
||||||
links = dev.downloads.query_links([{
|
links = dev.downloads.query_links([{
|
||||||
"jobUUIDs": [int(jobid)] if jobid.isdigit() else [jobid],
|
"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
|
base = save_to if isinstance(save_to, str) else JD_OUTPUT_PATH
|
||||||
paths.append(os.path.join(base, name))
|
paths.append(os.path.join(base, name))
|
||||||
|
|
||||||
# dedupe
|
|
||||||
out, seen = [], set()
|
out, seen = [], set()
|
||||||
for p in paths:
|
for p in paths:
|
||||||
if p not in seen:
|
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
|
return out
|
||||||
|
|
||||||
def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]:
|
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]
|
link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None]
|
||||||
pkg_ids = list(pkg_map.keys())
|
pkg_ids = list(pkg_map.keys())
|
||||||
|
|
||||||
# Try several known method names & payload styles
|
|
||||||
candidates = [
|
candidates = [
|
||||||
("downloads", "removeLinks"),
|
("downloads", "removeLinks"),
|
||||||
("downloads", "remove_links"),
|
("downloads", "remove_links"),
|
||||||
@@ -339,111 +448,13 @@ def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict
|
|||||||
continue
|
continue
|
||||||
for payload in payloads:
|
for payload in payloads:
|
||||||
try:
|
try:
|
||||||
meth([payload]) # most wrappers expect list
|
meth([payload])
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)."
|
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
|
# Worker
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -485,12 +496,11 @@ def worker(jobid: str):
|
|||||||
job.message = "Keine Video-Datei gefunden (Whitelist)."
|
job.message = "Keine Video-Datei gefunden (Whitelist)."
|
||||||
return
|
return
|
||||||
|
|
||||||
# ffprobe validation (only keep valid videos)
|
|
||||||
valid_videos = [p for p in video_files if ffprobe_ok(p)]
|
valid_videos = [p for p in video_files if ffprobe_ok(p)]
|
||||||
if not valid_videos:
|
if not valid_videos:
|
||||||
with lock:
|
with lock:
|
||||||
job.status = "failed"
|
job.status = "failed"
|
||||||
job.message = "ffprobe: keine gültige Video-Datei (oder ffprobe fehlt)."
|
job.message = "ffprobe: keine gültige Video-Datei."
|
||||||
return
|
return
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
@@ -500,26 +510,21 @@ def worker(jobid: str):
|
|||||||
ssh = ssh_connect()
|
ssh = ssh_connect()
|
||||||
try:
|
try:
|
||||||
for f in valid_videos:
|
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_dir, remote_name = build_remote_paths(job.library, job.package_name, f)
|
||||||
remote_file = f"{remote_dir}/{remote_name}"
|
remote_file = f"{remote_dir}/{remote_name}"
|
||||||
remote_md5f = remote_file + ".md5"
|
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, f, remote_file)
|
||||||
sftp_upload(ssh, md5_path, remote_md5f)
|
sftp_upload(ssh, md5_path, remote_md5f)
|
||||||
|
|
||||||
# Verify remote
|
|
||||||
remote_md5 = remote_md5sum(ssh, remote_file)
|
remote_md5 = remote_md5sum(ssh, remote_file)
|
||||||
if remote_md5.lower() != md5_hex.lower():
|
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:
|
try:
|
||||||
os.remove(f)
|
os.remove(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -532,12 +537,11 @@ def worker(jobid: str):
|
|||||||
finally:
|
finally:
|
||||||
ssh.close()
|
ssh.close()
|
||||||
|
|
||||||
|
jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map)
|
||||||
|
|
||||||
if JELLYFIN_LIBRARY_REFRESH:
|
if JELLYFIN_LIBRARY_REFRESH:
|
||||||
jellyfin_refresh_library()
|
jellyfin_refresh_library()
|
||||||
|
|
||||||
# Cleanup JD package/links (best effort)
|
|
||||||
jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map)
|
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
job.status = "finished"
|
job.status = "finished"
|
||||||
job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.")
|
job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.")
|
||||||
@@ -553,10 +557,15 @@ def worker(jobid: str):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Web
|
# Web
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
@app.get("/favicon.ico")
|
||||||
|
def favicon():
|
||||||
|
return HTMLResponse(status_code=204)
|
||||||
|
|
||||||
def render_page(error: str = "") -> str:
|
def render_page(error: str = "") -> str:
|
||||||
rows = ""
|
rows = ""
|
||||||
with lock:
|
with lock:
|
||||||
job_list = list(jobs.values())[::-1]
|
job_list = list(jobs.values())[::-1]
|
||||||
|
|
||||||
for j in job_list:
|
for j in job_list:
|
||||||
rows += (
|
rows += (
|
||||||
f"<tr>"
|
f"<tr>"
|
||||||
|
|||||||
Reference in New Issue
Block a user