diff --git a/data/example-config.py b/data/example-config.py index efd724010..8fd8e8bac 100644 --- a/data/example-config.py +++ b/data/example-config.py @@ -269,20 +269,42 @@ "sonarr_url": "http://localhost:8989", "sonarr_api_key": "", + # Required by --sonarr-add. Series are added unmonitored and Sonarr search is not triggered. + "sonarr_quality_profile_id": 0, + "sonarr_root_folder_path": "", + "sonarr_series_type": "standard", + "sonarr_season_folder": True, + "sonarr_monitor": "none", + # details for a second sonarr instance # additional sonarr instances can be added by adding more sonarr_url_x and sonarr_api_key_x entries "sonarr_url_1": "http://my-second-instance:8989", "sonarr_api_key_1": "", + # optional per-instance overrides for --sonarr-add + # "sonarr_quality_profile_id_1": 0, + # "sonarr_root_folder_path_1": "", + # "sonarr_series_type_1": "standard", + # "sonarr_season_folder_1": True, + # "sonarr_monitor_1": "none", # set true to use radarr for movie searching "use_radarr": False, "radarr_url": "http://localhost:7878", "radarr_api_key": "", + # Required by --radarr-add. Movies are added unmonitored and Radarr search is not triggered. + "radarr_quality_profile_id": 0, + "radarr_root_folder_path": "", + "radarr_minimum_availability": "released", + # details for a second radarr instance # additional radarr instances can be added by adding more radarr_url_x and radarr_api_key_x entries "radarr_url_1": "http://my-second-instance:7878", "radarr_api_key_1": "", + # optional per-instance overrides for --radarr-add + # "radarr_quality_profile_id_1": 0, + # "radarr_root_folder_path_1": "", + # "radarr_minimum_availability_1": "released", # Add a directory for Emby linking. This is the folder where the emby files will be linked to. # If not set, Emby linking will not be performed. Symlinking only, linux not tested @@ -350,7 +372,7 @@ "TRACKERS": { # Which trackers do you want to upload to? - # Available tracker: A4K, ACM, AITHER, ANT, AR, ASC, AZ, BHD, BHDTV, BJS, BLU, BT, CBR, CZ, DC, DP, DT, EMUW, FF, FL, FNP, FRIKI, GPW, HDB, HDS, HDT, HHD, HUNO, IHD, IS, ITT, LCD, LDU, LST, LT, LUME, MTV, NBL, OE, OTW, PHD, PT, PTER, PTP, PTS, PTT, R4E, RAS, RF, RTF, SAM, SHRI, SN, SP, SPD, STC, THR, TIK, TL, TLZ, TOS, TTG, TTR, TVC, ULCX, UTP, YOINK, YUS + # Available tracker: A4K, ACM, AITHER, ANT, AR, ASC, AZ, BHD, BHDTV, BJS, BLU, BT, CBR, CZ, DC, DP, DT, EMUW, FF, FL, FNP, FRIKI, GPW, HDB, HDS, HDT, HHD, HUNO, IHD, IS, ITT, LCD, LDU, LST, LT, LUME, MTV, NBL, OE, OTW, PHD, PT, PTER, PTP, PTS, PTT, R4E, RAS, RF, RMC, RTF, SAM, SHRI, SN, SP, SPD, STC, THR, TIK, TL, TLZ, TOS, TTG, TTR, TVC, ULCX, UTP, YOINK, YUS # Only add the trackers you want to upload to on a regular basis "default_trackers": "", @@ -786,6 +808,14 @@ "api_key": "", "anon": False, }, + "RMC": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + # Send uploads to RMC modq for staff approval + "modq": False, + }, "RTF": { # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name "link_dir_name": "", diff --git a/docs/example-config.md b/docs/example-config.md index 2fcaaba75..3425389c6 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -165,11 +165,23 @@ Implementation notes: - `sonarr_url` (str): Sonarr base URL. - `sonarr_api_key` (str): Sonarr API key. - `sonarr_url_1` / `sonarr_api_key_1` (str): Optional second Sonarr instance. +- `sonarr_quality_profile_id` (int): Quality profile ID used by `--sonarr-add`. +- `sonarr_root_folder_path` (str): Root folder path used by `--sonarr-add`. +- `sonarr_series_type` (str): Series type used by `--sonarr-add` (`standard`, `daily`, or `anime`). +- `sonarr_season_folder` (bool): Whether `--sonarr-add` enables season folders. +- `sonarr_monitor` (str): Sonarr monitor option for `--sonarr-add`; default `none`. - `use_radarr` (bool): Enable Radarr searching. - `radarr_url` (str): Radarr base URL. - `radarr_api_key` (str): Radarr API key. - `radarr_url_1` / `radarr_api_key_1` (str): Optional second Radarr instance. +- `radarr_quality_profile_id` (int): Quality profile ID used by `--radarr-add`. +- `radarr_root_folder_path` (str): Root folder path used by `--radarr-add`. +- `radarr_minimum_availability` (str): Minimum availability used by `--radarr-add`; default `released`. + +Implementation notes: +- `--sonarr-add` supports suffixed per-instance overrides such as `sonarr_quality_profile_id_1`, `sonarr_root_folder_path_1`, `sonarr_series_type_1`, `sonarr_season_folder_1`, and `sonarr_monitor_1`. +- `--radarr-add` supports suffixed per-instance overrides such as `radarr_quality_profile_id_1`, `radarr_root_folder_path_1`, and `radarr_minimum_availability_1`. ### Torrent creation - `mkbrr` (bool): Use mkbrr for torrent creation. diff --git a/radarr_add_mkv_directory.py b/radarr_add_mkv_directory.py new file mode 100644 index 000000000..b2b4b458c --- /dev/null +++ b/radarr_add_mkv_directory.py @@ -0,0 +1,963 @@ +#!/usr/bin/env python3 +"""Interactively add MKV movies from a directory to Radarr. + +The script scans a directory for movie content, skips samples, looks up +candidate movies in Radarr, and adds selected movies unmonitored. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DEFAULT_MINIMUM_AVAILABILITY = "released" +DEFAULT_TIMEOUT = 30 +MAX_DISPLAY_CANDIDATES = 3 +DISC_FOLDER_NAMES = {"bdmv", "video_ts"} +COMPLETED_STATE_STATUSES = {"added", "skipped"} + + +class RadarrError(Exception): + """Raised for Radarr API and transport failures.""" + + +@dataclass +class ParsedName: + primary_title: str | None + secondary_title: str | None + year: str | None + + @property + def search_term(self) -> str: + if not self.primary_title: + return self.year or "" + if self.year: + return f"{self.primary_title} {self.year}" + return self.primary_title + + +@dataclass +class MovieItem: + path: Path + parsed: ParsedName + candidates: list[dict[str, Any]] = field(default_factory=list) + selected_index: int | None = None + skipped: bool = False + skip_reason: str | None = None + + @property + def selected_candidate(self) -> dict[str, Any] | None: + if self.selected_index is None: + return None + if self.selected_index < 0 or self.selected_index >= len(self.candidates): + return None + return self.candidates[self.selected_index] + + +@dataclass(frozen=True) +class ContentItem: + path: Path + parse_path: Path + kind: str + + @property + def label(self) -> str: + return str(self.path) + + +@dataclass +class RunResult: + content_item: ContentItem + status: str + detail: str + movie: dict[str, Any] | None = None + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def item_id(content_item: ContentItem) -> str: + identity = f"{content_item.kind}\0{content_item.path.resolve()}" + return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24] + + +class StateStore: + def __init__(self, state_dir: Path, enabled: bool = True) -> None: + self.state_dir = state_dir + self.items_dir = state_dir / "items" + self.enabled = enabled + + def initialize(self, scan_directory: Path, recursive: bool) -> None: + if not self.enabled: + return + self.items_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "scanDirectory": str(scan_directory), + "recursive": recursive, + "updatedAt": now_iso(), + } + self._write_json(self.state_dir / "manifest.json", manifest) + + def path_for(self, content_item: ContentItem) -> Path: + return self.items_dir / f"{item_id(content_item)}.json" + + def load(self, content_item: ContentItem) -> dict[str, Any] | None: + if not self.enabled: + return None + path = self.path_for(content_item) + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + def record_discovered(self, content_item: ContentItem, parsed: ParsedName | None = None) -> None: + if self.load(content_item): + return + self.record(content_item, "pending", "discovered", parsed=parsed, existing_state={}) + + def record( + self, + content_item: ContentItem, + status: str, + detail: str, + parsed: ParsedName | None = None, + movie: dict[str, Any] | None = None, + *, + existing_state: dict[str, Any] | None = None, + ) -> None: + if not self.enabled: + return + existing = existing_state if existing_state is not None else self.load(content_item) or {} + created_at = existing.get("createdAt") or now_iso() + record = { + "id": item_id(content_item), + "contentPath": str(content_item.path), + "parsePath": str(content_item.parse_path), + "kind": content_item.kind, + "status": status, + "detail": detail, + "createdAt": created_at, + "updatedAt": now_iso(), + } + if parsed: + record["parsed"] = { + "primaryTitle": parsed.primary_title, + "secondaryTitle": parsed.secondary_title, + "year": parsed.year, + "searchTerm": parsed.search_term, + } + elif existing.get("parsed"): + record["parsed"] = existing["parsed"] + if movie: + record["movie"] = { + "title": movie.get("title") or movie.get("originalTitle"), + "year": movie.get("year"), + "tmdbId": movie.get("tmdbId"), + "imdbId": movie.get("imdbId"), + } + elif existing.get("movie"): + record["movie"] = existing["movie"] + + self._write_json(self.path_for(content_item), record) + + def _write_json(self, path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + with temp_path.open("w", encoding="utf-8") as file: + json.dump(data, file, indent=2, sort_keys=True) + file.write("\n") + temp_path.replace(path) + + +def normalize_base_url(value: str) -> str: + return value.rstrip("/") + + +class RadarrClient: + def __init__(self, base_url: str, api_key: str, timeout: int = DEFAULT_TIMEOUT) -> None: + self.base_url = normalize_base_url(base_url) + self.api_key = api_key + self.timeout = timeout + + def get_movies(self) -> list[dict[str, Any]]: + data = self._request_json("GET", "/api/v3/movie") + if not isinstance(data, list): + raise RadarrError("Radarr movie list response was not a list.") + return data + + def lookup(self, term: str) -> list[dict[str, Any]]: + data = self._request_json("GET", "/api/v3/movie/lookup", {"term": term}) + if not isinstance(data, list): + raise RadarrError(f"Radarr lookup response for {term!r} was not a list.") + return data + + def add_movie( + self, + movie: dict[str, Any], + quality_profile_id: int, + root_folder_path: str, + minimum_availability: str, + ) -> dict[str, Any]: + payload = copy.deepcopy(movie) + payload["qualityProfileId"] = quality_profile_id + payload["rootFolderPath"] = root_folder_path + payload["monitored"] = False + payload["minimumAvailability"] = minimum_availability + payload["addOptions"] = {"searchForMovie": False} + + data = self._request_json("POST", "/api/v3/movie", body=payload) + if not isinstance(data, dict): + raise RadarrError("Radarr add response was not an object.") + return data + + def _request_json( + self, + method: str, + path: str, + params: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + ) -> Any: + url = f"{self.base_url}{path}" + if params: + url = f"{url}?{urllib.parse.urlencode(params)}" + + encoded_body = None + headers = { + "X-Api-Key": self.api_key, + "Accept": "application/json", + "User-Agent": "radarr-add-mkv-directory/1.0", + } + if body is not None: + encoded_body = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=encoded_body, headers=headers, method=method) + + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + payload = error.read().decode("utf-8", errors="replace") + message = payload.strip() or error.reason + raise RadarrError(f"Radarr HTTP {error.code}: {message}") from error + except urllib.error.URLError as error: + raise RadarrError(f"Radarr request failed: {error.reason}") from error + + if not payload: + return None + try: + return json.loads(payload) + except json.JSONDecodeError as error: + raise RadarrError(f"Radarr returned invalid JSON from {path}: {error}") from error + + +def is_sample_mkv(path: Path) -> bool: + if path.suffix.lower() != ".mkv": + return False + + return is_sample_path(path) + + +def is_sample_content_item(path: Path) -> bool: + return is_sample_path(path) + + +def is_sample_path(path: Path) -> bool: + parent_tokens = {part.lower() for part in path.parent.parts} + if parent_tokens.intersection({"sample", "samples"}): + return True + + name_for_tokens = path.stem if path.is_file() and path.suffix else path.name + filename_tokens = release_tokens(name_for_tokens) + return "sample" in filename_tokens + + +def release_tokens(value: str) -> set[str]: + return {token.lower() for token in re.split(r"[^A-Za-z0-9]+", value) if token} + + +def find_mkv_files(directory: Path, recursive: bool = False) -> tuple[list[Path], list[Path]]: + iterator = directory.rglob("*") if recursive else directory.iterdir() + mkvs = sorted(path for path in iterator if path.is_file() and path.suffix.lower() == ".mkv") + samples = [path for path in mkvs if is_sample_mkv(path)] + sample_set = set(samples) + usable = [path for path in mkvs if path not in sample_set] + return usable, samples + + +def is_disc_content_dir(path: Path) -> bool: + if not path.is_dir(): + return False + try: + children = path.iterdir() + except OSError: + return False + return any(child.is_dir() and child.name.lower() in DISC_FOLDER_NAMES for child in children) + + +def has_disc_ancestor(path: Path, disc_dirs: set[Path]) -> bool: + return any(parent in disc_dirs for parent in path.parents) + + +def find_content_items(directory: Path, recursive: bool = False) -> tuple[list[ContentItem], list[ContentItem]]: + if recursive: + all_dirs = sorted((path for path in directory.rglob("*") if path.is_dir()), key=lambda path: len(path.parts)) + else: + all_dirs = sorted(path for path in directory.iterdir() if path.is_dir()) + + disc_paths = [path for path in all_dirs if is_disc_content_dir(path)] + disc_path_set = set(disc_paths) + + iterator = directory.rglob("*") if recursive else directory.iterdir() + mkv_paths = sorted( + path + for path in iterator + if path.is_file() + and path.suffix.lower() == ".mkv" + and not has_disc_ancestor(path, disc_path_set) + ) + + content_items = [ + ContentItem(path=path, parse_path=path, kind="disc") + for path in disc_paths + ] + [ + ContentItem(path=path, parse_path=path, kind="mkv") + for path in mkv_paths + ] + content_items.sort(key=lambda item: str(item.path).lower()) + + samples = [item for item in content_items if is_sample_content_item(item.path)] + usable = [item for item in content_items if item not in samples] + return usable, samples + + +def multi_replace(text: str, replacements: dict[str, str]) -> str: + for old, new in replacements.items(): + text = re.sub(re.escape(old), new, text, flags=re.IGNORECASE) + return text + + +def extract_title_and_year( + meta: dict[str, Any], + filename: str, +) -> tuple[str | None, str | None, str | None]: + """Vendored compatible parser based on Upload Assistant's helper. + + Source: https://github.com/Audionut/Upload-Assistant/blob/master/src/get_name.py + Function: NameManager.extract_title_and_year + """ + + basename = os.path.basename(filename) + basename = os.path.splitext(basename)[0] + + secondary_title: str | None = None + year: str | None = None + + aka_patterns = [" AKA ", ".aka.", " aka ", ".AKA."] + for pattern in aka_patterns: + if pattern in basename: + aka_parts = basename.split(pattern, 1) + if len(aka_parts) > 1: + primary_title = aka_parts[0].strip() + secondary_part = aka_parts[1].strip() + + year_match_primary = re.search(r"\b(19|20)\d{2}\b", primary_title) + if year_match_primary: + year = year_match_primary.group(0) + + secondary_match = re.match(r"^(\d+)", secondary_part) + if secondary_match: + secondary_title = secondary_match.group(1) + else: + year_or_release_match = re.search( + r"\b(19|20)\d{2}\b|\bBluRay\b|\bREMUX\b|\b\d+p\b|\bDTS-HD\b|\bAVC\b", + secondary_part, + ) + if ( + year_or_release_match + and re.match(r"\b(19|20)\d{2}\b", year_or_release_match.group(0)) + and not year + ): + year = year_or_release_match.group(0) + secondary_title = secondary_part[: year_or_release_match.start()].strip() + else: + secondary_title = secondary_part + + primary_title = primary_title.replace(".", " ") + if secondary_title is not None: + secondary_title = secondary_title.replace(".", " ") + return primary_title, secondary_title, year + + year_start_match = re.match(r"^(19|20)\d{2}", basename) + if year_start_match: + title = year_start_match.group(0) + rest = basename[len(title) :].lstrip(". _-") + year_match = re.search(r"\b(19|20)\d{2}\b", rest) + year = year_match.group(0) if year_match else None + if year: + return title, None, year + + folder_name = os.path.basename(str(meta.get("uuid", ""))) if meta.get("uuid") else "" + year_pattern = r"(18|19|20)\d{2}" + res_pattern = r"\b(480|576|720|1080|2160)[pi]\b" + type_pattern = ( + r"(WEBDL|BluRay|REMUX|HDRip|Blu-Ray|Web-DL|webrip|web-rip|DVD|" + r"BD100|BD50|BD25|HDTV|UHD|HDR|DOVI|REPACK|Season)(?=[._\-\s]|$)" + ) + season_pattern = r"\bS(\d{1,3})\b" + season_episode_pattern = r"\bS(\d{1,3})E(\d{1,3})\b" + date_pattern = r"\b(20\d{2})\.(\d{1,2})\.(\d{1,2})\b" + extension_pattern = r"\.(mkv|mp4)$" + + double_year_pattern = r"\b(18|19|20)\d{2}\.(18|19|20)\d{2}\b" + double_year_match = re.search(double_year_pattern, folder_name) + actual_year: str | None = None + + if double_year_match: + full_match = double_year_match.group(0) + years = full_match.split(".") + first_year = years[0] + second_year = years[1] + modified_folder_name = folder_name.replace(full_match, first_year) + + res_match = re.search(res_pattern, modified_folder_name, re.IGNORECASE) + season_pattern_match = re.search(season_pattern, modified_folder_name, re.IGNORECASE) + season_episode_match = re.search(season_episode_pattern, modified_folder_name, re.IGNORECASE) + extension_match = re.search(extension_pattern, modified_folder_name, re.IGNORECASE) + type_match = re.search(type_pattern, modified_folder_name, re.IGNORECASE) + + year_boundary = ( + double_year_match.start() + len(first_year) + if double_year_match.start() == 0 + else double_year_match.start() + ) + indices: list[tuple[str, int, str]] = [("year", year_boundary, second_year)] + if res_match: + indices.append(("res", res_match.start(), res_match.group())) + if season_pattern_match: + indices.append(("season", season_pattern_match.start(), season_pattern_match.group())) + if season_episode_match: + indices.append(("season_episode", season_episode_match.start(), season_episode_match.group())) + if extension_match: + indices.append(("extension", extension_match.start(), extension_match.group())) + if type_match: + indices.append(("type", type_match.start(), type_match.group())) + + folder_name_for_title = modified_folder_name + actual_year = second_year + else: + date_match = re.search(date_pattern, folder_name) + year_match = re.search(year_pattern, folder_name) + res_match = re.search(res_pattern, folder_name, re.IGNORECASE) + season_pattern_match = re.search(season_pattern, folder_name, re.IGNORECASE) + season_episode_match = re.search(season_episode_pattern, folder_name, re.IGNORECASE) + extension_match = re.search(extension_pattern, folder_name, re.IGNORECASE) + type_match = re.search(type_pattern, folder_name, re.IGNORECASE) + + indices = [] + if date_match: + indices.append(("date", date_match.start(), date_match.group())) + if year_match and not date_match: + indices.append(("year", year_match.start(), year_match.group())) + if res_match: + indices.append(("res", res_match.start(), res_match.group())) + if season_pattern_match: + indices.append(("season", season_pattern_match.start(), season_pattern_match.group())) + if season_episode_match: + indices.append(("season_episode", season_episode_match.start(), season_episode_match.group())) + if extension_match: + indices.append(("extension", extension_match.start(), extension_match.group())) + if type_match: + indices.append(("type", type_match.start(), type_match.group())) + + folder_name_for_title = folder_name + actual_year = year_match.group() if year_match and not date_match else None + + if indices: + indices.sort(key=lambda value: value[1]) + _, first_index, _ = indices[0] + title_part = folder_name_for_title[:first_index] + title_part = re.sub(r"[\.\-_ ]+$", "", title_part) + if title_part.count("(") > title_part.count(")"): + paren_pos = title_part.rfind("(") + content_after_paren = folder_name_for_title[paren_pos + 1 : first_index].strip() + + if content_after_paren: + secondary_title = content_after_paren + + title_part = title_part[:paren_pos].rstrip() + else: + title_part = folder_name + + replacements = { + "_": " ", + ".": " ", + "DVD9": "", + "DVD5": "", + "DVDR": "", + "BDR": "", + "HDDVD": "", + "WEB-DL": "", + "WEBRip": "", + "WEB": "", + "BluRay": "", + "Blu-ray": "", + "HDTV": "", + "DVDRip": "", + "REMUX": "", + "HDR": "", + "UHD": "", + "4K": "", + "DVD": "", + "HDRip": "", + "BDMV": "", + "R1": "", + "R2": "", + "R3": "", + "R4": "", + "R5": "", + "R6": "", + "Director's Cut": "", + "Extended Edition": "", + "directors cut": "", + "director cut": "", + "itunes": "", + } + + parsed_filename = multi_replace(title_part, replacements) + processed_secondary = multi_replace(secondary_title or "", replacements) + secondary_title = processed_secondary if processed_secondary else None + + if parsed_filename: + bracket_pattern = r"\s*\(([^)]+)\)\s*" + bracket_match = re.search(bracket_pattern, parsed_filename) + + if bracket_match: + bracket_content = bracket_match.group(1).strip() + bracket_content = multi_replace(bracket_content, replacements) + + if not secondary_title and bracket_content: + secondary_title = bracket_content + secondary_title = re.sub(r"[\.\-_ ]+$", "", secondary_title) + + parsed_filename = re.sub(bracket_pattern, " ", parsed_filename) + parsed_filename = re.sub(r"\s+", " ", parsed_filename).strip() + + if parsed_filename: + return parsed_filename, secondary_title, actual_year + + year_match = re.search(r"(? ParsedName: + primary_title, secondary_title, year = extract_title_and_year( + {"debug": False, "uuid": str(path)}, + str(path), + ) + return ParsedName( + clean_text(primary_title), + clean_text(secondary_title), + clean_text(year), + ) + + +def clean_text(value: str | None) -> str | None: + if value is None: + return None + cleaned = re.sub(r"\s+", " ", value).strip() + return cleaned or None + + +def movie_key(movie: dict[str, Any]) -> tuple[str, str] | None: + tmdb_id = movie.get("tmdbId") + if tmdb_id not in (None, ""): + return "tmdb", str(tmdb_id) + + imdb_id = movie.get("imdbId") + if imdb_id: + return "imdb", str(imdb_id) + + return None + + +def existing_movie_keys(movies: list[dict[str, Any]]) -> set[tuple[str, str]]: + keys: set[tuple[str, str]] = set() + for movie in movies: + key = movie_key(movie) + if key: + keys.add(key) + return keys + + +def pick_best_candidate(candidates: list[dict[str, Any]], year: str | None) -> int | None: + if not candidates: + return None + if year: + for index, candidate in enumerate(candidates): + if str(candidate.get("year", "")) == str(year): + return index + return 0 + + +def refresh_item_statuses(items: list[MovieItem], existing_keys: set[tuple[str, str]]) -> None: + seen_in_run: set[tuple[str, str]] = set() + for item in items: + if item.skipped and item.skip_reason == "user skipped": + continue + + if item.selected_candidate is None: + item.skipped = True + item.skip_reason = "no candidate selected" + continue + + key = movie_key(item.selected_candidate) + if key and key in existing_keys: + item.skipped = True + item.skip_reason = "exists in Radarr" + continue + + if key and key in seen_in_run: + item.skipped = True + item.skip_reason = "duplicate selection in this run" + continue + + if key: + seen_in_run.add(key) + + if item.skip_reason in { + "no candidate selected", + "exists in Radarr", + "duplicate selection in this run", + }: + item.skip_reason = None + item.skipped = False + + +def candidate_label(candidate: dict[str, Any] | None) -> str: + if candidate is None: + return "none" + title = candidate.get("title") or candidate.get("originalTitle") or "Unknown title" + year = candidate.get("year") or "????" + tmdb = candidate.get("tmdbId") or "-" + imdb = candidate.get("imdbId") or "-" + return f"{title} ({year}) tmdb={tmdb} imdb={imdb}" + + +def display_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]: + return candidates[:MAX_DISPLAY_CANDIDATES] + + +def prompt_for_item(item: MovieItem, client: RadarrClient) -> str: + while True: + print() + print(f"File: {item.path.name}") + print(f"Parsed: {item.parsed.search_term or 'no parsed title'}") + if item.candidates: + if len(item.candidates) == 1: + print("Candidate:") + else: + print(f"Candidates, showing first {min(MAX_DISPLAY_CANDIDATES, len(item.candidates))} of {len(item.candidates)}:") + for index, candidate in enumerate(display_candidates(item.candidates), 1): + selected = "*" if item.selected_index == index - 1 else " " + print(f" {selected}{index}. {candidate_label(candidate)}") + else: + print("No candidates.") + + try: + value = input("Choose candidate number to add, [m]anual search, /new search, [s]kip, or [q]uit: ").strip() + except EOFError: + value = "q" + if value.lower() == "q": + return "quit" + if value.lower() == "s": + item.skipped = True + item.skip_reason = "user skipped" + return "skip" + if value.lower() == "m": + search_term = input("Radarr search term: ").strip() + if not search_term: + print("Search term cannot be empty.") + continue + try: + item.candidates = client.lookup(search_term) + except RadarrError as error: + print(f"Radarr lookup failed: {error}") + item.candidates = [] + item.selected_index = None + continue + item.selected_index = pick_best_candidate(item.candidates, item.parsed.year) + item.skipped = False + item.skip_reason = None + continue + if value.startswith("/"): + term = value[1:].strip() + if not term: + print("Search term cannot be empty.") + continue + try: + item.candidates = client.lookup(term) + except RadarrError as error: + print(f"Radarr lookup failed: {error}") + item.candidates = [] + item.selected_index = None + continue + item.selected_index = pick_best_candidate(item.candidates, item.parsed.year) + item.skipped = False + item.skip_reason = None + continue + if value.isdigit(): + candidate_index = int(value) - 1 + if candidate_index < 0 or candidate_index >= len(display_candidates(item.candidates)): + print("Candidate number is out of range.") + continue + item.selected_index = candidate_index + item.skipped = False + item.skip_reason = None + return "add" + print("Invalid command.") + + +def process_one_item( + content_item: ContentItem, + index: int, + total: int, + client: RadarrClient, + quality_profile_id: int, + root_folder_path: str, + minimum_availability: str, + existing_keys: set[tuple[str, str]], + state_store: StateStore, +) -> RunResult: + parsed = parse_mkv_name(content_item.parse_path) + state_store.record(content_item, "pending", "lookup started", parsed=parsed) + print() + print( + f"[{index}/{total}] Lookup: {content_item.path.name} " + f"({content_item.kind}) -> {parsed.search_term or 'no parsed title'}" + ) + + candidates: list[dict[str, Any]] = [] + if parsed.search_term: + candidates = client.lookup(parsed.search_term) + + item = MovieItem( + path=content_item.path, + parsed=parsed, + candidates=candidates, + selected_index=pick_best_candidate(candidates, parsed.year), + ) + + while True: + action = prompt_for_item(item, client) + if action == "quit": + return RunResult(content_item, "quit", "user quit") + if action == "skip": + print(f"SKIP {content_item.path.name}: user skipped") + state_store.record(content_item, "skipped", "user skipped", parsed=parsed) + return RunResult(content_item, "skipped", "user skipped") + + candidate = item.selected_candidate + if candidate is None: + print("No candidate is selected.") + continue + + key = movie_key(candidate) + if key and key in existing_keys: + print(f"SKIP exists: {candidate_label(candidate)}") + state_store.record(content_item, "skipped", "exists in Radarr", parsed=parsed, movie=candidate) + return RunResult(content_item, "skipped", "exists in Radarr", candidate) + + try: + added = client.add_movie(candidate, quality_profile_id, root_folder_path, minimum_availability) + except RadarrError as error: + print(f"FAIL {content_item.path.name}: {error}") + state_store.record(content_item, "failed", str(error), parsed=parsed, movie=candidate) + return RunResult(content_item, "failed", str(error), candidate) + + added_key = movie_key(added) or key + if added_key: + existing_keys.add(added_key) + print(f"ADDED {content_item.path.name}: {candidate_label(added)}") + state_store.record(content_item, "added", candidate_label(added), parsed=parsed, movie=added) + return RunResult(content_item, "added", candidate_label(added), added) + + +def print_run_summary(results: list[RunResult], sample_items: list[ContentItem]) -> None: + added = [result for result in results if result.status == "added"] + skipped = [result for result in results if result.status == "skipped"] + failed = [result for result in results if result.status == "failed"] + + print() + print("Run Summary") + print("=" * 80) + print(f"Added: {len(added)}") + for result in added: + print(f" + {result.content_item.path} -> {result.detail}") + + print(f"Skipped: {len(skipped) + len(sample_items)}") + for item in sample_items: + print(f" - {item.path} -> sample content") + for result in skipped: + print(f" - {result.content_item.path} -> {result.detail}") + + print(f"Failed: {len(failed)}") + for result in failed: + print(f" ! {result.content_item.path} -> {result.detail}") + print("=" * 80) + + +def state_dir_for(directory: Path, args: argparse.Namespace) -> Path: + if args.state_dir: + return Path(args.state_dir).expanduser() + return directory / ".radarr-add-state" + + +def completed_result_from_state(content_item: ContentItem, state: dict[str, Any]) -> RunResult: + status = str(state.get("status") or "skipped") + detail = str(state.get("detail") or "completed in previous run") + movie = state.get("movie") + return RunResult(content_item, status, detail, movie if isinstance(movie, dict) else None) + + +def should_resume_skip(state: dict[str, Any] | None, no_resume: bool) -> bool: + if no_resume or not state: + return False + return str(state.get("status")) in COMPLETED_STATE_STATUSES + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Manually add MKV movies from a directory to Radarr.") + parser.add_argument("--directory", required=True, help="Directory containing MKV files.") + parser.add_argument("--radarr-url", default=os.getenv("RADARR_URL"), help="Radarr URL, or RADARR_URL.") + parser.add_argument("--api-key", default=os.getenv("RADARR_API_KEY"), help="Radarr API key, or RADARR_API_KEY.") + parser.add_argument("--quality-profile-id", type=int, required=True, help="Radarr quality profile id.") + parser.add_argument("--root-folder-path", required=True, help="Radarr root folder path for added movies.") + parser.add_argument( + "--minimum-availability", + default=DEFAULT_MINIMUM_AVAILABILITY, + choices=["announced", "inCinemas", "released"], + help="Radarr minimum availability.", + ) + parser.add_argument("--recursive", action="store_true", help="Scan subdirectories recursively.") + parser.add_argument( + "--state-dir", + help="Directory for resume state; defaults to /.radarr-add-state.", + ) + parser.add_argument("--no-resume", action="store_true", help="Ignore existing resume state for this run.") + return parser.parse_args(argv) + + +def validate_args(args: argparse.Namespace) -> Path: + missing = [] + if not args.radarr_url: + missing.append("--radarr-url or RADARR_URL") + if not args.api_key: + missing.append("--api-key or RADARR_API_KEY") + if missing: + raise ValueError(f"Missing required values: {', '.join(missing)}") + + directory = Path(args.directory).expanduser() + if not directory.exists(): + raise ValueError(f"Directory does not exist: {directory}") + if not directory.is_dir(): + raise ValueError(f"Not a directory: {directory}") + return directory + + +def run(args: argparse.Namespace) -> int: + try: + directory = validate_args(args) + except ValueError as error: + print(error, file=sys.stderr) + return 2 + + client = RadarrClient(args.radarr_url, args.api_key) + state_store = StateStore(state_dir_for(directory, args), enabled=True) + state_store.initialize(directory, args.recursive) + + try: + existing_movies = client.get_movies() + except RadarrError as error: + print(f"Unable to load Radarr movies: {error}", file=sys.stderr) + return 1 + + existing_keys = existing_movie_keys(existing_movies) + content_items, sample_items = find_content_items(directory, recursive=args.recursive) + + if not content_items: + print("No non-sample MKV or disc content items found.") + if sample_items: + print(f"Skipped sample content items: {len(sample_items)}") + return 0 + + if sample_items: + print(f"Skipped sample content items: {len(sample_items)}") + for item in sample_items: + print(f" sample: {item.path}") + state_store.record(item, "skipped", "sample content") + + results: list[RunResult] = [] + for index, content_item in enumerate(content_items, 1): + state = state_store.load(content_item) + if should_resume_skip(state, args.no_resume): + result = completed_result_from_state(content_item, state or {}) + print(f"RESUME skip {content_item.path.name}: {result.status} ({result.detail})") + results.append(result) + continue + + state_store.record_discovered(content_item) + try: + result = process_one_item( + content_item, + index, + len(content_items), + client, + args.quality_profile_id, + args.root_folder_path, + args.minimum_availability, + existing_keys, + state_store, + ) + except RadarrError as error: + print(f"FAIL {content_item.path.name}: {error}") + result = RunResult(content_item, "failed", str(error)) + + if result.status == "quit": + print("Stopped by user.") + break + results.append(result) + + print_run_summary(results, sample_items) + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/args.py b/src/args.py index fada86ff5..0dc924457 100644 --- a/src/args.py +++ b/src/args.py @@ -195,6 +195,8 @@ def parse(self, argv: Sequence[str], meta: dict[str, Any]) -> tuple[dict[str, An parser.add_argument('-emby', '--emby', action='store_true', required=False, help="Create an Emby-compliant NFO file and optionally symlink the content") parser.add_argument('-emby_cat', '--emby_cat', nargs=1, required=False, help="Set the expected category for Emby (e.g., 'movie', 'tv')") parser.add_argument('-emby_debug', '--emby_debug', action='store_true', required=False, help="Does debugging stuff for Audionut") + parser.add_argument('-radarr-add', '--radarr-add', action='store_true', required=False, help="Use queue/qBittorrent tracker IDs to add movie entries to Radarr without uploading") + parser.add_argument('-sonarr-add', '--sonarr-add', action='store_true', required=False, help="Use queue/qBittorrent tracker IDs to add TV series entries to Sonarr without uploading") parser.add_argument('-ch', '--channel', nargs=1, required=False, help="SPD only: Channel ID number or tag to upload to (preferably the ID), without '@'. Example: '-ch spd' when using a tag, or '-ch 1' when using an ID.", type=str, dest='spd_channel', default="") parser.add_argument("-excl", "--exclusive", nargs=1, required=False, help="Set exclusive flag on all supported trackers", dest="exclusive") parsed_args_ns, before_args = parser.parse_known_args(input) diff --git a/src/arr_add.py b/src/arr_add.py new file mode 100644 index 000000000..a6633c3ba --- /dev/null +++ b/src/arr_add.py @@ -0,0 +1,722 @@ +# Upload Assistant © 2025 Audionut & wastaken7 — Licensed under UAPL v1.0 +import json +import os +import re +import time +import traceback +from typing import Any, Optional, cast + +import aiofiles +from typing_extensions import TypeAlias + +from src.clients import Clients +from src.console import console +from src.get_tracker_data import TrackerDataManager +from src.radarr import RadarrManager +from src.sonarr import SonarrManager +from src.tvdb import tvdb_data + +Meta: TypeAlias = dict[str, Any] +RadarrAddDuplicateKey: TypeAlias = tuple[str, str] +SonarrAddDuplicateKey: TypeAlias = tuple[str, str, str] + +RADARR_ADD_TRACKER_ID_KEYS: tuple[str, ...] = ( + 'aither', 'ulcx', 'lst', 'blu', 'oe', 'btn', 'bhd', 'huno', 'hdb', 'rf', 'otw', 'yus', 'dp', 'sp', + 'ras', 'lume', 'hhd', 'rmc', 'ptp', 'ant' +) +RADARR_ADD_VIDEO_EXTENSIONS: tuple[str, ...] = ('.mkv', '.mp4', '.ts') +SONARR_ADD_SKIPPED_TRACKER_ID_KEYS: tuple[str, ...] = ('ptp', 'rf', 'ant') +SONARR_ADD_TRACKER_ID_KEYS: tuple[str, ...] = tuple( + key for key in RADARR_ADD_TRACKER_ID_KEYS if key not in SONARR_ADD_SKIPPED_TRACKER_ID_KEYS +) +SONARR_ADD_TRACKER_META_KEYS: dict[str, str] = { + 'AITHER': 'aither', + 'ULCX': 'ulcx', + 'LST': 'lst', + 'BLU': 'blu', + 'OE': 'oe', + 'BTN': 'btn', + 'BHD': 'bhd', + 'HUNO': 'huno', + 'HDB': 'hdb', + 'RF': 'rf', + 'OTW': 'otw', + 'YUS': 'yus', + 'DP': 'dp', + 'SP': 'sp', + 'RAS': 'ras', + 'LUME': 'lume', + 'HHD': 'hhd', + 'RMC': 'rmc', + 'PTP': 'ptp', + 'ANT': 'ant', +} + + +def _safe_int_id(value: Any) -> Optional[int]: + try: + parsed = int(str(value).replace("tt", "")) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _detect_radarr_add_disc(path: str) -> str: + if not os.path.isdir(path): + return "" + try: + for entry in os.scandir(path): + if entry.is_dir(): + entry_name = entry.name.upper() + if entry_name == "BDMV": + return "BDMV" + if entry_name == "VIDEO_TS": + return "DVD" + except OSError: + return "" + return "" + + +def _first_video_file(path: str) -> Optional[str]: + if os.path.isfile(path) and path.lower().endswith(RADARR_ADD_VIDEO_EXTENSIONS): + return path + if not os.path.isdir(path): + return None + try: + for root, _dirs, files in os.walk(path): + for file in sorted(files): + if file.lower().endswith(RADARR_ADD_VIDEO_EXTENSIONS): + return os.path.join(root, file) + except OSError: + return None + return None + + +def _normalize_arr_add_title(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() + + +async def _radarr_add_duplicate_key(name_manager: Any, meta: Meta, path: str) -> Optional[RadarrAddDuplicateKey]: + title, _secondary_title, year = await name_manager.extract_title_and_year(meta, path) + if not title or not year: + return None + normalized_title = _normalize_arr_add_title(title) + return (normalized_title, str(year)) if normalized_title else None + + +def _radarr_add_duplicate_label(key: RadarrAddDuplicateKey) -> str: + title, year = key + return f"{title} ({year})" + + +def _radarr_add_duplicate_key_from_movie(movie: Any) -> Optional[RadarrAddDuplicateKey]: + if not isinstance(movie, dict): + return None + title_value = movie.get("title") or movie.get("originalTitle") + year_value = movie.get("year") + if not title_value or not year_value: + return None + title = _normalize_arr_add_title(str(title_value)) + year = str(year_value) + return (title, year) if title and year else None + + +def radarr_add_seen_key_file(base_dir: str, meta: Meta) -> str: + queue_name = str(meta.get('queue') or "default").replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{queue_name}_radarr_add_title_years.json") + + +def radarr_add_unable_log_file(base_dir: str, meta: Meta) -> str: + queue_name = str(meta.get('queue') or "default").replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{queue_name}_radarr_add_unable_titles.json") + + +async def load_radarr_add_seen_keys(path: str) -> set[RadarrAddDuplicateKey]: + if not os.path.exists(path): + return set() + try: + async with aiofiles.open(path, encoding='utf-8') as f: + content = await f.read() + loaded = json.loads(content) if content.strip() else [] + except Exception: + return set() + + keys: set[RadarrAddDuplicateKey] = set() + if not isinstance(loaded, list): + return keys + for item in loaded: + if isinstance(item, dict): + title = item.get("title") + year = item.get("year") + if isinstance(title, str) and isinstance(year, str) and title and year: + keys.add((title, year)) + return keys + + +async def _save_radarr_add_seen_keys(path: str, seen_title_years: set[RadarrAddDuplicateKey]) -> None: + data = [ + {"title": title, "year": year} + for title, year in sorted(seen_title_years) + ] + os.makedirs(os.path.dirname(path), exist_ok=True) + async with aiofiles.open(path, "w", encoding='utf-8') as f: + await f.write(json.dumps(data, indent=4)) + + +async def _remember_radarr_add_key( + duplicate_key: Optional[RadarrAddDuplicateKey], + seen_title_years: set[RadarrAddDuplicateKey], + seen_key_file: Optional[str], +) -> None: + if not duplicate_key: + return + seen_title_years.add(duplicate_key) + if seen_key_file: + await _save_radarr_add_seen_keys(seen_key_file, seen_title_years) + + +async def _record_radarr_add_unable_title( + path: str, + search_term: str, + reason: str, + meta: Meta, + unable_log_file: Optional[str], +) -> None: + if not unable_log_file: + return + + record = { + "path": path, + "title": search_term, + "reason": reason, + "timestamp": str(int(time.time())), + } + tracker_ids = { + key: str(meta[key]) + for key in RADARR_ADD_TRACKER_ID_KEYS + if meta.get(key) not in (None, "", 0, "0") + } + if tracker_ids: + record["tracker_ids"] = json.dumps(tracker_ids, sort_keys=True) + + existing: list[dict[str, str]] = [] + if os.path.exists(unable_log_file): + try: + async with aiofiles.open(unable_log_file, encoding='utf-8') as f: + content = await f.read() + loaded = json.loads(content) if content.strip() else [] + if isinstance(loaded, list): + existing = [ + cast(dict[str, str], item) + for item in loaded + if isinstance(item, dict) + ] + except Exception: + existing = [] + + existing = [item for item in existing if item.get("path") != path] + existing.append(record) + os.makedirs(os.path.dirname(unable_log_file), exist_ok=True) + async with aiofiles.open(unable_log_file, "w", encoding='utf-8') as f: + await f.write(json.dumps(existing, indent=4)) + + +async def _sonarr_add_duplicate_keys(name_manager: Any, meta: Meta, path: str) -> set[SonarrAddDuplicateKey]: + title, _secondary_title, year = await name_manager.extract_title_and_year(meta, path) + if not title: + return set() + normalized_title = _normalize_arr_add_title(title) + if not normalized_title: + return set() + + keys: set[SonarrAddDuplicateKey] = {(normalized_title, "title", "")} + tvdb_id = _safe_int_id(meta.get('tvdb_id') or meta.get('tvdb')) + if tvdb_id: + keys.add((normalized_title, "tvdb_id", str(tvdb_id))) + if year: + keys.add((normalized_title, "year", str(year))) + return keys + + +def _sonarr_add_duplicate_label(key: SonarrAddDuplicateKey) -> str: + title, key_type, value = key + if key_type == "tvdb_id": + return f"{title} tvdb={value}" + if key_type == "title": + return title + return f"{title} ({value})" + + +def _sonarr_add_duplicate_keys_from_series(series: Any) -> set[SonarrAddDuplicateKey]: + if not isinstance(series, dict): + return set() + title_value = series.get("title") or series.get("originalTitle") + if not title_value: + return set() + title = _normalize_arr_add_title(str(title_value)) + if not title: + return set() + + keys: set[SonarrAddDuplicateKey] = {(title, "title", "")} + tvdb_id = _safe_int_id(series.get("tvdbId")) + if tvdb_id: + keys.add((title, "tvdb_id", str(tvdb_id))) + year_value = series.get("year") + if year_value: + keys.add((title, "year", str(year_value))) + return keys + + +def _sonarr_add_first_seen_key( + duplicate_keys: set[SonarrAddDuplicateKey], + seen_keys: set[SonarrAddDuplicateKey], +) -> Optional[SonarrAddDuplicateKey]: + key_types = ["tvdb_id", "year"] if any(key[1] in ("tvdb_id", "year") for key in duplicate_keys) else [] + key_types.append("title") + for key_type in key_types: + for key in sorted(duplicate_keys): + if key[1] == key_type and key in seen_keys: + return key + return None + + +def _sonarr_add_lookup_label(duplicate_keys: set[SonarrAddDuplicateKey], fallback: str) -> str: + for key_type in ("year", "title", "tvdb_id"): + for key in sorted(duplicate_keys): + if key[1] == key_type: + return _sonarr_add_duplicate_label(key) + return fallback + + +def sonarr_add_seen_key_file(base_dir: str, meta: Meta) -> str: + queue_name = str(meta.get('queue') or "default").replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{queue_name}_sonarr_add_series.json") + + +def sonarr_add_unable_log_file(base_dir: str, meta: Meta) -> str: + queue_name = str(meta.get('queue') or "default").replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{queue_name}_sonarr_add_unable_titles.json") + + +async def load_sonarr_add_seen_keys(path: str) -> set[SonarrAddDuplicateKey]: + if not os.path.exists(path): + return set() + try: + async with aiofiles.open(path, encoding='utf-8') as f: + content = await f.read() + loaded = json.loads(content) if content.strip() else [] + except Exception: + return set() + + keys: set[SonarrAddDuplicateKey] = set() + if not isinstance(loaded, list): + return keys + for item in loaded: + if isinstance(item, dict): + title = item.get("title") + tvdb_id = item.get("tvdb_id") + year = item.get("year") + if isinstance(title, str) and title: + keys.add((title, "title", "")) + if isinstance(title, str) and isinstance(tvdb_id, str) and title and tvdb_id: + keys.add((title, "tvdb_id", tvdb_id)) + if isinstance(title, str) and isinstance(year, str) and title and year: + keys.add((title, "year", year)) + return keys + + +async def _save_sonarr_add_seen_keys(path: str, seen_keys: set[SonarrAddDuplicateKey]) -> None: + data: list[dict[str, str]] = [] + for title, key_type, value in sorted(seen_keys): + item = {"title": title} + if key_type == "tvdb_id": + item["tvdb_id"] = value + elif key_type == "year": + item["year"] = value + data.append(item) + os.makedirs(os.path.dirname(path), exist_ok=True) + async with aiofiles.open(path, "w", encoding='utf-8') as f: + await f.write(json.dumps(data, indent=4)) + + +async def _remember_sonarr_add_keys( + duplicate_keys: set[SonarrAddDuplicateKey], + seen_keys: set[SonarrAddDuplicateKey], + seen_key_file: Optional[str], +) -> None: + if not duplicate_keys: + return + seen_keys.update(duplicate_keys) + if seen_key_file: + await _save_sonarr_add_seen_keys(seen_key_file, seen_keys) + + +async def _record_sonarr_add_unable_title( + path: str, + search_term: str, + reason: str, + meta: Meta, + unable_log_file: Optional[str], +) -> None: + if not unable_log_file: + return + + record = { + "path": path, + "title": search_term, + "reason": reason, + "timestamp": str(int(time.time())), + } + tracker_ids = { + key: str(meta[key]) + for key in SONARR_ADD_TRACKER_ID_KEYS + if meta.get(key) not in (None, "", 0, "0") + } + if tracker_ids: + record["tracker_ids"] = json.dumps(tracker_ids, sort_keys=True) + + existing: list[dict[str, str]] = [] + if os.path.exists(unable_log_file): + try: + async with aiofiles.open(unable_log_file, encoding='utf-8') as f: + content = await f.read() + loaded = json.loads(content) if content.strip() else [] + if isinstance(loaded, list): + existing = [ + cast(dict[str, str], item) + for item in loaded + if isinstance(item, dict) + ] + except Exception: + existing = [] + + existing = [item for item in existing if item.get("path") != path] + existing.append(record) + os.makedirs(os.path.dirname(unable_log_file), exist_ok=True) + async with aiofiles.open(unable_log_file, "w", encoding='utf-8') as f: + await f.write(json.dumps(existing, indent=4)) + + +def _prepare_radarr_add_meta(meta: Meta, path: str, base_dir: str) -> tuple[str, str]: + path_basename = os.path.basename(os.path.normpath(path)) + video_file = _first_video_file(path) + is_disc = _detect_radarr_add_disc(path) + + meta['path'] = path + meta['base_dir'] = base_dir + meta['uuid'] = path_basename + meta['category'] = "MOVIE" + meta['manual_category'] = "movie" + meta['is_disc'] = is_disc + meta['isdir'] = os.path.isdir(path) + meta['filelist'] = [video_file] if video_file else [] + meta['video'] = video_file or path + meta['trackers'] = [] + meta['requested_trackers'] = [] + meta['description'] = "" + meta['image_list'] = [] + meta['unattended'] = True + meta['unattended_confirm'] = False + meta['onlyID'] = True + meta['only_id'] = True + meta['keep_images'] = False + meta['client_ids_only'] = True + meta['skip_auto_torrent'] = False + meta['base_torrent_created'] = True + meta['we_checked_them_all'] = False + meta['debug'] = bool(meta.get('debug', False)) + + if os.path.isfile(path): + return os.path.basename(path), "file" + return path_basename, "folder" + + +def _prepare_sonarr_add_meta(meta: Meta, path: str, base_dir: str) -> tuple[str, str]: + path_basename = os.path.basename(os.path.normpath(path)) + video_file = _first_video_file(path) + + meta['path'] = path + meta['base_dir'] = base_dir + meta['uuid'] = path_basename + meta['filename'] = path_basename + meta['category'] = "TV" + meta['manual_category'] = "tv" + meta['is_disc'] = "" + meta['isdir'] = os.path.isdir(path) + meta['filelist'] = [video_file] if video_file else [] + meta['video'] = video_file or path + meta['trackers'] = [] + meta['requested_trackers'] = [] + meta['description'] = "" + meta['image_list'] = [] + meta['unattended'] = True + meta['unattended_confirm'] = False + meta['onlyID'] = True + meta['only_id'] = True + meta['keep_images'] = False + meta['client_ids_only'] = True + meta['skip_auto_torrent'] = False + meta['base_torrent_created'] = True + meta['we_checked_them_all'] = False + meta['debug'] = bool(meta.get('debug', False)) + + if os.path.isfile(path): + return os.path.basename(path), "file" + return path_basename, "folder" + + +async def _get_sonarr_add_tracker_data(config: dict[str, Any], meta: Meta, search_term: str, search_file_folder: str) -> None: + attempted_trackers: set[str] = set() + + while not _safe_int_id(meta.get('tvdb_id') or meta.get('tvdb')): + remaining_tracker_keys = [ + key + for key in SONARR_ADD_TRACKER_ID_KEYS + if key not in attempted_trackers and meta.get(key) not in (None, "", 0, "0") + ] + if not remaining_tracker_keys: + return + + meta.pop('matched_tracker', None) + meta.pop('no_tracker_match', None) + await TrackerDataManager(config).get_tracker_data( + None, + meta, + search_term=search_term, + search_file_folder=search_file_folder, + cat="TV", + only_id=True, + ) + + if _safe_int_id(meta.get('tvdb_id') or meta.get('tvdb')): + return + + matched_tracker = str(meta.get('matched_tracker') or "").upper() + matched_tracker_key = SONARR_ADD_TRACKER_META_KEYS.get(matched_tracker) + if not matched_tracker_key: + return + + attempted_trackers.add(matched_tracker_key) + meta[matched_tracker_key] = None + if meta.get('debug', False): + console.print(f"[yellow]Sonarr add: {matched_tracker} did not return TVDb; trying remaining tracker IDs.[/yellow]") + + +async def _resolve_sonarr_add_tvdb_from_external_ids( + config: dict[str, Any], + meta: Meta, + imdb_id: Optional[int], + tmdb_id: Optional[int], +) -> Optional[int]: + if not imdb_id and not tmdb_id: + return None + + console.print("[yellow]Sonarr add: no TVDb ID from trackers; trying TVDb lookup from IMDb/TMDb.[/yellow]") + tvdb_id, tvdb_series_name = await tvdb_data(config).get_tvdb_by_external_id( + imdb_id, + tmdb_id, + debug=bool(meta.get('debug', False)), + ) + resolved_tvdb_id = _safe_int_id(tvdb_id) + if not resolved_tvdb_id: + return None + + meta['tvdb_id'] = resolved_tvdb_id + meta['tvdb'] = resolved_tvdb_id + if tvdb_series_name: + meta['tvdb_series_name'] = tvdb_series_name + if meta.get('debug', False): + console.print(f"[green]Sonarr add: found TVDb ID from IMDb/TMDb lookup: {resolved_tvdb_id}[/green]") + return resolved_tvdb_id + + +async def process_radarr_add( + meta: Meta, + base_dir: str, + seen_title_years: set[RadarrAddDuplicateKey], + seen_key_file: Optional[str], + unable_log_file: Optional[str], + config: dict[str, Any], + name_manager: Any, +) -> bool: + path = str(meta.get('path') or "") + if not path: + console.print("[red]Radarr add skipped: no input path was available.[/red]") + return True + + search_term, search_file_folder = _prepare_radarr_add_meta(meta, path, base_dir) + duplicate_key = await _radarr_add_duplicate_key(name_manager, meta, path) + if duplicate_key and duplicate_key in seen_title_years: + console.print(f"[red]Radarr add skipped duplicate title/year before qBittorrent search: {_radarr_add_duplicate_label(duplicate_key)}[/red]") + return True + + if config.get('DEFAULT', {}).get('use_radarr', False): + lookup_term = _radarr_add_duplicate_label(duplicate_key) if duplicate_key else search_term + radarr_existing = await RadarrManager(config).existing_movie_by_lookup_term(lookup_term, debug=meta.get('debug', False)) + if radarr_existing.get("status") == "exists": + detail = str(radarr_existing.get("detail") or "") + console.print(f"[red]Radarr add skipped before qBittorrent search: {detail}[/red]") + movie_key = _radarr_add_duplicate_key_from_movie(radarr_existing.get("movie")) + await _remember_radarr_add_key(duplicate_key or movie_key, seen_title_years, seen_key_file) + return True + + console.print(f"[green]Radarr add: gathering tracker IDs for {os.path.basename(path)}[/green]") + + try: + await Clients(config).get_pathed_torrents(path, meta) + except Exception as e: + console.print(f"[red]Radarr add skipped: qBittorrent search failed for {path}: {e}[/red]") + if meta.get('debug', False): + console.print(traceback.format_exc()) + return True + + tracker_ids = { + key: str(meta[key]) + for key in RADARR_ADD_TRACKER_ID_KEYS + if meta.get(key) not in (None, "", 0, "0") + } + if not tracker_ids: + reason = "No tracker IDs found in qBittorrent." + await _record_radarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[yellow]Radarr add unable: {search_term} was logged because no tracker IDs were found in qBittorrent.[/yellow]") + return True + + if meta.get('debug', False): + console.print(f"[cyan]Radarr add tracker IDs from qBittorrent: {tracker_ids}[/cyan]") + + await TrackerDataManager(config).get_tracker_data( + None, + meta, + search_term=search_term, + search_file_folder=search_file_folder, + cat="MOVIE", + only_id=True, + ) + + tmdb_id = _safe_int_id(meta.get('tmdb_id') or meta.get('tmdb')) + imdb_id = _safe_int_id(meta.get('imdb_id') or meta.get('imdb')) + if not tmdb_id and not imdb_id: + reason = "Tracker IDs did not return a TMDb or IMDb ID after trying available trackers." + await _record_radarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[red]Radarr add unable: {search_term} was logged because no TMDb or IMDb ID was found.[/red]") + return True + + result = await RadarrManager(config).add_movie_by_ids( + tmdb_id=tmdb_id, + imdb_id=imdb_id, + debug=meta.get('debug', False), + ) + status = str(result.get("status") or "failed") + detail = str(result.get("detail") or "") + if status == "added": + console.print(f"[green]Radarr add complete: {detail}[/green]") + await _remember_radarr_add_key(duplicate_key, seen_title_years, seen_key_file) + return True + if status == "exists": + console.print(f"[red]Radarr add skipped: {detail}[/red]") + await _remember_radarr_add_key(duplicate_key, seen_title_years, seen_key_file) + return True + + reason = detail or "Radarr add failed." + await _record_radarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[red]Radarr add failed for {os.path.basename(path)}: {detail}[/red]") + return False + + +async def process_sonarr_add( + meta: Meta, + base_dir: str, + seen_keys: set[SonarrAddDuplicateKey], + seen_key_file: Optional[str], + unable_log_file: Optional[str], + config: dict[str, Any], + name_manager: Any, +) -> bool: + path = str(meta.get('path') or "") + if not path: + console.print("[red]Sonarr add skipped: no input path was available.[/red]") + return True + + search_term, search_file_folder = _prepare_sonarr_add_meta(meta, path, base_dir) + duplicate_keys = await _sonarr_add_duplicate_keys(name_manager, meta, path) + seen_key = _sonarr_add_first_seen_key(duplicate_keys, seen_keys) + if seen_key: + console.print(f"[red]Sonarr add skipped duplicate series before qBittorrent search: {_sonarr_add_duplicate_label(seen_key)}[/red]") + return True + + if config.get('DEFAULT', {}).get('use_sonarr', False): + lookup_term = _sonarr_add_lookup_label(duplicate_keys, search_term) + sonarr_existing = await SonarrManager(config).existing_series_by_lookup_term(lookup_term, debug=meta.get('debug', False)) + if sonarr_existing.get("status") == "exists": + detail = str(sonarr_existing.get("detail") or "") + console.print(f"[red]Sonarr add skipped before qBittorrent search: {detail}[/red]") + series_keys = _sonarr_add_duplicate_keys_from_series(sonarr_existing.get("series")) + await _remember_sonarr_add_keys(series_keys or duplicate_keys, seen_keys, seen_key_file) + return True + + console.print(f"[green]Sonarr add: gathering tracker IDs for {os.path.basename(path)}[/green]") + + try: + await Clients(config).get_pathed_torrents(path, meta) + except Exception as e: + console.print(f"[red]Sonarr add skipped: qBittorrent search failed for {path}: {e}[/red]") + if meta.get('debug', False): + console.print(traceback.format_exc()) + return True + + tracker_ids = { + key: str(meta[key]) + for key in SONARR_ADD_TRACKER_ID_KEYS + if meta.get(key) not in (None, "", 0, "0") + } + if not tracker_ids: + reason = "No tracker IDs found in qBittorrent." + await _record_sonarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[yellow]Sonarr add skipped: no tracker IDs found in qBittorrent for {os.path.basename(path)}[/yellow]") + return True + + if meta.get('debug', False): + console.print(f"[cyan]Sonarr add tracker IDs from qBittorrent: {tracker_ids}[/cyan]") + + await _get_sonarr_add_tracker_data(config, meta, search_term, search_file_folder) + + tvdb_id = _safe_int_id(meta.get('tvdb_id') or meta.get('tvdb')) + imdb_id = _safe_int_id(meta.get('imdb_id') or meta.get('imdb')) + tmdb_id = _safe_int_id(meta.get('tmdb_id') or meta.get('tmdb')) + if not tvdb_id: + tvdb_id = await _resolve_sonarr_add_tvdb_from_external_ids(config, meta, imdb_id, tmdb_id) + duplicate_keys.update(await _sonarr_add_duplicate_keys(name_manager, meta, path)) + seen_key = _sonarr_add_first_seen_key(duplicate_keys, seen_keys) + if seen_key: + console.print(f"[red]Sonarr add skipped duplicate series after tracker lookup: {_sonarr_add_duplicate_label(seen_key)}[/red]") + return True + if not tvdb_id and meta.get('debug', False): + console.print(f"[yellow]Tracker IDs did not return TVDb for {os.path.basename(path)}; logging title as unable to add.[/yellow]") + if not tvdb_id: + reason = "Tracker IDs did not return a TVDb ID after trying available trackers." + await _record_sonarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[red]Sonarr add unable: {search_term} was logged because no TVDb ID was found.[/red]") + return True + + result = await SonarrManager(config).add_series_by_ids( + tvdb_id=tvdb_id, + imdb_id=imdb_id, + tmdb_id=tmdb_id, + debug=meta.get('debug', False), + ) + status = str(result.get("status") or "failed") + detail = str(result.get("detail") or "") + series_keys = _sonarr_add_duplicate_keys_from_series(result.get("series")) + if status == "added": + console.print(f"[green]Sonarr add complete: {detail}[/green]") + await _remember_sonarr_add_keys(series_keys or duplicate_keys, seen_keys, seen_key_file) + return True + if status == "exists": + console.print(f"[red]Sonarr add skipped: {detail}[/red]") + await _remember_sonarr_add_keys(series_keys or duplicate_keys, seen_keys, seen_key_file) + return True + + reason = detail or "Sonarr add failed." + await _record_sonarr_add_unable_title(path, search_term, reason, meta, unable_log_file) + console.print(f"[red]Sonarr add failed for {os.path.basename(path)}: {detail}[/red]") + return False diff --git a/src/configvalidator.py b/src/configvalidator.py index ac0cb81a5..0f6f65d60 100644 --- a/src/configvalidator.py +++ b/src/configvalidator.py @@ -3,6 +3,7 @@ Config validation helper for Upload Assistant. Validates the user's config.py against expected structure and types. """ +import re from typing import Any, Optional, cast # Required top-level sections @@ -58,7 +59,15 @@ "keep_images": (bool,), "only_id": (bool,), "use_sonarr": (bool,), + "sonarr_quality_profile_id": (str, int), + "sonarr_root_folder_path": (str,), + "sonarr_series_type": (str,), + "sonarr_season_folder": (bool,), + "sonarr_monitor": (str,), "use_radarr": (bool,), + "radarr_quality_profile_id": (str, int), + "radarr_root_folder_path": (str,), + "radarr_minimum_availability": (str,), "mkbrr": (bool,), "mkbrr_threads": (str, int), "user_overrides": (bool,), @@ -83,6 +92,17 @@ "auto_mode": (bool, str), } +ARR_OVERRIDE_KEYS = ( + "sonarr_quality_profile_id", + "sonarr_root_folder_path", + "sonarr_series_type", + "sonarr_season_folder", + "sonarr_monitor", + "radarr_quality_profile_id", + "radarr_root_folder_path", + "radarr_minimum_availability", +) + # Valid image hosts VALID_IMAGE_HOSTS = [ "imgbb", "ptpimg", "imgbox", "pixhost", "lensdump", "ptscreens", @@ -395,6 +415,21 @@ def _validate_default_section(default: dict[str, Any]) -> tuple[list[str], list[ section="DEFAULT" )) + for key, value in default.items(): + if value is None: + continue + for base_key in ARR_OVERRIDE_KEYS: + if re.fullmatch(rf"{base_key}_\d+", key): + expected_types = DEFAULT_KEY_TYPES[base_key] + if not isinstance(value, expected_types): + warnings.append(ConfigValidationWarning( + f"Expected type {' or '.join(t.__name__ for t in expected_types)}, " + f"got {type(value).__name__}", + key=key, + section="DEFAULT" + )) + break + # Validate image hosts for i in range(1, 10): host_key = f"img_host_{i}" diff --git a/src/get_tracker_data.py b/src/get_tracker_data.py index 1a000c7c6..6aed6ee1b 100644 --- a/src/get_tracker_data.py +++ b/src/get_tracker_data.py @@ -112,29 +112,47 @@ async def get_tracker_data( tracker_keys = { # preference some unit3d based trackers first # since they can return tmdb/imdb/tvdb ids - 'aither': 'AITHER', 'blu': 'BLU', - 'lst': 'LST', - 'ulcx': 'ULCX', 'oe': 'OE', 'huno': 'HUNO', - 'ant': 'ANT', 'btn': 'BTN', - 'bhd': 'BHD', - 'hdb': 'HDB', 'sp': 'SP', 'rf': 'RF', 'otw': 'OTW', 'yus': 'YUS', 'dp': 'DP', + 'ras': 'RAS', + 'lume': 'LUME', + 'hhd': 'HHD', + 'rmc': 'RMC', + 'ulcx': 'ULCX', + 'lst': 'LST', + 'aither': 'AITHER', + 'bhd': 'BHD', + 'ant': 'ANT', + 'hdb': 'HDB', 'ptp': 'PTP', } + if meta.get('sonarr_add', False): + sonarr_add_skipped_trackers = ('ptp', 'rf', 'ant') + tracker_keys = { + key: value + for key, value in tracker_keys.items() + if key not in sonarr_add_skipped_trackers + } + tracker_keys = { + 'btn': 'BTN', + 'hdb': 'HDB', + 'aither': 'AITHER', + **{key: value for key, value in tracker_keys.items() if key not in ('btn', 'hdb', 'aither')}, + } else: # Preference trackers with lesser overall torrents # Leaving the more complete trackers free when really needed tracker_keys = { 'sp': 'SP', 'otw': 'OTW', + 'ras': 'RAS', 'dp': 'DP', 'yus': 'YUS', 'rf': 'RF', @@ -142,6 +160,9 @@ async def get_tracker_data( 'ulcx': 'ULCX', 'huno': 'HUNO', 'lst': 'LST', + 'lume': 'LUME', + 'hhd': 'HHD', + 'rmc': 'RMC', 'ant': 'ANT', 'hdb': 'HDB', 'bhd': 'BHD', @@ -372,6 +393,11 @@ async def process_tracker(tracker_name: str, meta: dict[str, Any], only_id: bool console.print("[yellow]No matches found on any available specific trackers.[/yellow]") else: + if meta.get('client_ids_only', False): + if meta['debug']: + console.print("[yellow]No configured tracker IDs from the client are available; skipping fallback tracker searches.[/yellow]") + return meta + # Process all trackers with API = true if no specific tracker is set in meta tracker_order = ["PTP", "HDB", "BHD", "BLU", "AITHER", "HUNO", "LST", "OE", "ULCX"] @@ -499,5 +525,3 @@ async def ping_unit3d(self, meta: dict[str, Any]) -> None: if meta.get('distributor') and not had_distributor and meta.get('debug', False): console.print(f"[green]Found distributor '{meta['distributor']}' from {tracker_name}[/green]") - - diff --git a/src/radarr.py b/src/radarr.py index b3c1496df..25951f0c6 100644 --- a/src/radarr.py +++ b/src/radarr.py @@ -7,12 +7,280 @@ from src.console import console MovieInfo = dict[str, Any] +RadarrAddResult = dict[str, Any] class RadarrManager: def __init__(self, config: dict[str, Any]) -> None: self.config = config self.default_config = cast(dict[str, Any], config.get('DEFAULT', {})) + @staticmethod + def _normalize_base_url(value: str) -> str: + return value.strip().rstrip('/') + + @staticmethod + def _normalize_imdb_id(value: Any) -> Optional[str]: + if not value: + return None + imdb_id = str(value).strip() + if not imdb_id or imdb_id == "0": + return None + if imdb_id.lower().startswith("tt"): + imdb_digits = imdb_id[2:] + return f"tt{imdb_digits.zfill(7)}" if imdb_digits.isdigit() else imdb_id.lower() + return f"tt{imdb_id.zfill(7)}" + + @staticmethod + def _movie_label(movie: Mapping[str, Any]) -> str: + title = movie.get("title") or movie.get("originalTitle") or "Unknown title" + year = movie.get("year") or "????" + tmdb = movie.get("tmdbId") or "-" + imdb = movie.get("imdbId") or "-" + return f"{title} ({year}) tmdb={tmdb} imdb={imdb}" + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + if value in (None, "", 0, "0"): + return None + try: + normalized = str(value).strip() + if normalized.lower().startswith("tt"): + normalized = normalized[2:] + parsed = int(normalized) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + def _get_instance_config(self, instance_index: int) -> Optional[dict[str, Any]]: + suffix = "" if instance_index == 0 else f"_{instance_index}" + api_key_name = f"radarr_api_key{suffix}" + url_name = f"radarr_url{suffix}" + + api_key_value = self.default_config.get(api_key_name) + base_url_value = self.default_config.get(url_name) + if not isinstance(api_key_value, str) or not api_key_value.strip(): + return None + if not isinstance(base_url_value, str) or not base_url_value.strip(): + return None + + quality_profile_id = self.default_config.get(f"radarr_quality_profile_id{suffix}", self.default_config.get("radarr_quality_profile_id")) + root_folder_path = self.default_config.get(f"radarr_root_folder_path{suffix}", self.default_config.get("radarr_root_folder_path")) + minimum_availability = self.default_config.get( + f"radarr_minimum_availability{suffix}", + self.default_config.get("radarr_minimum_availability", "released"), + ) + + try: + quality_profile_id_int = int(str(quality_profile_id or "0")) + except (TypeError, ValueError): + quality_profile_id_int = 0 + + return { + "label": str(instance_index if instance_index > 0 else "default"), + "base_url": self._normalize_base_url(base_url_value), + "api_key": api_key_value.strip(), + "quality_profile_id": quality_profile_id_int, + "root_folder_path": str(root_folder_path).strip() if root_folder_path is not None else "", + "minimum_availability": str(minimum_availability or "released").strip(), + } + + def _iter_configured_instances(self) -> list[dict[str, Any]]: + instances: list[dict[str, Any]] = [] + for instance_index in range(4): + instance = self._get_instance_config(instance_index) + if instance: + instances.append(instance) + return instances + + async def _request_json( + self, + client: httpx.AsyncClient, + method: str, + instance: Mapping[str, Any], + path: str, + params: Optional[dict[str, Any]] = None, + body: Optional[dict[str, Any]] = None, + ) -> Any: + url = f"{instance['base_url']}{path}" + headers = { + "X-Api-Key": str(instance["api_key"]), + "Accept": "application/json", + "Content-Type": "application/json", + } + response = await client.request(method, url, headers=headers, params=params, json=body, timeout=20.0) + response.raise_for_status() + if not response.content: + return None + return response.json() + + async def _lookup_movie_by_ids(self, client: httpx.AsyncClient, instance: Mapping[str, Any], tmdb_id: Optional[int], imdb_id: Optional[int]) -> Optional[dict[str, Any]]: + lookup_candidates: list[tuple[str, dict[str, Any], str, str]] = [] + if tmdb_id: + lookup_candidates.append(("/api/v3/movie/lookup/tmdb", {"tmdbId": tmdb_id}, "tmdbId", str(tmdb_id))) + lookup_candidates.append(("/api/v3/movie/lookup", {"term": f"tmdb:{tmdb_id}"}, "tmdbId", str(tmdb_id))) + normalized_imdb = self._normalize_imdb_id(imdb_id) + if normalized_imdb: + lookup_candidates.append(("/api/v3/movie/lookup/imdb", {"imdbId": normalized_imdb}, "imdbId", normalized_imdb)) + lookup_candidates.append(("/api/v3/movie/lookup", {"term": f"imdb:{normalized_imdb}"}, "imdbId", normalized_imdb)) + + for path, params, match_field, expected_value in lookup_candidates: + try: + data = await self._request_json(client, "GET", instance, path, params=params) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + continue + raise + + if isinstance(data, list): + items = cast(list[dict[str, Any]], data) + for item in items: + if self._movie_matches_lookup_id(item, match_field, expected_value): + return item + elif isinstance(data, dict): + item = cast(dict[str, Any], data) + if self._movie_matches_lookup_id(item, match_field, expected_value): + return item + return None + + def _movie_matches_lookup_id(self, movie: Mapping[str, Any], field: str, expected_value: str) -> bool: + if field == "tmdbId": + return str(self._coerce_optional_int(movie.get("tmdbId")) or "") == expected_value + if field == "imdbId": + return self._normalize_imdb_id(movie.get("imdbId")) == expected_value + return False + + async def _lookup_movie_by_filename(self, client: httpx.AsyncClient, instance: Mapping[str, Any], filename: Optional[str]) -> Optional[dict[str, Any]]: + if not filename: + return None + data = await self._request_json(client, "GET", instance, "/api/v3/movie/lookup", params={"term": filename}) + if isinstance(data, list): + items = cast(list[dict[str, Any]], data) + return items[0] if items else None + if isinstance(data, dict): + return cast(dict[str, Any], data) + return None + + async def _existing_movie(self, client: httpx.AsyncClient, instance: Mapping[str, Any], tmdb_id: Optional[int], imdb_id: Optional[int]) -> Optional[dict[str, Any]]: + data = await self._request_json(client, "GET", instance, "/api/v3/movie") + if not isinstance(data, list): + return None + + normalized_imdb = self._normalize_imdb_id(imdb_id) + for item in cast(list[dict[str, Any]], data): + if tmdb_id and str(item.get("tmdbId") or "") == str(tmdb_id): + return item + if normalized_imdb and str(item.get("imdbId") or "").lower() == normalized_imdb.lower(): + return item + return None + + async def add_movie_by_ids(self, tmdb_id: Optional[int] = None, imdb_id: Optional[int] = None, debug: bool = False) -> RadarrAddResult: + if not tmdb_id and not imdb_id: + return {"status": "skipped", "detail": "no TMDb ID or IMDb ID was available"} + + instances = self._iter_configured_instances() + if not instances: + return {"status": "failed", "detail": "No Radarr API keys are configured."} + + last_error = "" + async with httpx.AsyncClient() as client: + for instance in instances: + label = instance["label"] + if not instance["quality_profile_id"] or not instance["root_folder_path"]: + last_error = ( + f"Radarr instance {label} is missing radarr_quality_profile_id " + "or radarr_root_folder_path." + ) + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + try: + existing = await self._existing_movie(client, instance, tmdb_id, imdb_id) + if existing: + return { + "status": "exists", + "detail": f"already in Radarr: {self._movie_label(existing)}", + "movie": existing, + } + + movie = await self._lookup_movie_by_ids(client, instance, tmdb_id, imdb_id) + if not movie: + last_error = f"Radarr instance {label} did not find a movie for TMDb={tmdb_id} IMDb={imdb_id}." + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + payload = dict(movie) + payload["qualityProfileId"] = instance["quality_profile_id"] + payload["rootFolderPath"] = instance["root_folder_path"] + payload["monitored"] = False + payload["minimumAvailability"] = instance["minimum_availability"] + payload["addOptions"] = {"searchForMovie": False} + + added = await self._request_json(client, "POST", instance, "/api/v3/movie", body=payload) + if isinstance(added, dict): + return { + "status": "added", + "detail": f"added to Radarr: {self._movie_label(cast(dict[str, Any], added))}", + "movie": added, + } + return {"status": "added", "detail": "added to Radarr"} + except httpx.HTTPStatusError as e: + response_text = e.response.text.strip() + last_error = f"Radarr instance {label} HTTP {e.response.status_code}: {response_text or e.response.reason_phrase}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + except httpx.RequestError as e: + last_error = f"Radarr instance {label} request failed: {e}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + + return {"status": "failed", "detail": last_error or "Radarr add failed."} + + async def existing_movie_by_lookup_term(self, term: Optional[str], debug: bool = False) -> RadarrAddResult: + if not term: + return {"status": "skipped", "detail": "no Radarr lookup term was available"} + + instances = self._iter_configured_instances() + if not instances: + return {"status": "failed", "detail": "No Radarr API keys are configured."} + + last_error = "" + async with httpx.AsyncClient() as client: + for instance in instances: + label = instance["label"] + try: + movie = await self._lookup_movie_by_filename(client, instance, term) + if not movie: + last_error = f"Radarr instance {label} did not find a lookup candidate for {term}." + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + existing = await self._existing_movie( + client, + instance, + self._coerce_optional_int(movie.get("tmdbId")), + self._coerce_optional_int(movie.get("imdbId")), + ) + if existing: + return { + "status": "exists", + "detail": f"already in Radarr: {self._movie_label(existing)}", + "movie": existing, + } + except httpx.HTTPStatusError as e: + response_text = e.response.text.strip() + last_error = f"Radarr instance {label} HTTP {e.response.status_code}: {response_text or e.response.reason_phrase}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + except httpx.RequestError as e: + last_error = f"Radarr instance {label} request failed: {e}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + + return {"status": "missing", "detail": last_error or f"not found in Radarr: {term}"} + async def get_radarr_data(self, tmdb_id: Optional[int] = None, filename: Optional[str] = None, debug: bool = False) -> Optional[MovieInfo]: if not any(key.startswith('radarr_api_key') for key in self.default_config): console.print("[red]No Radarr API keys are configured.[/red]") @@ -142,5 +410,3 @@ async def extract_movie_data(self, radarr_data: Any, filename: Optional[str] = N "genres": movie.get("genres", []), "release_group": release_group if release_group else None } - - diff --git a/src/sonarr.py b/src/sonarr.py index d775ab422..5a0a2460e 100644 --- a/src/sonarr.py +++ b/src/sonarr.py @@ -7,12 +7,301 @@ from src.console import console ShowInfo = dict[str, Any] +SonarrAddResult = dict[str, Any] class SonarrManager: def __init__(self, config: dict[str, Any]) -> None: self.config = config self.default_config = cast(dict[str, Any], config.get('DEFAULT', {})) + @staticmethod + def _normalize_base_url(value: str) -> str: + return value.strip().rstrip('/') + + @staticmethod + def _normalize_imdb_id(value: Optional[int]) -> Optional[str]: + if not value: + return None + imdb_id = str(value).strip() + if not imdb_id or imdb_id == "0": + return None + return imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id.zfill(7)}" + + @staticmethod + def _series_label(series: Mapping[str, Any]) -> str: + title = series.get("title") or series.get("originalTitle") or "Unknown title" + year = series.get("year") or "????" + tvdb = series.get("tvdbId") or "-" + imdb = series.get("imdbId") or "-" + tmdb = series.get("tmdbId") or "-" + return f"{title} ({year}) tvdb={tvdb} imdb={imdb} tmdb={tmdb}" + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + if value in (None, "", 0, "0"): + return None + try: + normalized = str(value).strip() + if normalized.lower().startswith("tt"): + normalized = normalized[2:] + parsed = int(normalized) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + @staticmethod + def _coerce_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "y", "on"): + return True + if normalized in ("false", "0", "no", "n", "off"): + return False + return default + + def _get_instance_config(self, instance_index: int) -> Optional[dict[str, Any]]: + suffix = "" if instance_index == 0 else f"_{instance_index}" + api_key_name = f"sonarr_api_key{suffix}" + url_name = f"sonarr_url{suffix}" + + api_key_value = self.default_config.get(api_key_name) + base_url_value = self.default_config.get(url_name) + if not isinstance(api_key_value, str) or not api_key_value.strip(): + return None + if not isinstance(base_url_value, str) or not base_url_value.strip(): + return None + + quality_profile_id = self.default_config.get(f"sonarr_quality_profile_id{suffix}", self.default_config.get("sonarr_quality_profile_id")) + root_folder_path = self.default_config.get(f"sonarr_root_folder_path{suffix}", self.default_config.get("sonarr_root_folder_path")) + series_type = self.default_config.get(f"sonarr_series_type{suffix}", self.default_config.get("sonarr_series_type", "standard")) + season_folder = self.default_config.get(f"sonarr_season_folder{suffix}", self.default_config.get("sonarr_season_folder", True)) + monitor = self.default_config.get(f"sonarr_monitor{suffix}", self.default_config.get("sonarr_monitor", "none")) + + try: + quality_profile_id_int = int(str(quality_profile_id or "0")) + except (TypeError, ValueError): + quality_profile_id_int = 0 + + return { + "label": str(instance_index if instance_index > 0 else "default"), + "base_url": self._normalize_base_url(base_url_value), + "api_key": api_key_value.strip(), + "quality_profile_id": quality_profile_id_int, + "root_folder_path": str(root_folder_path).strip() if root_folder_path is not None else "", + "series_type": str(series_type or "standard").strip(), + "season_folder": self._coerce_bool(season_folder, default=True), + "monitor": str(monitor or "none").strip(), + } + + def _iter_configured_instances(self) -> list[dict[str, Any]]: + instances: list[dict[str, Any]] = [] + for instance_index in range(4): + instance = self._get_instance_config(instance_index) + if instance: + instances.append(instance) + return instances + + async def _request_json( + self, + client: httpx.AsyncClient, + method: str, + instance: Mapping[str, Any], + path: str, + params: Optional[dict[str, Any]] = None, + body: Optional[dict[str, Any]] = None, + ) -> Any: + url = f"{instance['base_url']}{path}" + headers = { + "X-Api-Key": str(instance["api_key"]), + "Accept": "application/json", + "Content-Type": "application/json", + } + response = await client.request(method, url, headers=headers, params=params, json=body, timeout=20.0) + response.raise_for_status() + if not response.content: + return None + return response.json() + + async def _lookup_series_by_tvdb_id(self, client: httpx.AsyncClient, instance: Mapping[str, Any], tvdb_id: Optional[int]) -> Optional[dict[str, Any]]: + if not tvdb_id: + return None + data = await self._request_json(client, "GET", instance, "/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"}) + if isinstance(data, list): + items = cast(list[dict[str, Any]], data) + for item in items: + if str(item.get("tvdbId") or "") == str(tvdb_id): + return item + return items[0] if items else None + if isinstance(data, dict): + return cast(dict[str, Any], data) + return None + + async def _lookup_series_by_term(self, client: httpx.AsyncClient, instance: Mapping[str, Any], term: Optional[str]) -> Optional[dict[str, Any]]: + if not term: + return None + data = await self._request_json(client, "GET", instance, "/api/v3/series/lookup", params={"term": term}) + if isinstance(data, list): + items = cast(list[dict[str, Any]], data) + return items[0] if items else None + if isinstance(data, dict): + if isinstance(data.get("series"), dict): + return cast(dict[str, Any], data["series"]) + return cast(dict[str, Any], data) + return None + + async def _existing_series( + self, + client: httpx.AsyncClient, + instance: Mapping[str, Any], + tvdb_id: Optional[int], + imdb_id: Optional[int] = None, + tmdb_id: Optional[int] = None, + ) -> Optional[dict[str, Any]]: + data = await self._request_json(client, "GET", instance, "/api/v3/series") + if not isinstance(data, list): + return None + + normalized_imdb = self._normalize_imdb_id(imdb_id) + for item in cast(list[dict[str, Any]], data): + if tvdb_id and str(item.get("tvdbId") or "") == str(tvdb_id): + return item + if normalized_imdb and str(item.get("imdbId") or "").lower() == normalized_imdb.lower(): + return item + if tmdb_id and str(item.get("tmdbId") or "") == str(tmdb_id): + return item + return None + + @staticmethod + def _prepare_add_payload(series: Mapping[str, Any], instance: Mapping[str, Any]) -> dict[str, Any]: + payload = dict(series) + payload["qualityProfileId"] = instance["quality_profile_id"] + payload["rootFolderPath"] = instance["root_folder_path"] + payload["seriesType"] = instance["series_type"] + payload["seasonFolder"] = instance["season_folder"] + payload["monitored"] = False + if isinstance(payload.get("seasons"), list): + payload["seasons"] = [ + {**cast(dict[str, Any], season), "monitored": False} + for season in payload["seasons"] + if isinstance(season, dict) + ] + payload["addOptions"] = { + "monitor": instance["monitor"], + "searchForMissingEpisodes": False, + } + return payload + + async def existing_series_by_lookup_term(self, term: Optional[str], debug: bool = False) -> SonarrAddResult: + if not term: + return {"status": "skipped", "detail": "no Sonarr lookup term was available"} + + instances = self._iter_configured_instances() + if not instances: + return {"status": "failed", "detail": "No Sonarr API keys are configured."} + + last_error = "" + async with httpx.AsyncClient() as client: + for instance in instances: + label = instance["label"] + try: + series = await self._lookup_series_by_term(client, instance, term) + if not series: + last_error = f"Sonarr instance {label} did not find a lookup candidate for {term}." + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + existing = await self._existing_series( + client, + instance, + self._coerce_optional_int(series.get("tvdbId")), + self._coerce_optional_int(series.get("imdbId")), + self._coerce_optional_int(series.get("tmdbId")), + ) + if existing: + return { + "status": "exists", + "detail": f"already in Sonarr: {self._series_label(existing)}", + "series": existing, + } + except httpx.HTTPStatusError as e: + response_text = e.response.text.strip() + last_error = f"Sonarr instance {label} HTTP {e.response.status_code}: {response_text or e.response.reason_phrase}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + except httpx.RequestError as e: + last_error = f"Sonarr instance {label} request failed: {e}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + + return {"status": "missing", "detail": last_error or f"not found in Sonarr: {term}"} + + async def add_series_by_ids( + self, + tvdb_id: Optional[int] = None, + imdb_id: Optional[int] = None, + tmdb_id: Optional[int] = None, + debug: bool = False, + ) -> SonarrAddResult: + if not tvdb_id and not imdb_id and not tmdb_id: + return {"status": "skipped", "detail": "no TVDb ID, IMDb ID, or TMDb ID was available"} + + instances = self._iter_configured_instances() + if not instances: + return {"status": "failed", "detail": "No Sonarr API keys are configured."} + + last_error = "" + async with httpx.AsyncClient() as client: + for instance in instances: + label = instance["label"] + if not instance["quality_profile_id"] or not instance["root_folder_path"]: + last_error = ( + f"Sonarr instance {label} is missing sonarr_quality_profile_id " + "or sonarr_root_folder_path." + ) + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + try: + existing = await self._existing_series(client, instance, tvdb_id, imdb_id, tmdb_id) + if existing: + return { + "status": "exists", + "detail": f"already in Sonarr: {self._series_label(existing)}", + "series": existing, + } + + series = await self._lookup_series_by_tvdb_id(client, instance, tvdb_id) + if not series: + last_error = f"Sonarr instance {label} did not find a series for TVDb={tvdb_id} IMDb={imdb_id} TMDb={tmdb_id}." + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + continue + + payload = self._prepare_add_payload(series, instance) + added = await self._request_json(client, "POST", instance, "/api/v3/series", body=payload) + if isinstance(added, dict): + return { + "status": "added", + "detail": f"added to Sonarr: {self._series_label(cast(dict[str, Any], added))}", + "series": added, + } + return {"status": "added", "detail": "added to Sonarr"} + except httpx.HTTPStatusError as e: + response_text = e.response.text.strip() + last_error = f"Sonarr instance {label} HTTP {e.response.status_code}: {response_text or e.response.reason_phrase}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + except httpx.RequestError as e: + last_error = f"Sonarr instance {label} request failed: {e}" + if debug: + console.print(f"[yellow]{last_error}[/yellow]") + + return {"status": "failed", "detail": last_error or "Sonarr add failed."} + async def get_sonarr_data( self, tvdb_id: Optional[int] = None, @@ -163,5 +452,3 @@ async def extract_show_data(self, sonarr_data: Any) -> ShowInfo: "year": None, "release_group": None } - - diff --git a/src/torrent_clients/qbittorrent.py b/src/torrent_clients/qbittorrent.py index b05f3d3f8..a9e83f74f 100644 --- a/src/torrent_clients/qbittorrent.py +++ b/src/torrent_clients/qbittorrent.py @@ -1254,9 +1254,13 @@ async def _search_single_qbit_client(self, client_config: dict[str, Any], _conte 'yus': {"url": "https://yu-scene.net", "pattern": r'/(\d+)$'}, 'dp': {"url": "https://darkpeers.org", "pattern": r'/(\d+)$'}, 'sp': {"url": "https://seedpool.org", "pattern": r'/(\d+)$'}, + 'lume': {"url": "https://luminarr.me", "pattern": r'/(\d+)$'}, + 'hhd': {"url": "https://homiehelpdesk.net", "pattern": r'/(\d+)$'}, + 'rmc': {"url": "https://retro-movies.club", "pattern": r'/(\d+)$'}, + 'ras': {"url": "https://rastastugan.org/", "pattern": r'/torrent/(\w+)$'}, } - tracker_priority = ['aither', 'ulcx', 'lst', 'blu', 'oe', 'btn', 'bhd', 'huno', 'hdb', 'rf', 'otw', 'yus', 'dp', 'sp', 'ptp'] + tracker_priority = ['aither', 'ulcx', 'lst', 'blu', 'oe', 'btn', 'bhd', 'huno', 'hdb', 'rf', 'otw', 'yus', 'dp', 'sp', 'ras', 'lume', 'hhd', 'rmc', 'ptp'] if proxy_url: try: diff --git a/src/trackermeta.py b/src/trackermeta.py index f2d9cd3f8..b7fc32a10 100644 --- a/src/trackermeta.py +++ b/src/trackermeta.py @@ -406,8 +406,7 @@ async def update_metadata_from_tracker( imdb_id, meta['ext_torrenthash'] = cast(tuple[int, Optional[str]], ptp_imdb_result) if imdb_id: meta['imdb_id'] = imdb_id - if meta['debug']: - console.print(f"[green]IMDb ID found: tt{str(meta['imdb_id']).zfill(7)}[/green]") + console.print(f"[green]PTP IMDb ID found: tt{str(meta['imdb_id']).zfill(7)}[/green]") found_match = True meta['skipit'] = True if not only_id or meta.get('keep_images'): @@ -589,7 +588,7 @@ async def update_metadata_from_tracker( console.print(f"[yellow]{tracker_name} returned invalid IDs (both 0)[/yellow]") found_match = False - elif tracker_name in ["HUNO", "BLU", "AITHER", "LST", "OE", "ULCX", "RF", "OTW", "YUS", "DP", "SP"]: + elif tracker_name in ["HUNO", "BLU", "AITHER", "LST", "OE", "ULCX", "RF", "OTW", "YUS", "DP", "SP", "RAS", "LUME", "HHD", "RMC"]: if meta.get(tracker_key) is not None: if meta['debug']: console.print(f"[cyan]{tracker_name} ID found in meta, reusing existing ID: {meta[tracker_key]}[/cyan]") @@ -633,7 +632,8 @@ async def update_metadata_from_tracker( bbcode = BBCODE() if meta.get('hdb') is not None: meta[manual_key] = meta[tracker_key] - console.print(f"[cyan]{tracker_name} ID found in meta, reusing existing ID: {meta[tracker_key]}[/cyan]") + if meta.get('debug'): + console.print(f"[cyan]{tracker_name} ID found in meta, reusing existing ID: {meta[tracker_key]}[/cyan]") # Use get_info_from_torrent_id function if ID is found in meta hdb_info = await tracker_instance.get_info_from_torrent_id(meta[tracker_key]) @@ -653,11 +653,13 @@ async def update_metadata_from_tracker( bbcode.clean_hdb_description(description_source), ) if description and len(description) > 0 and not only_id: - console.print(f"Description content:\n{description[:500]}...", markup=False) + if meta.get('debug'): + console.print(f"Description content:\n{description[:500]}...", markup=False) meta['description'] = description meta['saved_description'] = True else: - console.print("[yellow]HDB description empty[/yellow]") + if meta.get('debug'): + console.print("[yellow]HDB description empty[/yellow]") if image_list and meta.get('keep_images'): valid_images = await check_images_concurrently(image_list, meta) if valid_images: diff --git a/src/trackers/COMMON.py b/src/trackers/COMMON.py index b85fe0563..a8f85c16d 100644 --- a/src/trackers/COMMON.py +++ b/src/trackers/COMMON.py @@ -735,6 +735,9 @@ async def unit3d_torrent_info( if not meta.get('keep_images'): imagelist = [] + if (tmdb or imdb or tvdb or mal): + console.print(f"[green]Found {tracker} IDs: TMDb: {tmdb}, IMDb: {imdb}, TVDb: {tvdb}, MAL: {mal}[/green]") + return tmdb, imdb, tvdb, mal, description, category, infohash, imagelist, file_name except Exception as e: diff --git a/src/trackers/RMC.py b/src/trackers/RMC.py new file mode 100644 index 000000000..b07a5b57c --- /dev/null +++ b/src/trackers/RMC.py @@ -0,0 +1,177 @@ +# Upload Assistant © 2025 Audionut & wastaken7 — Licensed under UAPL v1.0 +import re +from typing import Any, Optional + +from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + +Meta = dict[str, Any] +Config = dict[str, Any] + + +class RMC(UNIT3D): + def __init__(self, config: Config) -> None: + super().__init__(config, tracker_name='RMC') + self.config = config + self.common = COMMON(config) + self.tracker = 'RMC' + self.base_url = 'https://retro-movies.club' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '[Oj]', '3LTON', '4yEo', 'ADE', 'AFG', 'AniHLS', 'AnimeRG', 'AniURL', 'AROMA', 'aXXo', 'CM8', + 'CrEwSaDe', 'DeadFish', 'DNL', 'ELiTE', 'eSc', 'FaNGDiNG0', 'FGT', 'Flights', 'FRDS', 'FUM', + 'GalaxyRG', 'HAiKU', 'HDS', 'HDTime', 'INFINITY', 'ION10', 'iPlanet', 'JIVE', 'KiNGDOM', 'LAMA', + 'Leffe', 'LOAD', 'mHD', 'nHD', 'NOIVTC', 'nSD', 'PiRaTeS', 'RARBG', 'RDN', 'REsuRRecTioN', + 'RMTeam', 'SANTi', 'SicFoI', 'SPASM', 'STUTTERSHIT', 'Telly', 'TM', 'UPiNSMOKE', 'WAF', 'xRed', + 'XS', 'YELLO', 'YIFY', 'YTS', 'ZKBL', 'ZmN' + ] + + async def get_category_id( + self, + meta: Meta, + category: Optional[str] = None, + reverse: bool = False, + mapping_only: bool = False, + ) -> dict[str, str]: + _ = (category, reverse, mapping_only) + category_id = { + 'MOVIE': '1', + }.get(meta['category'], '0') + return {'category_id': category_id} + + async def get_type_id( + self, + meta: Meta, + type: Optional[str] = None, + reverse: bool = False, + mapping_only: bool = False, + ) -> dict[str, str]: + if mapping_only: + return { + 'BDMV': '1', + 'REMUX_BLURAY': '2', + 'DVD': '3', + 'REMUX_DVD': '4', + 'ENCODE': '5', + 'DVDRIP': '6', + 'WEBDL': '7', + 'WEBRIP': '8', + 'UHDTV': '9', + 'HDTV': '10', + 'TV_SD': '11', + } + if reverse: + return { + '1': 'BDMV', + '2': 'REMUX_BLURAY', + '3': 'DVD', + '4': 'REMUX_DVD', + '5': 'ENCODE', + '6': 'DVDRIP', + '7': 'WEBDL', + '8': 'WEBRIP', + '9': 'UHDTV', + '10': 'HDTV', + '11': 'TV_SD', + } + + source = str(meta.get('source', '')).upper() + is_disc = str(meta.get('is_disc', '')).upper() + category = str(meta.get('category', '')).upper() + sd = int(meta.get('sd') or 0) + type_value = str(type) if type is not None else str(meta.get('type', '')) + type_upper = type_value.upper() + + if is_disc == 'BDMV': + return {'type_id': '1'} + if type_upper == 'REMUX' and source in {'BLURAY', 'BLU-RAY'}: + return {'type_id': '2'} + if is_disc == 'DVD': + return {'type_id': '3'} + if type_upper == 'REMUX' and source in {'DVD', 'PAL DVD', 'NTSC DVD'}: + return {'type_id': '4'} + if type_upper == 'ENCODE': + return {'type_id': '5'} + if type_upper == 'DVDRIP': + return {'type_id': '6'} + if type_upper == 'WEBDL': + return {'type_id': '7'} + if type_upper == 'WEBRIP' or source == 'WEB': + return {'type_id': '8'} + if source == 'UHDTV': + return {'type_id': '9'} + if type_upper == 'HDTV': + return {'type_id': '10'} + if category == 'TV' and sd == 1: + return {'type_id': '11'} + + return {'type_id': '0'} + + async def get_resolution_id( + self, + meta: Meta, + resolution: Optional[str] = None, + reverse: bool = False, + mapping_only: bool = False, + ) -> dict[str, str]: + _ = (resolution, reverse, mapping_only) + resolution_id = { + '4320p': '1', + '2160p': '2', + '1440p': '3', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9' + }.get(meta['resolution'], '11') + return {'resolution_id': resolution_id} + + async def get_additional_checks(self, meta: Meta) -> bool: + should_continue = True + + if meta.get('category') != "MOVIE": + if not meta.get('unattended'): + console.print(f"Only movies are allowed on {self.tracker}.") + meta['skipping'] = self.tracker + return False + + parsed_year = None + if meta.get('year'): + try: + parsed_year = int(meta['year']) + except ValueError: + parsed_year = None + + if parsed_year is not None and parsed_year > 2000: + if not meta.get('unattended'): + console.print(f"{self.tracker} only allows movies released in 2000 or earlier.") + meta['skipping'] = self.tracker + return False + + return should_continue + + async def get_additional_data(self, meta: Meta) -> dict[str, Any]: + data = { + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + } + + return data + + async def get_name(self, meta: Meta) -> dict[str, str]: + rmc_name = str(meta.get('name', '')) + aka = str(meta.get('aka', '')).strip() + + if aka: + rmc_name = rmc_name.replace(f" {aka} ", " ") + + rmc_name = re.sub(r'[^A-Za-z0-9 ._-]+', '', rmc_name) + rmc_name = re.sub(r'\s+', ' ', rmc_name).strip() + + return {'name': rmc_name} diff --git a/src/trackersetup.py b/src/trackersetup.py index ae2f13493..8a55973ec 100644 --- a/src/trackersetup.py +++ b/src/trackersetup.py @@ -64,6 +64,7 @@ from src.trackers.R4E import R4E from src.trackers.RAS import RAS from src.trackers.RF import RF +from src.trackers.RMC import RMC from src.trackers.RTF import RTF from src.trackers.SAM import SAM from src.trackers.SHRI import SHRI @@ -1340,13 +1341,13 @@ async def make_trumpable_report(self, meta: Meta, tracker: str) -> bool: 'A4K': A4K, 'ACM': ACM, 'AITHER': AITHER, 'ANT': ANT, 'AR': AR, 'ASC': ASC, 'AZ': AZ, 'BHD': BHD, 'BHDTV': BHDTV, 'BJS': BJS, 'BLU': BLU, 'BT': BT, 'CBR': CBR, 'CZ': CZ, 'DC': DC, 'DP': DP, 'DT': DT, 'EMUW': EMUW, 'FNP': FNP, 'FF': FF, 'FL': FL, 'FRIKI': FRIKI, 'GPW': GPW, 'HDB': HDB, 'HDS': HDS, 'HDT': HDT, 'HHD': HHD, 'HUNO': HUNO, 'ITT': ITT, 'IHD': IHD, 'IS': IS, 'LCD': LCD, 'LDU': LDU, 'LST': LST, 'LT': LT, 'LUME': LUME, 'MTV': MTV, 'NBL': NBL, 'OE': OE, 'OTW': OTW, 'PHD': PHD, 'PT': PT, 'PTP': PTP, 'PTER': PTER, 'PTS': PTS, 'PTT': PTT, - 'R4E': R4E, 'RAS': RAS, 'RF': RF, 'RTF': RTF, 'SAM': SAM, 'SHRI': SHRI, 'SN': SN, 'SP': SP, 'SPD': SPD, 'STC': STC, 'THR': THR, + 'R4E': R4E, 'RAS': RAS, 'RF': RF, 'RMC': RMC, 'RTF': RTF, 'SAM': SAM, 'SHRI': SHRI, 'SN': SN, 'SP': SP, 'SPD': SPD, 'STC': STC, 'THR': THR, 'TIK': TIK, 'TL': TL, 'TLZ': TLZ, 'TOS': TOS, 'TVC': TVC, 'TTG': TTG, 'TTR': TTR, 'ULCX': ULCX, 'UTP': UTP, 'YOINK': YOINK, 'YUS': YUS } api_trackers = { 'A4K', 'ACM', 'AITHER', 'BHD', 'BLU', 'CBR', 'DP', 'DT', 'EMUW', 'FNP', 'FRIKI', 'HHD', 'HUNO', 'IHD', 'ITT', 'LCD', 'LDU', 'LST', 'LT', 'LUME', - 'OE', 'OTW', 'PT', 'PTT', 'RAS', 'RF', 'R4E', 'SAM', 'SHRI', 'SP', 'STC', 'TIK', 'TLZ', 'TOS', 'TTR', 'ULCX', 'UTP', 'YOINK', 'YUS' + 'OE', 'OTW', 'PT', 'PTT', 'RAS', 'RF', 'R4E', 'RMC', 'SAM', 'SHRI', 'SP', 'STC', 'TIK', 'TLZ', 'TOS', 'TTR', 'ULCX', 'UTP', 'YOINK', 'YUS' } other_api_trackers = { diff --git a/upload.py b/upload.py index a6cc273ae..528c6c039 100644 --- a/upload.py +++ b/upload.py @@ -31,6 +31,22 @@ from discordbot import DiscordNotifier from src.add_comparison import ComparisonManager from src.args import Args +from src.arr_add import ( + load_radarr_add_seen_keys, + load_sonarr_add_seen_keys, + process_radarr_add, + process_sonarr_add, +) +from src.arr_add import ( + radarr_add_seen_key_file as get_radarr_add_seen_key_file, + radarr_add_unable_log_file as get_radarr_add_unable_log_file, +) +from src.arr_add import ( + sonarr_add_seen_key_file as get_sonarr_add_seen_key_file, +) +from src.arr_add import ( + sonarr_add_unable_log_file as get_sonarr_add_unable_log_file, +) from src.cleanup import cleanup_manager from src.clients import Clients from src.console import console @@ -1521,6 +1537,12 @@ def ensure_secure_tmp_subdir(subdir_path: str) -> None: processed_files_count = 0 skipped_files_count = 0 base_meta = dict(meta.items()) + radarr_add_seen_key_file = get_radarr_add_seen_key_file(base_dir, meta) + radarr_add_seen_title_years = await load_radarr_add_seen_keys(radarr_add_seen_key_file) if meta.get('radarr_add', False) else set() + radarr_add_unable_log_file = get_radarr_add_unable_log_file(base_dir, meta) + sonarr_add_seen_key_file = get_sonarr_add_seen_key_file(base_dir, meta) + sonarr_add_seen_keys = await load_sonarr_add_seen_keys(sonarr_add_seen_key_file) if meta.get('sonarr_add', False) else set() + sonarr_add_unable_log_file = get_sonarr_add_unable_log_file(base_dir, meta) for queue_item in queue_list: total_files = len(queue_list) @@ -1591,6 +1613,36 @@ def ensure_secure_tmp_subdir(subdir_path: str) -> None: console.print(f"[red]Exception: '{path}': {e}") cleanup_manager.reset_terminal() + if meta.get('radarr_add', False): + processed_files_count += 1 + radarr_completed = await process_radarr_add(meta, base_dir, radarr_add_seen_title_years, radarr_add_seen_key_file, radarr_add_unable_log_file, config, name_manager) + if radarr_completed and log_file and (not meta['debug'] or "debug" in os.path.basename(log_file)): + await save_processed_file(log_file, current_item_path) + console.print(f"[cyan]Processed {processed_files_count}/{total_files} files for Radarr add.") + limit_queue_value = int(meta.get('limit_queue', 0) or 0) + reached_limit = limit_queue_value > 0 and processed_files_count >= limit_queue_value + await cleanup_manager.cleanup() + gc.collect() + cleanup_manager.reset_terminal() + if reached_limit: + break + continue + + if meta.get('sonarr_add', False): + processed_files_count += 1 + sonarr_completed = await process_sonarr_add(meta, base_dir, sonarr_add_seen_keys, sonarr_add_seen_key_file, sonarr_add_unable_log_file, config, name_manager) + if sonarr_completed and log_file and (not meta['debug'] or "debug" in os.path.basename(log_file)): + await save_processed_file(log_file, current_item_path) + console.print(f"[cyan]Processed {processed_files_count}/{total_files} files for Sonarr add.") + limit_queue_value = int(meta.get('limit_queue', 0) or 0) + reached_limit = limit_queue_value > 0 and processed_files_count >= limit_queue_value + await cleanup_manager.cleanup() + gc.collect() + cleanup_manager.reset_terminal() + if reached_limit: + break + continue + discord_bot_token = discord_config.get('discord_bot_token') if discord_config is not None else None only_unattended = bool(discord_config.get('only_unattended', False)) if discord_config is not None else False diff --git a/web_ui/static/js/config_app.js b/web_ui/static/js/config_app.js index 69f4d4f6e..7e95bb6b3 100644 --- a/web_ui/static/js/config_app.js +++ b/web_ui/static/js/config_app.js @@ -1156,8 +1156,8 @@ function ItemList({ 'multi-file/disc': ['multiScreens', 'pack_thumb_size', 'fileLimit', 'processLimit', 'charLimit'], 'Headers': ['custom_description_header', 'tonemapped_header', 'screenshot_header'], 'Signature': ['custom_signature'], - 'Sonarr': ['use_sonarr', 'sonarr_url', 'sonarr_api_key', 'sonarr_url_1', 'sonarr_api_key_1'], - 'Radarr': ['use_radarr', 'radarr_url', 'radarr_api_key', 'radarr_url_1', 'radarr_api_key_1'], + 'Sonarr': ['use_sonarr', 'sonarr_url', 'sonarr_api_key', 'sonarr_quality_profile_id', 'sonarr_root_folder_path', 'sonarr_series_type', 'sonarr_season_folder', 'sonarr_monitor', 'sonarr_url_1', 'sonarr_api_key_1'], + 'Radarr': ['use_radarr', 'radarr_url', 'radarr_api_key', 'radarr_quality_profile_id', 'radarr_root_folder_path', 'radarr_minimum_availability', 'radarr_url_1', 'radarr_api_key_1'], }; // Partition regularItems into subgroups and an "Other" bucket