Ctrl K
ComunidadGuías…

search-missing.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Weekly "search for anything missing" across Sonarr and Radarr.

Sonarr and Radarr search for an item once, when it is added, then only watch RSS. A request whose
first search came up empty (indexer down that day) or whose download was reaped therefore sits
"processing" in Jellyseerr indefinitely; this re-searches weekly.

  Sonarr: queues MissingEpisodeSearch with monitored=true, the same command as "Search All
    Missing". Its missing list holds only AIRED episodes (unaired-guard.py reaps a grab for an
    unaired one that slips through).
  Radarr: deliberately NOT MissingMoviesSearch. It picks the movies itself (monitored, no file,
    RELEASED, not already queued) and queues MoviesSearch for those. "Released" is stricter than
    Radarr's: status "released" AND isAvailable AND a digital or physical release date that has
    passed. isAvailable alone follows minimumAvailability (an "In Cinemas" movie is available at
    the theatrical date), and both flags fall back to "90 days after cinemas" when no home
    release date is known; for films still in cinemas the quality profile approves pre-release
    junk.

Fire and forget: both apps run the searches themselves, so this reports only what it queued.
Weekly via ops/cron.d/media-stack.crontab.

Exit codes:
  0  every search that was due got queued (including "nothing to search")
  1  a key was missing, an app unreachable, or a list unreadable, so something that should have
     been searched may not have been
"""

from __future__ import annotations

import argparse
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import TypeGuard

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_stack.clients.arr import ArrClient  # noqa: E402
from media_stack.paths import (  # noqa: E402
    MEDIA_STACK_ROOT, RADARR_URL, SONARR_URL, load_env_file,
)


def log(msg: str) -> None:
    print(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {msg}", flush=True)


def is_real_int(value: object) -> TypeGuard[int]:
    return isinstance(value, int) and not isinstance(value, bool)


def _passed(value: object, now: datetime) -> bool:
    """Radarr sends a literal Z suffix, which Python 3.10's fromisoformat rejects."""
    if not isinstance(value, str) or not value:
        return False
    try:
        when = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return False
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    return when <= now


def is_released(movie: dict, now: datetime | None = None) -> bool:
    """Out on home release, whatever minimumAvailability (all three conditions, see the module
    docstring). Anything missing, malformed or in the future counts as NOT released: fail toward
    searching nothing."""
    now = now or datetime.now(timezone.utc)
    return (movie.get("status") == "released"
            and movie.get("isAvailable") is True
            and (_passed(movie.get("digitalRelease"), now)
                 or _passed(movie.get("physicalRelease"), now)))


def pick_movies(movies: list[dict], queue: list[dict]) -> list[int]:
    """Each flag must be literally True/False; anything else excludes the movie."""
    downloading = {r["movieId"] for r in queue if is_real_int(r.get("movieId"))}
    return sorted(
        m["id"] for m in movies
        if is_real_int(m.get("id"))
        and m.get("monitored") is True
        and m.get("hasFile") is False
        and is_released(m)
        and m["id"] not in downloading
    )


def search_tv(env: dict[str, str], dry_run: bool) -> bool:
    key = env.get("SONARR_API_KEY", "")
    if not key:
        log("ERROR: SONARR_API_KEY is not set; TV not searched")
        return False
    if dry_run:
        log("DRY RUN would queue Sonarr MissingEpisodeSearch (monitored)")
        return True
    cid = ArrClient(SONARR_URL, key).run_command("MissingEpisodeSearch", monitored=True)
    if cid is None:
        log("ERROR: Sonarr did not accept MissingEpisodeSearch (unreachable or refused)")
        return False
    log(f"Sonarr: queued MissingEpisodeSearch (monitored), command {cid}")
    return True


def search_movies(env: dict[str, str], dry_run: bool) -> bool:
    key = env.get("RADARR_API_KEY", "")
    if not key:
        log("ERROR: RADARR_API_KEY is not set; movies not searched")
        return False
    radarr = ArrClient(RADARR_URL, key)
    movies = radarr.movies()
    if movies is None:
        log("ERROR: Radarr movie list unreadable; movies not searched")
        return False
    try:
        queue = radarr.get_queue()
    except Exception as exc:
        # without the queue a downloading movie would be searched again
        log(f"ERROR: Radarr queue unreadable ({type(exc).__name__}); movies not searched")
        return False

    ids = pick_movies(movies, queue)
    unreleased = sum(1 for m in movies if m.get("monitored") is True
                     and m.get("hasFile") is False and not is_released(m))
    # a null title must not crash the log line
    titles = {m["id"]: str(m.get("title") or "?") for m in movies if is_real_int(m.get("id"))}
    names = ", ".join(titles[i] for i in ids[:15]) + (" ..." if len(ids) > 15 else "")
    if not ids:
        log(f"Radarr: nothing released is missing ({unreleased} not yet released, skipped)")
        return True
    if dry_run:
        log(f"DRY RUN would search {len(ids)} movie(s): {names}")
        return True
    cid = radarr.run_command("MoviesSearch", movieIds=ids)
    if cid is None:
        log(f"ERROR: Radarr did not accept MoviesSearch for {len(ids)} movie(s)")
        return False
    log(f"Radarr: queued MoviesSearch for {len(ids)} movie(s), command {cid} "
        f"({unreleased} not yet released, skipped): {names}")
    return True


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dry-run", action="store_true",
                    help="report what would be searched; queue nothing")
    args = ap.parse_args()

    env = {**load_env_file(MEDIA_STACK_ROOT / ".env"), **os.environ}
    # Both run regardless: a Sonarr outage must not also skip the movie search.
    tv_ok = search_tv(env, args.dry_run)
    movies_ok = search_movies(env, args.dry_run)
    return 0 if tv_ok and movies_ok else 1


if __name__ == "__main__":
    sys.exit(main())