Skill: seriallazer/moviefetch (version 0.1.0)

--- name: moviefetch description: > Download, track, and remove movies across Plex, Radarr, and qBittorrent from chat. Triggers on user requests like "get <movie>", "status of <movie>", or "remove <movie>". --- # MovieFetch Chat-driven movie downloading for your Plex stack. ## Features 1. **Search & Request:** Looks up movies in Radarr and adds them to the download queue. 2. **Download Status:** Fetches live progress and ETA from qBittorrent. 3. **Library Check:** Verifies whether a movie is already in Plex before requesting it. 4. **Cleanup:** Removes movies from the Radarr library and optionally deletes files. ## Workflow 1. **Request:** - Call `scripts/check_plex.py` first. - If not found → call `scripts/request_movie.py`. 2. **Status:** - Call `scripts/check_download.py` for live % and ETA. 3. **Remove:** - Call `scripts/remove_movie.py`. ## Tools | Tool | File | Purpose | |----------------|--------------------------|--------------------------------------| | check_plex | scripts/check_plex.py | Is it already in Plex? | | request_movie | scripts/request_movie.py | Add to Radarr search queue | | check_download | scripts/check_download.py | Live qBit progress & ETA | | remove_movie | scripts/remove_movie.py | Delete from queue and library |

path: README.md
content: # MovieFetch

MovieFetch turns chat into a remote control for your movie pipeline.

Ask for a film, and MovieFetch can download it for you. It checks whether it is already in Plex, sends new requests to Radarr, lets qBittorrent handle the transfer, and gives you status updates without making you tab through three different dashboards.

## The Pitch

MovieFetch is built for the common home-media loop:

- You remember a movie you want to watch.
- You ask for it in chat.
- MovieFetch starts the download flow for you.
- The system checks Plex first so you do not request duplicates.
- Radarr queues the movie.
- qBittorrent downloads it.
- Plex becomes the final destination.

It is not a giant media automation suite. It is a focused skill that makes your existing stack feel conversational.

## What It Can Do

### 1. Check Before You Queue

Before adding anything, MovieFetch can search your Plex movie library and tell you whether the title is already available.

Example prompts:
- `Do I already have Dune Part Two?`
- `Check if Spider-Man: Across the Spider-Verse is in Plex`

### 2. Request a Movie From Chat

MovieFetch can take a plain-language movie request and start downloading it through your Radarr and qBittorrent pipeline.

Example prompts:
- `Get The Nice Guys`
- `Download Arrival`
- `Add The Dark Knight to Plex`

### 3. Track Live Download Progress

Once a movie is in flight, MovieFetch can query qBittorrent and return a human-readable progress snapshot with state, ETA, and size.

Example prompts:
- `Status of Oppenheimer`
- `How far along is Blade Runner 2049?`
- `Check download progress for Interstellar`

### 4. Remove a Movie Cleanly

If you want to cancel, clean up, or make space, MovieFetch can remove a title from Radarr and optionally delete the files too.

Example prompts:
- `Remove Joker`
- `Delete Tenet and remove the files`
- `Cancel The Batman`

## How It Feels In Use

```mermaid
sequenceDiagram
    participant User
    participant MovieFetch
    participant Plex
    participant Radarr
    participant qBittorrent

    User->>MovieFetch: "Get Inception"
    MovieFetch->>Plex: Search library
    Plex-->>MovieFetch: Not found
    MovieFetch->>Radarr: Add movie and start monitoring
    Radarr->>qBittorrent: Queue download
    MovieFetch-->>User: "Inception was added to the queue"

    User->>MovieFetch: "Status of Inception"
    MovieFetch->>qBittorrent: Fetch torrent info
    qBittorrent-->>MovieFetch: 82% complete, ETA 6m
    MovieFetch-->>User: "Downloading, 82%, ETA 6m"
```

## Tooling Model

MovieFetch currently exposes four focused tools:

| Tool | Purpose |
|---|---|
| `check_plex` | Check whether a movie already exists in Plex |
| `request_movie` | Add a movie to Radarr and kick off the movie download flow |
| `check_download` | Read live qBittorrent progress for a requested movie |
| `remove_movie` | Remove a movie from Radarr and optionally delete files |

## Setup

### Requirements

- Radarr with API access
- qBittorrent Web UI enabled
- Plex server access token

### Environment Variables

Configure these in your OpenClaw environment or shell:

| Variable | Description | Default |
|---|---|---|
| `RADARR_URL` | Base URL of the Radarr API | `http://localhost:7878/api/v3` |
| `RADARR_API_KEY` | Radarr API key | Required |
| `RADARR_QUALITY_PROFILE_ID` | Quality profile used for new requests | `1` |
| `RADARR_ROOT_FOLDER` | Root folder path for imported movies | `/tmp/plex_movies/` |
| `PLEX_URL` | Plex server URL | `http://localhost:32400` |
| `PLEX_TOKEN` | Plex token | Required |
| `PLEX_MOVIE_LIBRARY` | Plex movie library name | `Movies` |
| `QBIT_URL` | qBittorrent Web UI URL | `http://localhost:8080` |
| `QBIT_USERNAME` | qBittorrent username | `admin` |
| `QBIT_PASSWORD` | qBittorrent password | Required in most setups |

## Why It Plays Well On ClawHub

- Clear job to be done: download, track, and remove movies
- Tight integration with a stack many self-hosters already run
- Simple natural-language prompts with immediate utility
- Focused surface area instead of a bloated “do everything” media assistant

## Publish Notes

If you publish this on ClawHub, use a listing description close to:

> Request, track, and remove movies across Plex, Radarr, and qBittorrent from chat.
>
> Or more directly:
>
> Download, track, and remove movies across Plex, Radarr, and qBittorrent from chat.


path: scripts/check_download.py
content: #!/usr/bin/env python3
"""
homeflix / check_download
Queries qBittorrent Web UI for download progress of a movie.
Uses fuzzy title matching since torrent names include year/quality tags.
"""
import json
import os
import sys
import requests

QBIT_URL      = os.environ.get("QBIT_URL", "http://localhost:8080")
QBIT_USERNAME = os.environ.get("QBIT_USERNAME", "admin")
QBIT_PASSWORD = os.environ.get("QBIT_PASSWORD", "")

# qBittorrent state → human label
STATE_MAP = {
    "downloading":      "Downloading",
    "stalledDL":        "Stalled (no seeds)",
    "uploading":        "Seeding",
    "stalledUP":        "Seeding (idle)",
    "pausedDL":         "Paused",
    "pausedUP":         "Complete (paused)",
    "queuedDL":         "Queued",
    "checkingDL":       "Checking",
    "missingFiles":     "Missing files",
    "error":            "Error",
    "moving":           "Moving to Plex",
}


def login(session: requests.Session):
    resp = session.post(
        f"{QBIT_URL}/api/v2/auth/login",
        data={"username": QBIT_USERNAME, "password": QBIT_PASSWORD},
        timeout=5,
    )
    if resp.text.strip() != "Ok.":
        raise RuntimeError(f"qBittorrent login failed: {resp.text}")


def get_torrents(session: requests.Session):
    resp = session.get(f"{QBIT_URL}/api/v2/torrents/info", timeout=5)
    resp.raise_for_status()
    return resp.json()


def fuzzy_match(title: str, torrents: list):
    """Return the best matching torrent by word overlap (case-insensitive)."""
    title_words = set(title.lower().split())
    best, best_score = None, 0
    for t in torrents:
        torrent_words = set(t["name"].lower().replace(".", " ").split())
        score = len(title_words & torrent_words)
        if score > best_score:
            best, best_score = t, score
    # Require at least half the query words to match
    if best_score >= max(1, len(title_words) // 2):
        return best
    return None


def format_eta(seconds: int) -> str:
    if seconds < 0 or seconds > 86400 * 7:
        return "unknown"
    h, rem = divmod(seconds, 3600)
    m, s   = divmod(rem, 60)
    if h:
        return f"{h}h {m}m"
    if m:
        return f"{m}m {s}s"
    return f"{s}s"


def main():
    try:
        input_data = json.loads(sys.argv[1])
        title = input_data.get("movie_title", "").strip()
        if not title:
            print(json.dumps({"error": "movie_title is required"}))
            return

        session = requests.Session()
        login(session)
        torrents = get_torrents(session)

        match = fuzzy_match(title, torrents)
        if not match:
            print(json.dumps({"found": False, "message": f"No active torrent found for '{title}'"}))
            return

        state_label = STATE_MAP.get(match["state"], match["state"])
        progress    = round(match["progress"] * 100, 1)

        print(json.dumps({
            "found":    True,
            "name":     match["name"],
            "state":    state_label,
            "progress": progress,
            "eta":      format_eta(match.get("eta", -1)),
            "size_gb":  round(match["size"] / 1e9, 2),
        }))

    except Exception as e:
        print(json.dumps({"error": str(e)}))


if __name__ == "__main__":
    main()


path: scripts/check_ott.py
content: #!/usr/bin/env python3
import json
import os
import sys
import requests

TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
TMDB_URL = "https://api.themoviedb.org/3"

def search_tmdb(query: str, media_type: str = "movie"):
    """Search for a movie or TV series on TMDB."""
    url = f"{TMDB_URL}/search/{media_type}"
    params = {"api_key": TMDB_API_KEY, "query": query}
    resp = requests.get(url, params=params, timeout=10)
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0] if results else None

def get_watch_providers(tmdb_id: int, media_type: str = "movie"):
    """Get OTT providers for a movie/TV series."""
    url = f"{TMDB_URL}/{media_type}/{tmdb_id}/watch/providers"
    params = {"api_key": TMDB_API_KEY}
    resp = requests.get(url, params=params, timeout=10)
    resp.raise_for_status()
    # Looking for India (IN) by default as per user's timezone
    results = resp.json().get("results", {})
    return results.get("IN", {})

def main():
    try:
        if len(sys.argv) < 2:
            print(json.dumps({"error": "Usage: check_ott.py <query> [movie|tv]"}))
            return
            
        query = sys.argv[1]
        media_type = sys.argv[2] if len(sys.argv) > 2 else "movie"
        
        # Try both if media_type not specified
        result = search_tmdb(query, media_type)
        if not result and len(sys.argv) <= 2:
            media_type = "tv"
            result = search_tmdb(query, media_type)
            
        if not result:
            print(json.dumps({"error": f"No results found for '{query}'"}))
            return
            
        tmdb_id = result["id"]
        title = result.get("title") or result.get("name")
        year = (result.get("release_date") or result.get("first_air_date", ""))[:4]
        
        providers = get_watch_providers(tmdb_id, media_type)
        
        output = {
            "title": title,
            "year": year,
            "media_type": media_type,
            "streaming": providers.get("flatrate", []),
            "rent": providers.get("rent", []),
            "buy": providers.get("buy", []),
            "link": providers.get("link")
        }
        print(json.dumps(output))
        
    except Exception as e:
        print(json.dumps({"error": str(e)}))

if __name__ == "__main__":
    main()


path: scripts/check_plex.py
content: #!/usr/bin/env python3
"""
homeflix / check_plex
Checks whether a movie already exists in the Plex library.
"""
import json
import os
import sys
import requests

PLEX_URL   = os.environ.get("PLEX_URL", "http://localhost:32400")
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
PLEX_LIBRARY = os.environ.get("PLEX_MOVIE_LIBRARY", "Movies")


def get_library_key():
    """Returns the section key for the configured movie library."""
    url = f"{PLEX_URL}/library/sections"
    headers = {"X-Plex-Token": PLEX_TOKEN, "Accept": "application/json"}
    resp = requests.get(url, headers=headers, timeout=5)
    resp.raise_for_status()
    for section in resp.json()["MediaContainer"]["Directory"]:
        if section["title"].lower() == PLEX_LIBRARY.lower():
            return section["key"]
    return None


def search_plex(title: str, library_key: str):
    """Full-text search within the Plex movie library."""
    url = f"{PLEX_URL}/library/sections/{library_key}/search"
    headers = {"X-Plex-Token": PLEX_TOKEN, "Accept": "application/json"}
    params = {"query": title, "type": 1}  # type 1 = movies
    resp = requests.get(url, headers=headers, params=params, timeout=5)
    resp.raise_for_status()
    results = resp.json()["MediaContainer"].get("Metadata", [])
    return results


def main():
    try:
        input_data = json.loads(sys.argv[1])
        title = input_data.get("movie_title", "").strip()
        if not title:
            print(json.dumps({"error": "movie_title is required"}))
            return

        library_key = get_library_key()
        if not library_key:
            print(json.dumps({"error": f"Plex library '{PLEX_LIBRARY}' not found"}))
            return

        results = search_plex(title, library_key)

        if results:
            match = results[0]
            print(json.dumps({
                "found": True,
                "title": match.get("title"),
                "year": match.get("year"),
                "plex_key": match.get("ratingKey"),
            }))
        else:
            print(json.dumps({"found": False}))

    except Exception as e:
        print(json.dumps({"error": str(e)}))


if __name__ == "__main__":
    main()


path: scripts/list_torrents.py
content: #!/usr/bin/env python3
import json
import os
import requests

QBIT_URL      = os.environ.get("QBIT_URL", "http://localhost:8080")
QBIT_USERNAME = os.environ.get("QBIT_USERNAME", "admin")
QBIT_PASSWORD = os.environ.get("QBIT_PASSWORD", "")

def login(session: requests.Session):
    resp = session.post(
        f"{QBIT_URL}/api/v2/auth/login",
        data={"username": QBIT_USERNAME, "password": QBIT_PASSWORD},
        timeout=5,
    )
    if resp.text.strip() != "Ok.":
        raise RuntimeError(f"qBittorrent login failed: {resp.text}")

def get_torrents(session: requests.Session):
    resp = session.get(f"{QBIT_URL}/api/v2/torrents/info", timeout=5)
    resp.raise_for_status()
    return resp.json()

def main():
    try:
        session = requests.Session()
        login(session)
        torrents = get_torrents(session)
        
        output = []
        for t in torrents:
            output.append({
                "name": t["name"],
                "progress": round(t["progress"] * 100, 1),
                "ratio": round(t["ratio"], 2),
                "state": t["state"]
            })
        print(json.dumps(output))
    except Exception as e:
        print(json.dumps({"error": str(e)}))

if __name__ == "__main__":
    main()


path: scripts/remove_movie.py
content: #!/usr/bin/env python3
"""
homeflix / remove_movie
Finds a movie in Radarr by title and removes it from the library.
Optionally deletes the downloaded files.
"""
import json
import os
import sys
import requests

RADARR_URL     = os.environ.get("RADARR_URL", "http://localhost:7878/api/v3")
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")

HEADERS = {"X-Api-Key": RADARR_API_KEY}


def find_movie_in_library(title: str):
    """Search the Radarr library for a movie by title (case-insensitive partial match)."""
    resp = requests.get(f"{RADARR_URL}/movie", headers=HEADERS, timeout=10)
    resp.raise_for_status()
    title_lower = title.lower()
    matches = [m for m in resp.json() if title_lower in m["title"].lower()]
    if not matches:
        return None
    # Prefer exact match, fall back to first partial
    for m in matches:
        if m["title"].lower() == title_lower:
            return m
    return matches[0]


def remove_from_queue(movie_id: int, delete_files: bool):
    """Delete the movie record from Radarr (and optionally its files)."""
    params = {
        "deleteFiles":            str(delete_files).lower(),
        "addImportExclusion":     "false",
    }
    resp = requests.delete(
        f"{RADARR_URL}/movie/{movie_id}",
        headers=HEADERS,
        params=params,
        timeout=10,
    )
    return resp.status_code in (200, 204)


def main():
    try:
        input_data   = json.loads(sys.argv[1])
        title        = input_data.get("movie_title", "").strip()
        delete_files = input_data.get("delete_files", True)

        if not title:
            print(json.dumps({"error": "movie_title is required"}))
            return

        movie = find_movie_in_library(title)
        if not movie:
            print(json.dumps({
                "status":  "not_found",
                "message": f"'{title}' is not in your Radarr library.",
            }))
            return

        success = remove_from_queue(movie["id"], delete_files)
        if success:
            action = "removed with files" if delete_files else "removed from library"
            print(json.dumps({
                "status":  "removed",
                "title":   movie["title"],
                "year":    movie["year"],
                "action":  action,
            }))
        else:
            print(json.dumps({
                "status":  "error",
                "message": f"Radarr failed to delete '{movie['title']}'.",
            }))

    except Exception as e:
        print(json.dumps({"error": str(e)}))


if __name__ == "__main__":
    main()


path: scripts/request_movie.py
content: #!/usr/bin/env python3
"""
homeflix / request_movie
Looks up a movie via Radarr (TMDB) and adds it to the download queue.
Triggers an immediate search via Prowlarr/YTS.
"""
import json
import os
import sys
import requests
import string

RADARR_URL              = os.environ.get("RADARR_URL", "http://localhost:7878/api/v3")
RADARR_API_KEY          = os.environ.get("RADARR_API_KEY", "")
QUALITY_PROFILE_ID      = int(os.environ.get("RADARR_QUALITY_PROFILE_ID", "1"))
ROOT_FOLDER_PATH        = os.environ.get("RADARR_ROOT_FOLDER", "/tmp/plex_movies/")

HEADERS = {"X-Api-Key": RADARR_API_KEY}


def lookup_movie(title: str):
    """Search TMDB via Radarr — returns top match or None."""
    resp = requests.get(
        f"{RADARR_URL}/movie/lookup",
        headers=HEADERS,
        params={"term": title},
        timeout=10,
    )
    if resp.status_code != 200 or not resp.json():
        return None
    return resp.json()[0]


def get_clean_words(text: str):
    text = text.translate(str.maketrans('', '', string.punctuation)).lower()
    return set(text.split())

def extract_extra_keywords(query: str, title: str):
    query_words = get_clean_words(query)
    title_words = get_clean_words(title)
    # Stopwords specific to natural language requests
    stopwords = {"in", "with", "a", "an", "the", "of", "for", "and", "version", "movie", "film", "i", "want", "to", "download", "get"}
    return query_words - title_words - stopwords

def get_movie_id(tmdb_id: int):
    """Fetch internal Radarr movie ID from TMDB ID."""
    resp = requests.get(
        f"{RADARR_URL}/movie",
        headers=HEADERS,
        params={"tmdbId": tmdb_id},
        timeout=10,
    )
    if resp.status_code == 200:
        movies = resp.json()
        if movies:
            return movies[0].get("id")
    return None

def search_and_download_release(movie_id: int, extra_words: set):
    """Search releases for a movie and queue the first one that matches all extra keywords."""
    resp = requests.get(
        f"{RADARR_URL}/release",
        headers=HEADERS,
        params={"movieId": movie_id},
        timeout=60,
    )
    if resp.status_code != 200:
        return False, f"Release search failed (HTTP {resp.status_code})"

    releases = resp.json()
    valid_releases = []
    
    for r in releases:
        rtitle = r.get("title", "").translate(str.maketrans('', '', string.punctuation)).lower()
        if all(w in rtitle for w in extra_words) and not r.get("rejected", False):
            valid_releases.append(r)
            
    # Fallback: maybe allow rejected ones if no valid ones found?
    # Sometimes language rejections happen but user still wants it
    if not valid_releases:
        for r in releases:
            rtitle = r.get("title", "").translate(str.maketrans('', '', string.punctuation)).lower()
            if all(w in rtitle for w in extra_words):
                valid_releases.append(r)

    if not valid_releases:
        return False, f"No release found matching extra keywords: {', '.join(extra_words)}"
        
    valid_releases.sort(key=lambda x: x.get("seeders", 0), reverse=True)
    best_release = valid_releases[0]

    dl_payload = {
        "guid": best_release["guid"],
        "indexerId": best_release["indexerId"],
        "movieId": movie_id
    }
    dl_resp = requests.post(
        f"{RADARR_URL}/release",
        headers={**HEADERS, "Content-Type": "application/json"},
        json=dl_payload,
        timeout=10,
    )
    
    if dl_resp.status_code in (200, 201):
        return True, best_release.get("title")
    else:
        return False, f"Failed to queue release: {dl_resp.status_code}"

def add_movie(movie: dict, original_query: str):
    """POST to Radarr to monitor + specific search if query has extra words."""
    extra_words = extract_extra_keywords(original_query, movie["title"])
    is_specific_version = len(extra_words) > 0

    payload = {
        "title":            movie["title"],
        "year":             movie["year"],
        "tmdbId":           movie["tmdbId"],
        "titleSlug":        movie["titleSlug"],
        "images":           movie["images"],
        "qualityProfileId": QUALITY_PROFILE_ID,
        "rootFolderPath":   ROOT_FOLDER_PATH,
        "monitored":        True,
        "addOptions": {
            # Only do automatic search if no specific version requested
            "searchForMovie": not is_specific_version
        },
    }
    resp = requests.post(
        f"{RADARR_URL}/movie",
        headers={**HEADERS, "Content-Type": "application/json"},
        json=payload,
        timeout=10,
    )
    
    # 201 Created successfully
    if resp.status_code == 201:
        result = {
            "status": "queued",
            "title":  movie["title"],
            "year":   movie["year"],
        }
        if is_specific_version:
            movie_id = resp.json().get("id")
            if movie_id:
                success, msg = search_and_download_release(movie_id, extra_words)
                if success:
                    result["status"] = "queued_specific_version"
                    result["release_title"] = msg
                else:
                    result["status"] = "queued_base_version_only"
                    result["message"] = msg
        return result

    # 400 Bad Request (Already exists)
    elif resp.status_code == 400 and "already" in resp.text.lower():
        result = {
            "title":  movie["title"],
            "year":   movie["year"],
        }
        if is_specific_version:
            movie_id = get_movie_id(movie["tmdbId"])
            if movie_id:
                success, msg = search_and_download_release(movie_id, extra_words)
                if success:
                    result["status"] = "queued_specific_version"
                    result["release_title"] = msg
                    return result
                else:
                    result["status"] = "duplicate_no_specific_version_found"
                    result["message"] = msg
                    return result

        result["status"] = "duplicate"
        return result

    # Other API Errors
    else:
        return {
            "status": "error",
            "message": f"Radarr returned {resp.status_code}: {resp.text[:200]}",
        }


def main():
    try:
        input_data = json.loads(sys.argv[1])
        title_query = input_data.get("movie_title", "").strip()
        if not title_query:
            print(json.dumps({"error": "movie_title is required"}))
            return

        movie = lookup_movie(title_query)
        if not movie:
            print(json.dumps({"error": f"No TMDB match found for '{title_query}'"}))
            return

        result = add_movie(movie, title_query)
        print(json.dumps(result))

    except Exception as e:
        print(json.dumps({"error": str(e)}))


if __name__ == "__main__":
    main()


path: skill.yaml
content: name: moviefetch
description: Download, track, and remove movies across Plex, Radarr, and qBittorrent from chat.

tools:
  - name: check_plex
    description: Check if a movie is already available in the Plex library. Always call this before requesting a download.
    entrypoint: scripts/check_plex.py
    input_schema:
      type: object
      properties:
        movie_title:
          type: string
          description: The movie title to search for in Plex (e.g. "Inception")
      required: [movie_title]

  - name: request_movie
    description: Add a movie to Radarr to automatically search, download via qBittorrent, and deliver to Plex.
    entrypoint: scripts/request_movie.py
    input_schema:
      type: object
      properties:
        movie_title:
          type: string
          description: The movie title to download (e.g. "The Dark Knight")
      required: [movie_title]

  - name: check_download
    description: Check the current download progress and status of a movie in qBittorrent.
    entrypoint: scripts/check_download.py
    input_schema:
      type: object
      properties:
        movie_title:
          type: string
          description: The movie title to check progress for
      required: [movie_title]

  - name: remove_movie
    description: Remove a movie from the Radarr queue and/or library. Use when user wants to cancel or delete.
    entrypoint: scripts/remove_movie.py
    input_schema:
      type: object
      properties:
        movie_title:
          type: string
          description: The movie title to remove
        delete_files:
          type: boolean
          description: Whether to also delete downloaded files from Plex and Torrent. Default true when deleting a movie.
      required: [movie_title]


path: tests/conftest.py
content: import sys
import os
from pathlib import Path

# Add the root of the skill to sys.path so we can import `tools.*`
CURRENT_DIR = Path(__file__).parent.resolve()
SKILL_ROOT = CURRENT_DIR.parent
if str(SKILL_ROOT) not in sys.path:
    sys.path.insert(0, str(SKILL_ROOT))

# Mock environment variables so unit tests don't hit live instances by default
os.environ["RADARR_URL"] = "http://mock-radarr:7878/api/v3"
os.environ["RADARR_API_KEY"] = "mock_radarr_key"
os.environ["QBIT_URL"] = "http://mock-qbit:8080"
os.environ["QBIT_USERNAME"] = "mock_admin"
os.environ["QBIT_PASSWORD"] = "mock_pass"
os.environ["PLEX_URL"] = "http://mock-plex:32400"
os.environ["PLEX_TOKEN"] = "mock_plex_token"


path: tests/integration/test_pipeline.py
content: import os
import sys
import json
import pytest
from scripts.request_movie import add_movie, lookup_movie
from scripts.remove_movie import find_movie_in_library, remove_from_queue

pytestmark = pytest.mark.skipif(
    not os.environ.get("RUN_LIVE_TESTS"),
    reason="Need RUN_LIVE_TESTS=1 environment variable to run against live Radarr"
)

def test_live_movie_pipeline():
    # Big Buck Bunny is an open source short film (tmdb ID 10378)
    # 1. Lookup
    movie = lookup_movie("Big Buck Bunny")
    assert movie is not None, "TMDB lookup failed"

    # 2. Add
    res = add_movie(movie, "Big Buck Bunny")
    assert res.get("status") in ("queued", "duplicate", "queued_base_version_only")

    # 3. Wait/Verify it's in library
    in_lib = find_movie_in_library("Big Buck Bunny")
    assert in_lib is not None

    # 4. Remove
    success = remove_from_queue(in_lib["id"], delete_files=True)
    assert success is True


path: tests/unit/test_check_download.py
content: import pytest
from scripts.check_download import fuzzy_match, format_eta

def test_fuzzy_match_exact_and_partial():
    torrents = [
        {"name": "The.Matrix.1999.1080p.BluRay.x264-SPARKS", "progress": 1.0, "state": "pausedUP"},
        {"name": "The.Matrix.Reloaded.2003.1080p.BluRay.x264-SPARKS", "progress": 0.5, "state": "downloading"}
    ]
    
    # Match base matrix
    res = fuzzy_match("The Matrix", torrents)
    assert res is not None
    assert "1999" in res["name"]
    
    # Match reloaded
    res = fuzzy_match("matrix reloaded", torrents)
    assert res is not None
    assert "Reloaded" in res["name"]
    
    # Completely missing
    res = fuzzy_match("Inception", torrents)
    assert res is None

def test_format_eta():
    assert format_eta(3600) == "1h 0m"
    assert format_eta(65) == "1m 5s"
    assert format_eta(3000000) == "unknown" # greater than 7 days
    assert format_eta(45) == "45s"


path: tests/unit/test_check_plex.py
content: import pytest
import responses
from scripts.check_plex import get_library_key, search_plex, PLEX_URL

@responses.activate
def test_get_library_key():
    responses.add(
        responses.GET,
        f"{PLEX_URL}/library/sections",
        json={
            "MediaContainer": {
                "Directory": [
                    {"title": "TV Shows", "key": "2"},
                    {"title": "Movies", "key": "1"}
                ]
            }
        },
        status=200
    )
    
    key = get_library_key()
    assert key == "1"

@responses.activate
def test_search_plex():
    responses.add(
        responses.GET,
        f"{PLEX_URL}/library/sections/1/search",
        json={
            "MediaContainer": {
                "Metadata": [
                    {"title": "Inception", "year": 2010, "ratingKey": "123"}
                ]
            }
        },
        status=200
    )
    
    results = search_plex("Inception", "1")
    assert len(results) == 1
    assert results[0]["title"] == "Inception"


path: tests/unit/test_remove_movie.py
content: import pytest
import responses
from scripts.remove_movie import find_movie_in_library, remove_from_queue, RADARR_URL

@responses.activate
def test_find_movie_in_library():
    responses.add(
        responses.GET,
        f"{RADARR_URL}/movie",
        json=[
            {"id": 1, "title": "The Matrix"},
            {"id": 2, "title": "The Matrix Reloaded"}
        ],
        status=200
    )
    
    # exact match
    movie = find_movie_in_library("The Matrix")
    assert movie is not None
    assert movie["id"] == 1
    
    # partial match lookup
    movie = find_movie_in_library("reloaded")
    assert movie is not None
    assert movie["id"] == 2
    
    # none
    movie = find_movie_in_library("Inception")
    assert movie is None

@responses.activate
def test_remove_from_queue():
    responses.add(
        responses.DELETE,
        f"{RADARR_URL}/movie/1",
        status=200
    )
    assert remove_from_queue(1, True) is True
    
    responses.add(
        responses.DELETE,
        f"{RADARR_URL}/movie/99",
        status=404
    )
    assert remove_from_queue(99, False) is False


path: tests/unit/test_request_movie.py
content: import pytest
import responses
from scripts.request_movie import extract_extra_keywords, add_movie, RADARR_URL

def test_extract_extra_keywords():
    assert extract_extra_keywords("inception hindi", "Inception") == {"hindi"}
    assert extract_extra_keywords("the matrix reloaded 1080p", "The Matrix Reloaded") == {"1080p"}
    # testing stopwords and punctuation
    assert extract_extra_keywords("i want inception in hindi!!", "Inception") == {"hindi"}

@responses.activate
def test_add_movie_success():
    movie = {
        "title": "Inception",
        "year": 2010,
        "tmdbId": 27205,
        "titleSlug": "inception-27205",
        "images": []
    }
    responses.add(
        responses.POST,
        f"{RADARR_URL}/movie",
        json={"id": 12, "title": "Inception"},
        status=201
    )
    
    result = add_movie(movie, "Inception")
    assert result["status"] == "queued"
    assert result["title"] == "Inception"

@responses.activate
def test_add_movie_duplicate_no_extra_words():
    movie = {
        "title": "Inception",
        "year": 2010,
        "tmdbId": 27205,
        "titleSlug": "inception-27205",
        "images": []
    }
    responses.add(
        responses.POST,
        f"{RADARR_URL}/movie",
        body='[{"propertyName": "Title", "errorMessage": "This movie has already been added", "attemptedValue": "Inception"}]',
        status=400
    )
    
    result = add_movie(movie, "Inception")
    assert result["status"] == "duplicate"

@responses.activate
def test_add_movie_duplicate_with_extra_words():
    movie = {
        "title": "Inception",
        "year": 2010,
        "tmdbId": 27205,
        "titleSlug": "inception-27205",
        "images": []
    }
    
    # 1. Reject first post as already exists
    responses.add(
        responses.POST,
        f"{RADARR_URL}/movie",
        body='[{"propertyName": "Title", "errorMessage": "This movie has already been added", "attemptedValue": "Inception"}]',
        status=400
    )
    
    # 2. Mock getting local movie id
    responses.add(
        responses.GET,
        f"{RADARR_URL}/movie",
        match=[responses.matchers.query_param_matcher({"tmdbId": 27205})],
        json=[{"id": 42, "title": "Inception"}],
        status=200
    )
    
    # 3. Mock release search
    responses.add(
        responses.GET,
        f"{RADARR_URL}/release",
        match=[responses.matchers.query_param_matcher({"movieId": 42})],
        json=[
            {"title": "Inception.2010.1080p", "guid": "abc", "indexerId": 1, "seeders": 10},
            {"title": "Inception.Hindi.1080p", "guid": "def", "indexerId": 2, "seeders": 5, "rejected": False}
        ],
        status=200
    )
    
    # 4. Mock trigger specific download
    responses.add(
        responses.POST,
        f"{RADARR_URL}/release",
        json={},
        status=200
    )
    
    result = add_movie(movie, "Inception hindi")
    assert result["status"] == "queued_specific_version"
    assert "Inception.Hindi.1080p" in result["release_title"]


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)