Integrate Jellyfin and TMDB API functionality
Added configuration for Jellyfin API and TMDB API. Implemented functions for searching movies and TV shows, and for refreshing the Jellyfin library.
This commit is contained in:
129
jd-webgui/app.py
129
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user