Files
importarr/importarr/worker.py
T

1094 lines
39 KiB
Python

#!/usr/bin/env python3
"""Import manual SABnzbd downloads into Jellyfin movie/TV library roots."""
from __future__ import annotations
import argparse
import fcntl
import gzip
import json
import os
import re
import shutil
import sqlite3
import sys
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import Counter
from datetime import datetime
from pathlib import Path
MANUAL_DOWNLOADS = Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual"))
LEGACY_DANISH_DOWNLOADS = Path(os.getenv("IMPORTARR_LEGACY_DOWNLOAD_ROOT", "/data/downloads/legacy"))
DOWNLOAD_ROOTS = [MANUAL_DOWNLOADS, LEGACY_DANISH_DOWNLOADS]
SAB_TRANSIENT_PREFIXES = ("_UNPACK_", "__UNPACK__", "_FAILED_", "_ADMIN_")
HOST_MEDIA_PREFIX = os.getenv("IMPORTARR_HOST_MEDIA_PREFIX", "/data")
MOVIES_ROOT = Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies"))
TV_ROOT = Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv"))
LOG = Path("/var/log/manual-media-import.log")
LOCK = Path("/run/manual-media-import.lock")
STATUS = Path("/run/manual-media-import/status.json")
MANUAL_BATCHES = Path("/var/lib/importarr/manual-batches.json")
HOME_ASSISTANT_WEBHOOK = os.getenv("IMPORTARR_HOME_ASSISTANT_WEBHOOK", "")
RADARR_CONFIG = Path(os.getenv("IMPORTARR_RADARR_CONFIG", "/config/radarr/config.xml"))
RADARR_URL = os.getenv("IMPORTARR_RADARR_URL", "http://radarr:7878")
RADARR_DB = Path(os.getenv("IMPORTARR_RADARR_DB", "/config/radarr/radarr.db"))
SONARR_CONFIG = Path(os.getenv("IMPORTARR_SONARR_CONFIG", "/config/sonarr/config.xml"))
SONARR_URL = os.getenv("IMPORTARR_SONARR_URL", "http://sonarr:8989")
SONARR_DB = Path(os.getenv("IMPORTARR_SONARR_DB", "/config/sonarr/sonarr.db"))
SABNZBD_CONFIG = Path(os.getenv("IMPORTARR_SABNZBD_CONFIG", "/config/sabnzbd/sabnzbd.ini"))
SABNZBD_URL = os.getenv("IMPORTARR_SABNZBD_URL", "http://sabnzbd:8080/api")
ARR_API_TIMEOUT = 10
VIDEO_EXT = {".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts", ".webm"}
SIDECAR_EXT = {".srt", ".ass", ".sub", ".idx", ".nfo"}
MIN_SIZE = 100 * 1024 * 1024
IMDB_DATASET_CANDIDATES = [
Path("/data/imdb"),
Path("/data/imdb-datasets"),
Path("/data/.cache/imdb"),
Path("/var/lib/imdb"),
Path("/opt/imdb"),
]
STOP_TOKENS = {
"x264",
"x265",
"h264",
"h265",
"hevc",
"aac",
"ac3",
"dts",
"ddp5",
"atmos",
"hdr",
"dv",
"repack",
"proper",
"remux",
"internal",
"sample",
"mkv",
"mp4",
"avi",
}
class Skip(Exception):
pass
def log(level: str, msg: str, **kw: object) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
rec = {"ts": datetime.now().isoformat(timespec="seconds"), "level": level, "msg": msg, **kw}
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"[{level}] {msg} {kw}")
def write_status(phase: str, **kw: object) -> None:
STATUS.parent.mkdir(parents=True, exist_ok=True)
payload = {"ts": datetime.now().isoformat(timespec="seconds"), "phase": phase, **kw}
tmp = STATUS.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS)
def norm(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
def toks(text: str) -> list[str]:
return [t for t in norm(text).split() if t not in {"the", "and", "in", "of", "a", "an"}]
def title_match_score(query: str, candidate: str) -> int:
qn = norm(query)
cn = norm(candidate)
if not qn or not cn:
return 0
if qn == cn:
return 100
qt = set(toks(query))
ct = set(toks(candidate))
if not qt or not ct:
return 0
overlap = len(qt & ct)
if overlap == 0:
return 0
return overlap * 10 - abs(len(qt) - overlap)
def sanitize_title(title: str) -> str:
title = re.sub(r"[\\/:*?\"<>|]", " ", title)
return re.sub(r"\s+", " ", title).strip().strip(".")
def open_tsv_dataset(base_dir: Path, stem: str):
plain = base_dir / f"{stem}.tsv"
gz = base_dir / f"{stem}.tsv.gz"
if plain.is_file():
return plain.open("rt", encoding="utf-8", errors="replace")
if gz.is_file():
return gzip.open(gz, "rt", encoding="utf-8", errors="replace")
return None
def discover_imdb_dataset_dir() -> Path | None:
env_dir = os.environ.get("IMDB_DATASET_DIR")
candidates = [Path(env_dir)] if env_dir else []
candidates.extend(IMDB_DATASET_CANDIDATES)
for base in candidates:
if (base / "title.basics.tsv").is_file() or (base / "title.basics.tsv.gz").is_file():
return base
return None
def resolve_with_imdb_datasets(source_title: str, year: int, imdb_dir: Path) -> tuple[str, int] | None:
basics = open_tsv_dataset(imdb_dir, "title.basics")
if basics is None:
return None
wanted = range(max(1888, year - 1), year + 2)
candidates: list[dict[str, object]] = []
try:
next(basics, None)
for line in basics:
cols = line.rstrip("\n").split("\t")
if len(cols) < 9:
continue
tconst, title_type, primary, original, _, _, start_year, _, _ = cols[:9]
if title_type not in {"movie", "tvMovie"} or start_year == "\\N":
continue
try:
movie_year = int(start_year)
except ValueError:
continue
if movie_year not in wanted:
continue
score = max(title_match_score(source_title, primary), title_match_score(source_title, original))
if score > 0:
candidates.append({"tconst": tconst, "title": primary, "year": movie_year, "score": score})
finally:
basics.close()
if not candidates:
return None
akas = open_tsv_dataset(imdb_dir, "title.akas")
if akas is not None:
boosts = {str(c["tconst"]): 0 for c in candidates}
try:
next(akas, None)
wanted_ids = set(boosts)
for line in akas:
cols = line.rstrip("\n").split("\t")
if len(cols) < 3 or cols[0] not in wanted_ids:
continue
boosts[cols[0]] = max(boosts[cols[0]], title_match_score(source_title, cols[2]))
finally:
akas.close()
for c in candidates:
c["score"] = int(c["score"]) + boosts.get(str(c["tconst"]), 0)
candidates.sort(key=lambda c: (-int(c["score"]), abs(int(c["year"]) - year), str(c["title"])))
top = candidates[0]
if len(candidates) > 1 and candidates[1]["score"] == top["score"]:
return None
return sanitize_title(str(top["title"])), int(top["year"])
def quality_source_tokens(name: str) -> list[str]:
text = Path(name).stem.replace(".", " ").replace("_", " ")
words = text.split()
lower_words = [w.lower() for w in words]
parts: list[str] = []
quality = next((w for w in words if re.fullmatch(r"(?i)(2160p|1080p|720p|480p)", w)), None)
if quality:
parts.append(quality)
if "bluray" in lower_words or ("blu" in lower_words and "ray" in lower_words):
parts.append("BluRay")
elif any(w in lower_words for w in {"web", "web-dl", "webdl", "webrip"}):
parts.append("WEB-DL")
elif "hdtv" in lower_words:
parts.append("HDTV")
for w in reversed(words):
clean = re.sub(r"[^A-Za-z0-9-]", "", w)
if not clean:
continue
if clean.lower() in STOP_TOKENS:
continue
if re.fullmatch(r"\d{4}|\d+", clean):
continue
parts.append(clean)
break
# preserve order and dedupe
out: list[str] = []
seen: set[str] = set()
for p in parts:
k = p.lower()
if k in seen:
continue
seen.add(k)
out.append(p)
return out
def safe_label(filename: str, source_tag: str, for_existing: bool = False) -> str:
tokens = quality_source_tokens(filename)
if for_existing:
prefix = "Existing"
elif source_tag == "manual":
prefix = "Manual"
elif source_tag == "danish-legacy":
prefix = "Nordic"
else:
prefix = "Imported"
if tokens:
return f"{prefix} {' '.join(tokens)}"
return prefix
def stable(path: Path) -> bool:
s1 = path.stat().st_size
time.sleep(5)
return s1 == path.stat().st_size
def unique(folder: Path, base: str, ext: str) -> Path:
p = folder / (base + ext)
i = 2
while p.exists():
p = folder / (f"{base} {i}" + ext)
i += 1
return p
def api_key_from_config(config_path: Path) -> str | None:
if not config_path.is_file():
return None
key = ET.parse(config_path).findtext("ApiKey")
return key or None
def sabnzbd_api_key() -> str | None:
try:
m = re.search(r"^api_key\s*=\s*(\S+)", SABNZBD_CONFIG.read_text(errors="replace"), re.M)
except OSError:
return None
return m.group(1) if m else None
def sabnzbd_history() -> list[dict[str, object]]:
key = sabnzbd_api_key()
if not key:
return []
url = SABNZBD_URL + "?" + urllib.parse.urlencode({"mode": "history", "output": "json", "limit": 200, "apikey": key})
try:
data = json.load(urllib.request.urlopen(url, timeout=ARR_API_TIMEOUT))
except Exception as exc:
log("WARN", "SABnzbd history unavailable; using filesystem-only readiness", error=str(exc))
return []
return data.get("history", {}).get("slots", [])
def sabnzbd_readiness_by_name() -> dict[str, bool]:
out: dict[str, bool] = {}
for item in sabnzbd_history():
name = str(item.get("name") or "")
if not name:
continue
status = str(item.get("status") or "")
storage = str(item.get("storage") or "")
category = str(item.get("category") or item.get("cat") or "")
out[name] = category == "manual" and status == "Completed" and bool(storage) and "_UNPACK_" not in storage
return out
def manual_batch_roots() -> list[Path]:
try:
raw = json.loads(MANUAL_BATCHES.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
out = []
for item in raw if isinstance(raw, list) else []:
try:
p = Path(str(item)).resolve()
if any(p == root.resolve() or p.is_relative_to(root.resolve()) for root in DOWNLOAD_ROOTS):
out.append(p)
except OSError:
continue
return out
def under_manual_batch(path: Path, batches: list[Path]) -> bool:
try:
resolved = path.resolve()
except OSError:
return False
return any(resolved == batch or resolved.is_relative_to(batch) for batch in batches)
def prune_manual_batches() -> None:
batches = manual_batch_roots()
keep = []
for batch in batches:
if any(p.is_file() and p.suffix.lower() in VIDEO_EXT for p in batch.rglob("*")):
keep.append(str(batch))
MANUAL_BATCHES.parent.mkdir(parents=True, exist_ok=True)
MANUAL_BATCHES.write_text(json.dumps(keep, indent=2), encoding="utf-8")
def radarr_movies() -> list[dict[str, object]]:
key = api_key_from_config(RADARR_CONFIG)
if not key:
return []
req = urllib.request.Request(RADARR_URL + "/api/v3/movie", headers={"X-Api-Key": key})
return json.load(urllib.request.urlopen(req, timeout=ARR_API_TIMEOUT))
def sonarr_series() -> list[dict[str, object]]:
key = api_key_from_config(SONARR_CONFIG)
if not key:
return []
req = urllib.request.Request(SONARR_URL + "/api/v3/series", headers={"X-Api-Key": key})
return json.load(urllib.request.urlopen(req, timeout=ARR_API_TIMEOUT))
def arr_get_json(base_url: str, api_key: str, api_path: str, params: dict[str, object] | None = None) -> object:
url = base_url + api_path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
return json.load(urllib.request.urlopen(req, timeout=ARR_API_TIMEOUT))
def arr_post_json(base_url: str, api_key: str, api_path: str, payload: dict[str, object]) -> dict[str, object]:
req = urllib.request.Request(
base_url + api_path,
data=json.dumps(payload).encode("utf-8"),
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
method="POST",
)
return json.load(urllib.request.urlopen(req, timeout=ARR_API_TIMEOUT))
def most_common_nonempty(values: list[object]) -> object | None:
usable = [v for v in values if v not in (None, "")]
if not usable:
return None
return Counter(usable).most_common(1)[0][0]
def arr_path_from_host_path(host_path: Path) -> str | None:
try:
rel = host_path.resolve().relative_to(Path(HOST_MEDIA_PREFIX))
except ValueError:
return None
return "/data/" + str(rel).replace("\\", "/")
def ensure_radarr_unmonitored_movie(meta: dict[str, object], radarr_catalog: list[dict[str, object]]) -> object | None:
if meta.get("id"):
return meta.get("id")
api_key = api_key_from_config(RADARR_CONFIG)
if not api_key:
return None
title = str(meta.get("title") or "").strip()
year = meta.get("year")
if not title:
return None
root_folder = arr_path_from_host_path(MOVIES_ROOT) or "/data/movies"
default_quality = most_common_nonempty([m.get("qualityProfileId") for m in radarr_catalog])
default_min_avail = most_common_nonempty([m.get("minimumAvailability") for m in radarr_catalog])
term = title if year is None else f"{title} {year}"
lookup = arr_get_json(RADARR_URL, api_key, "/api/v3/movie/lookup", {"term": term})
if not isinstance(lookup, list):
return None
best: dict[str, object] | None = None
best_score = 0
for item in lookup:
if not isinstance(item, dict):
continue
item_title = str(item.get("title") or "")
score = title_match_score(title, item_title)
item_year = item.get("year")
if year is not None and item_year == year:
score += 20
if score > best_score:
best_score = score
best = item
if not best or best_score <= 0:
return None
payload = dict(best)
payload["rootFolderPath"] = root_folder
payload["monitored"] = False
payload["addOptions"] = {"searchForMovie": False}
if default_quality is not None:
payload["qualityProfileId"] = default_quality
if default_min_avail is not None:
payload["minimumAvailability"] = default_min_avail
created = arr_post_json(RADARR_URL, api_key, "/api/v3/movie", payload)
movie_id = created.get("id")
log("INFO", "added missing movie to Radarr as unmonitored", title=title, year=year, id=movie_id)
return movie_id
def ensure_sonarr_unmonitored_series(meta: dict[str, object], sonarr_catalog: list[dict[str, object]]) -> object | None:
if meta.get("id"):
return meta.get("id")
api_key = api_key_from_config(SONARR_CONFIG)
if not api_key:
return None
show_title = str(meta.get("series_name") or meta.get("title") or "").strip()
if not show_title:
return None
root_folder = arr_path_from_host_path(TV_ROOT) or "/data/tv"
default_quality = most_common_nonempty([s.get("qualityProfileId") for s in sonarr_catalog])
default_language = most_common_nonempty([s.get("languageProfileId") for s in sonarr_catalog])
lookup = arr_get_json(SONARR_URL, api_key, "/api/v3/series/lookup", {"term": show_title})
if not isinstance(lookup, list):
return None
best: dict[str, object] | None = None
best_score = 0
for item in lookup:
if not isinstance(item, dict):
continue
item_title = str(item.get("title") or "")
score = title_match_score(show_title, item_title)
if score > best_score:
best_score = score
best = item
if not best or best_score <= 0:
return None
payload = dict(best)
payload["rootFolderPath"] = root_folder
payload["monitored"] = False
payload["addOptions"] = {"searchForMissingEpisodes": False}
if default_quality is not None:
payload["qualityProfileId"] = default_quality
if default_language is not None:
payload["languageProfileId"] = default_language
seasons = payload.get("seasons")
if isinstance(seasons, list):
for season in seasons:
if isinstance(season, dict):
season["monitored"] = False
created = arr_post_json(SONARR_URL, api_key, "/api/v3/series", payload)
series_id = created.get("id")
log("INFO", "added missing show to Sonarr as unmonitored", title=show_title, id=series_id)
return series_id
def parse_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return False
def jellyfin_settings_from_arr_db(db_path: Path, source: str) -> dict[str, object] | None:
if not db_path.is_file():
return None
try:
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"SELECT Settings FROM Notifications WHERE Implementation = ? AND Name = ? ORDER BY Id DESC LIMIT 1",
("MediaBrowser", "Jellyfin"),
).fetchone()
except sqlite3.Error as exc:
log("WARN", "failed reading Arr notification settings", source=source, db=str(db_path), error=str(exc))
return None
if not row or not row[0]:
return None
try:
settings = json.loads(str(row[0]))
except json.JSONDecodeError as exc:
log("WARN", "invalid Arr Jellyfin settings JSON", source=source, db=str(db_path), error=str(exc))
return None
host = str(settings.get("host") or "").strip()
api_key = str(settings.get("apiKey") or "").strip()
if not host or not api_key:
return None
host = re.sub(r"^https?://", "", host).rstrip("/")
if "/" in host:
host = host.split("/", 1)[0]
if not host:
return None
raw_port = settings.get("port")
try:
port = int(raw_port)
except (TypeError, ValueError):
port = 8096
return {
"source": source,
"host": host,
"port": port,
"use_ssl": parse_bool(settings.get("useSsl")),
"api_key": api_key,
}
def jellyfin_settings_from_arr_notifications() -> dict[str, object] | None:
for source, db_path in (("radarr", RADARR_DB), ("sonarr", SONARR_DB)):
settings = jellyfin_settings_from_arr_db(db_path, source)
if settings:
return settings
return None
def refresh_jellyfin_library() -> None:
settings = jellyfin_settings_from_arr_notifications()
if not settings:
log("WARN", "Jellyfin refresh skipped: no Arr Jellyfin connector settings found")
return
scheme = "https" if bool(settings["use_ssl"]) else "http"
host = str(settings["host"])
port = int(settings["port"])
url = f"{scheme}://{host}:{port}/Library/Refresh"
payload = b"{}"
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json", "X-Emby-Token": str(settings["api_key"])},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=ARR_API_TIMEOUT) as resp:
log("INFO", "requested Jellyfin library refresh", status=resp.status, source=settings["source"], host=host, port=port)
except Exception as exc:
log(
"WARN",
"Jellyfin library refresh failed; continuing",
source=settings["source"],
host=host,
port=port,
error=str(exc),
)
def host_path_from_arr_path(arr_path: str | None) -> Path | None:
if not arr_path or not arr_path.startswith("/data"):
return None
return Path(HOST_MEDIA_PREFIX + arr_path[5:])
def parse_movie_title_year(name: str) -> tuple[str, int] | None:
stem = Path(name).stem.replace(".", " ").replace("_", " ")
ym = re.search(r"\b(19\d{2}|20\d{2})\b", stem)
if not ym:
return None
year = int(ym.group(1))
title = re.sub(r"\s+", " ", stem[: ym.start()]).strip(" .-_")
if not title:
return None
return title, year
def parse_episode_info(name: str) -> dict[str, object] | None:
stem = Path(name).stem.replace("_", " ").replace(".", " ")
m = re.search(r"\b[Ss](\d{1,2})[ ._-]*[Ee](\d{2})(?:[ ._-]*[Ee](\d{2}))?\b", stem)
if m:
season = int(m.group(1))
e1 = int(m.group(2))
e2 = int(m.group(3)) if m.group(3) else None
title = re.sub(r"\s+", " ", stem[: m.start()]).strip(" .-_")
episode_title = re.sub(r"\s+", " ", stem[m.end() :]).strip(" .-_") or None
if title:
return {
"show_title": title,
"season": season,
"episode": e1,
"episode_end": e2,
"air_date": None,
"episode_title": episode_title,
}
m = re.search(r"\b(\d{1,2})x(\d{2})(?:x(\d{2}))?\b", stem)
if m:
season = int(m.group(1))
e1 = int(m.group(2))
e2 = int(m.group(3)) if m.group(3) else None
title = re.sub(r"\s+", " ", stem[: m.start()]).strip(" .-_")
episode_title = re.sub(r"\s+", " ", stem[m.end() :]).strip(" .-_") or None
if title:
return {
"show_title": title,
"season": season,
"episode": e1,
"episode_end": e2,
"air_date": None,
"episode_title": episode_title,
}
m = re.search(r"\b(20\d{2})[ ._-](\d{2})[ ._-](\d{2})\b", stem)
if m:
air_date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
season = int(m.group(1))
title = re.sub(r"\s+", " ", stem[: m.start()]).strip(" .-_")
if title:
return {
"show_title": title,
"season": season,
"episode": None,
"episode_end": None,
"air_date": air_date,
"episode_title": None,
}
return None
def useful_release_name(src: Path) -> str:
ignored = {"manual", "danish", "downloads"}
for parent in [src.parent, *src.parents]:
name = parent.name.strip()
lower = name.lower()
if not name or lower in ignored:
continue
if lower.startswith(("leftover-cleanup-", "_unpack_", "__unpack__", "_failed_", "_admin_")):
continue
if parent in DOWNLOAD_ROOTS:
break
return name
return src.stem
def movie_label_source_name(src: Path) -> str:
return src.name if parse_movie_title_year(src.name) else useful_release_name(src)
def infer_media_type(src: Path) -> str:
info = parse_episode_info(src.name)
if info:
return "tv"
rel = str(src).lower()
if any(token in rel for token in ["/tv/", "season", "episode", "s0", " s1", " e0"]):
return "tv"
return "movie"
def resolve_movie_folder(src: Path, radarr_catalog: list[dict[str, object]]) -> tuple[Path, dict[str, object]]:
release_name = useful_release_name(src)
parsed = parse_movie_title_year(src.name) or parse_movie_title_year(release_name)
matches: list[dict[str, object]] = []
if parsed and radarr_catalog:
source_title, year = parsed
for movie in radarr_catalog:
if movie.get("year") != year:
continue
title = str(movie.get("title") or "")
if title_match_score(source_title, title) > 0:
matches.append(movie)
matches.sort(key=lambda m: -title_match_score(parsed[0], str(m.get("title") or "")))
if len(matches) == 1:
m = matches[0]
host = host_path_from_arr_path(str(m.get("path") or ""))
if host:
host.mkdir(parents=True, exist_ok=True)
return host, {"title": m.get("title"), "year": m.get("year"), "id": m.get("id")}
if parsed:
source_title, year = parsed
imdb_dir = discover_imdb_dataset_dir()
if imdb_dir:
resolved = resolve_with_imdb_datasets(source_title, year, imdb_dir)
if resolved:
title, resolved_year = resolved
target = MOVIES_ROOT / f"{title} ({resolved_year})"
target.mkdir(parents=True, exist_ok=True)
log("INFO", "resolved movie via local IMDb datasets", src=str(src), dataset=str(imdb_dir), title=title, year=resolved_year)
return target, {"title": title, "year": resolved_year, "id": None}
log("WARN", "IMDb datasets present but no unique movie match", src=str(src), dataset=str(imdb_dir), source_title=source_title, year=year)
target = MOVIES_ROOT / f"{sanitize_title(source_title)} ({year})"
target.mkdir(parents=True, exist_ok=True)
return target, {"title": sanitize_title(source_title), "year": year, "id": None}
fallback_title = sanitize_title(release_name)
target = MOVIES_ROOT / fallback_title
target.mkdir(parents=True, exist_ok=True)
return target, {"title": fallback_title, "year": None, "id": None}
def find_best_series_folder(show_title: str, sonarr_catalog: list[dict[str, object]]) -> tuple[str, Path, object | None]:
sonarr_matches: list[tuple[int, dict[str, object]]] = []
for series in sonarr_catalog:
title = str(series.get("title") or "")
score = title_match_score(show_title, title)
if score > 0:
sonarr_matches.append((score, series))
sonarr_matches.sort(key=lambda item: -item[0])
if sonarr_matches:
best_score = sonarr_matches[0][0]
top = [item for item in sonarr_matches if item[0] == best_score]
if len(top) == 1:
series = top[0][1]
host = host_path_from_arr_path(str(series.get("path") or ""))
if host:
host.mkdir(parents=True, exist_ok=True)
return str(series.get("title") or show_title), host, series.get("id")
if TV_ROOT.is_dir():
library_matches: list[tuple[int, Path]] = []
for entry in TV_ROOT.iterdir():
if not entry.is_dir():
continue
score = title_match_score(show_title, entry.name)
if score > 0:
library_matches.append((score, entry))
library_matches.sort(key=lambda item: (-item[0], item[1].name.lower()))
if library_matches:
top_score = library_matches[0][0]
top = [item for item in library_matches if item[0] == top_score]
if len(top) == 1:
return top[0][1].name, top[0][1], None
folder = TV_ROOT / sanitize_title(show_title)
folder.mkdir(parents=True, exist_ok=True)
return sanitize_title(show_title), folder, None
def resolve_tv_destination(src: Path, sonarr_catalog: list[dict[str, object]]) -> tuple[Path, dict[str, object], str]:
info = parse_episode_info(src.name)
if not info:
raise Skip("TV candidate missing season/episode number")
show_name, show_folder, series_id = find_best_series_folder(str(info["show_title"]), sonarr_catalog)
season_num = int(info["season"])
season_folder = show_folder / f"Season {season_num}"
season_folder.mkdir(parents=True, exist_ok=True)
if info["air_date"]:
ep = f"{show_name} - {info['air_date']}"
episode_num = None
else:
e1 = int(info["episode"])
e2 = info["episode_end"]
episode_num = e1
if e2 is not None:
ep = f"{show_name} - S{season_num:02d}E{e1:02d}-E{int(e2):02d}"
else:
ep = f"{show_name} - S{season_num:02d}E{e1:02d}"
meta = {
"title": show_name,
"year": None,
"id": series_id,
"item_type": "Episode",
"series_name": show_name,
"season_number": season_num,
"episode_number": episode_num,
"name": info.get("episode_title"),
}
return season_folder, meta, ep
def normalize_existing_movie_versions(folder: Path) -> None:
prefix = folder.name + " - "
for p in list(folder.iterdir()):
if not p.is_file() or p.suffix.lower() not in VIDEO_EXT:
continue
if p.name.startswith(prefix):
continue
label = safe_label(p.name, source_tag="existing", for_existing=True)
dest = unique(folder, f"{folder.name} - {label}", p.suffix)
log("MOVE", "normalizing existing movie filename", src=str(p), dest=str(dest))
p.rename(dest)
def copy_then_remove(src: Path, dest: Path, *, media_type: str | None = None, source_tag: str | None = None, kind: str = "media") -> None:
partial = dest.with_name(dest.name + ".partial")
if partial.exists():
log("WARN", "removing stale partial", partial=str(partial))
partial.unlink()
total = src.stat().st_size
copied = 0
last_status = 0.0
write_status("copying", src=str(src), dest=str(dest), partial=str(partial), bytes_copied=0, bytes_total=total, percent=0, media_type=media_type, source_tag=source_tag, kind=kind)
with src.open("rb") as source, partial.open("wb") as target:
shutil.copystat(str(src), str(partial), follow_symlinks=True)
while True:
chunk = source.read(16 * 1024 * 1024)
if not chunk:
break
target.write(chunk)
copied += len(chunk)
now = time.monotonic()
if now - last_status >= 1 or copied == total:
last_status = now
write_status("copying", src=str(src), dest=str(dest), partial=str(partial), bytes_copied=copied, bytes_total=total, percent=round((copied / total * 100), 2) if total else 100, media_type=media_type, source_tag=source_tag, kind=kind)
if partial.stat().st_size != total:
raise RuntimeError(f"partial size mismatch {partial.stat().st_size} != {total}")
write_status("finalizing", src=str(src), dest=str(dest), partial=str(partial), bytes_copied=total, bytes_total=total, percent=100, media_type=media_type, source_tag=source_tag, kind=kind)
partial.rename(dest)
src.unlink()
def notify_home_assistant(item: dict[str, object], dest: Path, source_tag: str) -> None:
if not HOME_ASSISTANT_WEBHOOK:
return
item_type = str(item.get("item_type") or "Movie")
name = item.get("title") or dest.parent.name
payload_body: dict[str, object] = {
"NotificationType": "ItemAdded",
"Name": name,
"ItemType": item_type,
"Year": item.get("year"),
"ItemId": str(item.get("id") or ""),
"Source": f"manual-media-import:{source_tag}",
"Path": str(dest),
}
if item_type == "Episode":
if not item.get("series_name") or item.get("season_number") is None or item.get("episode_number") is None:
log("WARN", "not sending episode notification without series/season/episode", item=item, dest=str(dest))
return
payload_body["Name"] = item.get("name") or ""
payload_body["SeriesName"] = item.get("series_name") or name
payload_body["SeasonNumber"] = item.get("season_number")
payload_body["EpisodeNumber"] = item.get("episode_number")
payload = json.dumps(
payload_body
).encode()
req = urllib.request.Request(
HOME_ASSISTANT_WEBHOOK,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
log("INFO", "sent Home Assistant media notification", item=item.get("title"), status=resp.status)
def source_tag_for(path: Path) -> str:
for root in DOWNLOAD_ROOTS:
try:
path.relative_to(root)
return "manual" if root == MANUAL_DOWNLOADS else "legacy"
except ValueError:
continue
return "manual"
def candidates() -> list[Path]:
out: list[Path] = []
sab_ready = sabnzbd_readiness_by_name()
manual_batches = manual_batch_roots()
for root in DOWNLOAD_ROOTS:
if not root.exists():
continue
for dirpath, _, files in os.walk(root):
rel_parts = Path(dirpath).relative_to(root).parts if Path(dirpath) != root else ()
if any(part.startswith(SAB_TRANSIENT_PREFIXES) for part in rel_parts):
continue
if rel_parts and rel_parts[0] in sab_ready and not sab_ready[rel_parts[0]]:
continue
if rel_parts and rel_parts[0] not in sab_ready and not under_manual_batch(Path(dirpath), manual_batches):
continue
if "sample" in dirpath.lower():
continue
for f in files:
p = Path(dirpath) / f
if p.suffix.lower() in VIDEO_EXT and "sample" not in f.lower():
out.append(p)
return out
def ignored_source_files() -> list[Path]:
out: list[Path] = []
for root in DOWNLOAD_ROOTS:
if not root.exists():
continue
for dirpath, _, files in os.walk(root):
rel_parts = Path(dirpath).relative_to(root).parts if Path(dirpath) != root else ()
if any(part.startswith(SAB_TRANSIENT_PREFIXES) for part in rel_parts):
continue
sample_root = "sample" in dirpath.lower()
for f in files:
p = Path(dirpath) / f
if sample_root or "sample" in f.lower() or p.suffix.lower() not in VIDEO_EXT:
out.append(p)
return out
def cleanup_source_directories() -> tuple[int, int]:
removed_files = removed_dirs = 0
for p in ignored_source_files():
try:
log("DELETE", "removing ignored source file", src=str(p))
p.unlink()
removed_files += 1
except FileNotFoundError:
continue
for root in DOWNLOAD_ROOTS:
if not root.exists():
continue
for dirpath, dirs, _ in os.walk(root, topdown=False):
for d in dirs:
p = Path(dirpath) / d
try:
p.rmdir()
log("DELETE", "removing empty source directory", src=str(p))
removed_dirs += 1
except OSError:
pass
return removed_files, removed_dirs
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
LOCK.parent.mkdir(parents=True, exist_ok=True)
lock_f = LOCK.open("w", encoding="utf-8")
try:
fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log("WARN", "another importer run is active")
return 0
try:
write_status("starting")
radarr_catalog = radarr_movies()
except Exception as exc: # optional hint only
log("WARN", "Radarr lookup unavailable; continuing without it", error=str(exc))
radarr_catalog = []
try:
sonarr_catalog = sonarr_series()
except Exception as exc: # optional hint only
log("WARN", "Sonarr lookup unavailable; continuing without it", error=str(exc))
sonarr_catalog = []
moved = skipped = errors = 0
radarr_added: dict[tuple[str, object], object | None] = {}
sonarr_added: dict[str, object | None] = {}
for src in candidates():
source_tag = source_tag_for(src)
try:
if src.stat().st_size < MIN_SIZE:
raise Skip("too small")
if not stable(src):
raise Skip("file still changing")
media_type = infer_media_type(src)
if media_type == "tv":
dest_folder, meta, episode_stub = resolve_tv_destination(src, sonarr_catalog)
sonarr_key = norm(str(meta.get("series_name") or meta.get("title") or ""))
if sonarr_key and meta.get("id") is None:
if sonarr_key in sonarr_added:
meta["id"] = sonarr_added[sonarr_key]
else:
try:
meta["id"] = ensure_sonarr_unmonitored_series(meta, sonarr_catalog)
except Exception as exc:
log("WARN", "failed adding missing show to Sonarr; continuing", title=meta.get("series_name") or meta.get("title"), error=str(exc))
sonarr_added[sonarr_key] = meta.get("id")
label = safe_label(src.name, source_tag=source_tag)
dest = unique(dest_folder, f"{episode_stub} - {label}", src.suffix)
else:
dest_folder, meta = resolve_movie_folder(src, radarr_catalog)
radarr_key = (norm(str(meta.get("title") or "")), meta.get("year"))
if radarr_key[0] and meta.get("id") is None:
if radarr_key in radarr_added:
meta["id"] = radarr_added[radarr_key]
else:
try:
meta["id"] = ensure_radarr_unmonitored_movie(meta, radarr_catalog)
except Exception as exc:
log("WARN", "failed adding missing movie to Radarr; continuing", title=meta.get("title"), year=meta.get("year"), error=str(exc))
radarr_added[radarr_key] = meta.get("id")
normalize_existing_movie_versions(dest_folder)
label = safe_label(movie_label_source_name(src), source_tag=source_tag)
dest = unique(dest_folder, f"{dest_folder.name} - {label}", src.suffix)
if args.dry_run:
log("DRYRUN", "would move", src=str(src), dest=str(dest), media_type=media_type)
continue
log("MOVE", "moving", src=str(src), dest=str(dest), media_type=media_type, source_tag=source_tag)
copy_then_remove(src, dest, media_type=media_type, source_tag=source_tag)
try:
notify_home_assistant(meta, dest, source_tag=source_tag)
except Exception as exc:
log("WARN", "failed Home Assistant media notification", item=meta.get("title"), error=str(exc))
moved += 1
for side in list(src.parent.iterdir()):
if side.is_file() and side.stem == src.stem and side.suffix.lower() in SIDECAR_EXT:
side_dest = unique(dest.parent, dest.stem, side.suffix)
log("MOVE", "moving sidecar", src=str(side), dest=str(side_dest))
copy_then_remove(side, side_dest, media_type=media_type, source_tag=source_tag, kind="sidecar")
except Skip as exc:
skipped += 1
log("SKIP", str(exc), src=str(src))
except Exception as exc:
errors += 1
log("ERROR", "failed candidate", src=str(src), error=str(exc))
cleaned_files = cleaned_dirs = 0
if moved > 0 and not args.dry_run:
refresh_jellyfin_library()
if errors == 0 and not args.dry_run:
cleaned_files, cleaned_dirs = cleanup_source_directories()
prune_manual_batches()
log(
"INFO",
"summary",
moved=moved,
skipped=skipped,
errors=errors,
dry_run=args.dry_run,
cleaned_files=cleaned_files,
cleaned_dirs=cleaned_dirs,
)
write_status("done", moved=moved, skipped=skipped, errors=errors, dry_run=args.dry_run, cleaned_files=cleaned_files, cleaned_dirs=cleaned_dirs)
return 2 if errors else 0
if __name__ == "__main__":
sys.exit(main())