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:
2025-12-30 23:15:17 +01:00
committed by GitHub
parent 1801cd388f
commit 13daf39adc

View File

@@ -11,6 +11,9 @@ import threading
import time import time
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
@@ -47,6 +50,17 @@ POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5"))
JD_OUTPUT_PATH = "/output" JD_OUTPUT_PATH = "/output"
URL_RE = re.compile(r"^https?://", re.I) 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 # gängige Videoformate
VIDEO_EXTS = { VIDEO_EXTS = {
@@ -131,6 +145,8 @@ def ensure_env():
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: if missing:
raise RuntimeError("Missing env vars: " + ", ".join(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(): def get_device():
jd = Myjdapi() jd = Myjdapi()
@@ -156,11 +172,14 @@ def md5_file(path: str) -> str:
return h.hexdigest() return h.hexdigest()
def write_md5_sidecar(file_path: str, md5_hex: str) -> str: 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: 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 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). 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)." 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
# ============================================================ # ============================================================
@@ -384,10 +501,11 @@ def worker(jobid: str):
try: try:
for f in valid_videos: for f in valid_videos:
fn = os.path.basename(f) fn = os.path.basename(f)
target_dir = pick_library_target(job.library, fn, job.package_name) remote_dir, remote_name = build_remote_paths(job.library, job.package_name, f)
remote_file = f"{target_dir}/{fn}" remote_file = f"{remote_dir}/{remote_name}"
remote_md5f = remote_file + ".md5" remote_md5f = remote_file + ".md5"
# MD5 local # MD5 local
md5_hex = md5_file(f) md5_hex = md5_file(f)
md5_path = write_md5_sidecar(f, md5_hex) md5_path = write_md5_sidecar(f, md5_hex)
@@ -414,6 +532,9 @@ def worker(jobid: str):
finally: finally:
ssh.close() ssh.close()
if JELLYFIN_LIBRARY_REFRESH:
jellyfin_refresh_library()
# Cleanup JD package/links (best effort) # Cleanup JD package/links (best effort)
jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map) jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map)