Metadata-Version: 2.4
Name: arr-py-client
Version: 0.9.6
Summary: Typed Python client + MCP server + declarative config sync for Radarr, Sonarr, and Prowlarr
Project-URL: Homepage, https://github.com/allada-homelab/arr-py-client
Project-URL: Documentation, https://allada-homelab.github.io/arr-py-client/
Project-URL: Source, https://github.com/allada-homelab/arr-py-client
Project-URL: Changelog, https://github.com/allada-homelab/arr-py-client/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/allada-homelab/arr-py-client/issues
Author-email: David Allada <davidanilallada@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Framework :: Pydantic :: 2
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: <3.15,>=3.11
Requires-Dist: anyio>=4.13.0
Requires-Dist: httpx<1.0,>=0.28
Requires-Dist: pydantic-settings<3.0,>=2.2
Requires-Dist: pydantic<3.0,>=2.13.3
Provides-Extra: api
Provides-Extra: config
Requires-Dist: pyyaml<7.0,>=6.0; extra == 'config'
Provides-Extra: mcp
Requires-Dist: mcp<2.0,>=1.27; extra == 'mcp'
Provides-Extra: webhooks
Requires-Dist: fastapi<1.0,>=0.110; extra == 'webhooks'
Description-Content-Type: text/markdown

# arr-py-client

Typed Python client + MCP server + declarative config sync for
[Radarr](https://radarr.video), [Sonarr](https://sonarr.tv), and
[Prowlarr](https://prowlarr.com). One SDK for scripts, one server for
LLM agents, one YAML pipeline for "make my stack match this file."

[![CI](https://github.com/allada-homelab/arr-py-client/actions/workflows/ci.yml/badge.svg)](https://github.com/allada-homelab/arr-py-client/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/arr-py-client.svg)](https://pypi.org/project/arr-py-client/)
[![Python](https://img.shields.io/pypi/pyversions/arr-py-client.svg)](https://pypi.org/project/arr-py-client/)
[![Coverage](https://img.shields.io/badge/coverage-82%25-brightgreen.svg)](https://github.com/allada-homelab/arr-py-client/actions/workflows/ci.yml)
[![License](https://img.shields.io/pypi/l/arr-py-client.svg)](LICENSE)

- [Highlights](#highlights)
- [Install](#install)
- [Quickstart](#quickstart)
  - [SDK](#sdk)
  - [MCP server (stdio)](#mcp-server-stdio)
  - [MCP server (Docker)](#mcp-server-docker)
- [Features in depth](#features-in-depth)
- [How it compares](#how-it-compares)
- [Documentation](#documentation)
- [Development](#development)
- [License](#license)

## Highlights

**Fully typed, fully async-ready SDK.**
Pydantic v2 models, `httpx` transport, complete Radarr v3 + Sonarr v3 +
Prowlarr v1 endpoint coverage, sync and async mirrors of every
operation. Ships `py.typed` so `mypy` / `basedpyright` / Pylance pick it
up immediately.

**Drop-in MCP server for Claude, Cursor, and custom agents.**
`arr-py-mcp` exposes 60+ tools over stdio or streamable-HTTP — the LLM
can list movies, explain a release grade, run a queue janitor, or sync
declarative config without any bespoke tool plumbing on your side.
Every response carries `_meta.action_hints` so chains self-suggest
their next call.

**Multi-tenant out of the box.**
One environment-var provider for solo use; a `ClientProvider` +
OAuth 2.1 path for SaaS deployments where every user has their own
stack. Per-tool scopes, RFC 6750 `WWW-Authenticate` headers, and an
`audit=` hook are already wired.

**Declarative config sync (Recyclarr-style, in Python).**
Describe tags, custom formats, and quality profiles in YAML (or JSON /
TOML). Call `plan()` to preview, `apply()` to converge — same
desired-state model for Radarr and Sonarr.

**Workflow primitives that are boring on purpose.**
`queue.janitor()` (policy-based cleanup with named bundles),
`library.backfill()` (rate-limited missing-content search with
`.estimate()`), `releases.explain()` (human-readable grading +
`.advice()`), `arr_health()` (cross-brand rollup). No LLM required —
they're ordinary Python you can schedule from cron.

**Webhook receivers.**
Typed events from Radarr / Sonarr, plumbed through FastAPI or plain
WSGI with zero extra deps.

**Testing utilities.**
`make_fake_radarr()` / `make_fake_sonarr()` for in-process fakes and an
`@replay(...)` decorator for record-on-miss / replay-on-hit against
real instances.

**Small, focused CLI.**
`arr-py` handles status and basic add; workflows deliberately live in
Python + MCP, not shell.

Python 3.11 – 3.14.

## Install

```bash
pip install arr-py-client              # core SDK
pip install arr-py-client[mcp]         # + MCP server
pip install arr-py-client[config]      # + YAML loader for config_sync
pip install arr-py-client[webhooks]    # + FastAPI receiver helper
```

Works with `pip`, `uv`, `pipx`, and any PEP 621 installer.

## Quickstart

### SDK

```python
from arr_py_client import RadarrClient

with RadarrClient(base_url="http://radarr:7878", api_key="YOUR_KEY") as client:
    for m in client.movies.list()[:5]:
        print(m.id, m.title, m.year)
```

Or let it read `RADARR_BASE_URL` / `RADARR_API_KEY` from the environment
(or a `.env` file):

```python
from arr_py_client import RadarrClient

with RadarrClient() as client:
    print(len(client.movies.list()))
```

Async twins ship for every client — `AsyncRadarrClient`,
`AsyncSonarrClient`, `AsyncProwlarrClient`:

```python
import asyncio
from arr_py_client import AsyncSonarrClient

async def main() -> None:
    async with AsyncSonarrClient() as client:
        series = await client.series.list()
        print(len(series), "shows")

asyncio.run(main())
```

### MCP server (stdio)

Point Claude Desktop, Cursor, or any MCP client at a local process:

```bash
pip install arr-py-client[mcp]

export RADARR_BASE_URL=http://radarr:7878 RADARR_API_KEY=...
export SONARR_BASE_URL=http://sonarr:8989 SONARR_API_KEY=...
export PROWLARR_BASE_URL=http://prowlarr:9696 PROWLARR_API_KEY=...  # optional

arr-py-mcp                      # stdio — register with Claude / Cursor
arr-py-mcp --transport http     # streamable-http for remote clients
```

### MCP server (Docker)

Prebuilt image on every release. Smoke-test in one command:

```bash
docker run --rm -p 3000:3000 \
  -e RADARR_BASE_URL -e RADARR_API_KEY \
  -e SONARR_BASE_URL -e SONARR_API_KEY \
  -e PROWLARR_BASE_URL -e PROWLARR_API_KEY \
  ghcr.io/allada-homelab/arr-py-client:latest
```

Or use the bundled compose file (reads `.env` from the repo root):

```bash
just mcp-up                     # docker compose up -d
curl -sSL -X POST http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"arr_health","arguments":{}}}'
```

Tags: `latest`, `{version}`, `{major}.{minor}`, `{major}`, plus
`sha-<short>` for immutable pinning. Full walk-through:
[docs/guides/docker-deployment.md](docs/guides/docker-deployment.md).

## Features in depth

### Typed client

Every request and response is a pydantic v2 model. Fields unknown to the
client are preserved — a schema drift on the server side won't crash
your code. Retries, `url_base` prefixes, custom httpx clients, and
per-call timeouts are all first-class.

```python
from arr_py_client import RadarrClient

with RadarrClient(base_url="http://radarr:7878", api_key="k") as client:
    movie = client.movies.get(id=42)      # pydantic model, not dict
    movie.monitored = True
    client.movies.put(id=42, body=movie)
```

### MCP server

60+ tools, one of three transports (stdio, streamable-HTTP, SSE for
legacy clients). Tools split into three groups:

- **Per-brand** — `radarr_*`, `sonarr_*`, `prowlarr_*` mirror the HTTP
  API surface (list / get / system status / queue / blocklist / logs).
- **Composed** — `arr_*` cross-brand workflows: `arr_health`,
  `arr_sync_plan`, `arr_sync_apply`, `arr_backfill`, `arr_janitor_run`,
  `arr_explain_grab`.
- **Describe / reference** — `arr_describe_fields`,
  `arr_list_instances`, `arr_list_resources` so the LLM can discover
  the schema at call time.

Every list / get tool returns `{"data": ..., "_meta": {...}}` with
`action_hints` pointing at the next plausible tool call — LLM chains
self-navigate without a giant system prompt.

Multi-tenant? Implement a `ClientProvider`, wire a `TokenVerifier`, and
mount the resulting ASGI app under `/mcp`:

```python
from fastapi import FastAPI
from arr_py_client.mcp import (
    build_server, CallbackTokenVerifier, InMemoryCachedProvider,
)

class MyProvider(InMemoryCachedProvider):
    async def identity(self, ctx):
        return ctx.principal.id

    async def build_client(self, ctx, brand, instance_id, identity):
        # lookup encrypted creds in your DB, return AsyncRadarrClient(...)
        ...

mcp = build_server(
    provider=MyProvider(),
    token_verifier=CallbackTokenVerifier(verify=app_auth.to_principal),
    auth=AuthSettings(issuer_url=..., resource_server_url=...),
)

app = FastAPI()
app.mount("/mcp", mcp.streamable_http_app())
```

Full guide: [docs/guides/mcp-multi-tenant.md](docs/guides/mcp-multi-tenant.md).

### Declarative config sync

Describe the desired state once; `plan` tells you what would change;
`apply` converges. Works for tags, custom formats, quality profiles.

```yaml
# config.yaml
tags: [4k, anime, kids]
custom_formats:
  - name: x265
    specifications:
      - name: x265
        implementation: ReleaseTitleSpecification
        required: true
        fields: [{ name: value, value: "(h|x).?265" }]
quality_profiles:
  - name: HD-Bluray
    upgradeAllowed: true
    cutoff: 7
    formatItems:
      - { name: x265, score: -10000 }
```

```python
from arr_py_client import RadarrClient
from arr_py_client.config_sync import load, plan, apply

with RadarrClient() as client:
    desired = load("config.yaml")
    plan_ = plan(client, desired)
    print(plan_.summary())
    apply(client, plan_, dry_run=False)
```

Full schema + more examples: [docs/examples/config-sync/](docs/examples/config-sync/).

### Queue janitor

Named policy bundles for common opinions; BYO policies for the rest.

```python
from arr_py_client import RadarrClient, POLICIES

with RadarrClient() as client:
    report = client.queue.janitor(
        policies=POLICIES.default,     # .conservative | .aggressive | .ratio_preserving
        protected_trackers=("private-tracker.example",),
        dry_run=False,
    )
    print(report.total_matches)
```

### Webhooks

Parse-only:

```python
from arr_py_client.webhooks import parse_event, OnGrab

event = parse_event(request.json())
if isinstance(event, OnGrab):
    notify(f"Grabbed {event.movie.title if event.movie else '?'}")
```

FastAPI:

```python
from fastapi import FastAPI
from arr_py_client.webhooks import fastapi_router

app = FastAPI()
app.include_router(fastapi_router(on_event), prefix="/webhooks/arr")
```

Zero-dep WSGI:

```python
from wsgiref.simple_server import make_server
from arr_py_client.webhooks import wsgi_app

make_server("0.0.0.0", 9000, wsgi_app(on_event)).serve_forever()  # noqa: S104
```

## How it compares

|  | arr-py-client | pyarr | Recyclarr |
| :-- | :-- | :-- | :-- |
| Pydantic v2 models | yes | no (dicts) | n/a |
| Async | yes | no | n/a |
| Radarr / Sonarr v3 coverage | yes | yes | partial (config only) |
| Prowlarr v1 coverage | yes | yes | yes |
| Lidarr / Readarr | planned | yes | yes |
| MCP server | yes (60+ tools) | no | no |
| Declarative config sync | yes (YAML/JSON/TOML) | no | yes |
| Queue janitor / backfill / release explain | yes | no | no |
| Webhook receiver helper | yes | no | no |
| Ships `py.typed` | yes | no | n/a |

## Documentation

- **API reference**: <https://allada-homelab.github.io/arr-py-client/>
- **Architecture**: [docs/architecture.md](docs/architecture.md)
- **Docker deployment**: [docs/guides/docker-deployment.md](docs/guides/docker-deployment.md)
- **Multi-tenant MCP**: [docs/guides/mcp-multi-tenant.md](docs/guides/mcp-multi-tenant.md)
- **TRaSH-Guides integration**: [docs/guides/trash-guides.md](docs/guides/trash-guides.md)
- **Connection diagnostics**: [docs/guides/connection-diagnostics.md](docs/guides/connection-diagnostics.md)
- **Operator agent**: [docs/guides/operator-agent.md](docs/guides/operator-agent.md)
- **Roadmap**: [docs/roadmap.md](docs/roadmap.md)

## Development

```bash
git clone https://github.com/allada-homelab/arr-py-client
cd arr-py-client
uv sync --all-extras --all-groups
just test           # unit suite
just typecheck      # basedpyright in strict mode
just lint fmt       # ruff check + format
```

Integration tests (spin up real Radarr / Sonarr / Prowlarr containers via
`compose.integration.yml`):

```bash
just int-up         # start the test containers
just test-int       # @pytest.mark.integration
just int-down       # tear down
```

Regenerate clients from upstream OpenAPI specs:

```bash
just gen-radarr <radarr-tag>
just gen-sonarr <sonarr-tag>
just gen-prowlarr <prowlarr-tag>
```

Full contributor workflow, PR conventions, and release process:
[CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT. See [LICENSE](LICENSE).
