diff --git a/.gitignore b/.gitignore index 5af1ec9..f2be90b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ sops-age.key # Ignore older source iterations *.old + +# Ignore local Python bytecode from validation helpers +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 211835c..6063ca8 100644 --- a/README.md +++ b/README.md @@ -544,7 +544,9 @@ pipeline. It is better suited to a Docker Compose stack on the Debian host with persistent volumes under `/data`, where large media paths, qBittorrent writes, and service config directories can survive cluster rebuilds without OpenEBS node-affinity or PVC migration concerns. The legacy `apps/arr-stack` manifests -are retained only as migration reference. +are retained only as migration reference. The active host-level Compose +definition lives under `infra/arr-stack` and stores persistent data under +`/data/arr`. ## Storage diff --git a/infra/arr-stack/.env.example b/infra/arr-stack/.env.example new file mode 100644 index 0000000..edcca00 --- /dev/null +++ b/infra/arr-stack/.env.example @@ -0,0 +1,28 @@ +ARR_ROOT=/data/arr +PUID=1000 +PGID=1000 +TZ=America/Mexico_City + +PROWLARR_PORT=9696 +RADARR_PORT=7878 +SONARR_PORT=8989 +QBITTORRENT_PORT=8080 +QBITTORRENT_TORRENTING_PORT=6881 +FLARESOLVERR_PORT=8191 +KAPOWARR_PORT=5656 +SUWAYOMI_PORT=4567 +MAINTAINERR_PORT=6246 + +# Optional bootstrap values. Leave blank until the apps have created API keys. +PROWLARR_API_KEY= +RADARR_API_KEY= +SONARR_API_KEY= +QBITTORRENT_USERNAME=admin +QBITTORRENT_PASSWORD= + +RADARR_URL=http://127.0.0.1:7878 +SONARR_URL=http://127.0.0.1:8989 +RADARR_ROOT_FOLDER=/data/media/movies +SONARR_ROOT_FOLDER=/data/media/tv +RADARR_IMPORT_LISTS_JSON= +SONARR_IMPORT_LISTS_JSON= diff --git a/infra/arr-stack/README.md b/infra/arr-stack/README.md new file mode 100644 index 0000000..b211baa --- /dev/null +++ b/infra/arr-stack/README.md @@ -0,0 +1,49 @@ +# External ARR Stack + +This Docker Compose stack runs the media-management apps outside Kubernetes on +the Debian host. It is not called by `lab.sh` and is not part of the homelab +OpenTofu/Argo pipeline. + +Services: + +- Prowlarr: `9696` +- Radarr: `7878` +- Sonarr: `8989` +- qBittorrent: `8080` +- FlareSolverr: `8191` +- Kapowarr: `5656` +- Suwayomi: `4567` +- Maintainerr: `6246` + +Persistent storage defaults to `/data/arr`. + +## Start + +```bash +cd infra/arr-stack +cp .env.example .env +./bootstrap.sh +``` + +The helper creates `/data/arr`, fixes ownership for `PUID`/`PGID`, and starts +the Compose stack. + +## Manage + +```bash +docker compose --env-file .env -f docker-compose.yml ps +docker compose --env-file .env -f docker-compose.yml logs -f +docker compose --env-file .env -f docker-compose.yml pull +docker compose --env-file .env -f docker-compose.yml up -d +docker compose --env-file .env -f docker-compose.yml down +``` + +`down` stops containers but keeps `/data/arr` intact. + +## Bootstrap + +See `docs/bootstrap.md` for path wiring and safe import-list guidance. + +After the first boot and API-key discovery, `./configure-arr.py` can configure +Radarr/Sonarr root folders and qBittorrent download clients without putting +credentials in Git. diff --git a/infra/arr-stack/bootstrap.sh b/infra/arr-stack/bootstrap.sh new file mode 100755 index 0000000..2b1192d --- /dev/null +++ b/infra/arr-stack/bootstrap.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ARR_ENV_FILE:-${SCRIPT_DIR}/.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + cp "${SCRIPT_DIR}/.env.example" "${ENV_FILE}" +fi + +set -a +# shellcheck disable=SC1090 +. "${ENV_FILE}" +set +a + +ARR_ROOT="${ARR_ROOT:-/data/arr}" +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" + +sudo mkdir -p \ + "${ARR_ROOT}/config/flaresolverr" \ + "${ARR_ROOT}/config/prowlarr" \ + "${ARR_ROOT}/config/radarr" \ + "${ARR_ROOT}/config/sonarr" \ + "${ARR_ROOT}/config/qbittorrent" \ + "${ARR_ROOT}/config/kapowarr" \ + "${ARR_ROOT}/config/suwayomi" \ + "${ARR_ROOT}/config/maintainerr" \ + "${ARR_ROOT}/downloads/complete" \ + "${ARR_ROOT}/downloads/incomplete" \ + "${ARR_ROOT}/downloads/torrents" \ + "${ARR_ROOT}/media/movies" \ + "${ARR_ROOT}/media/tv" \ + "${ARR_ROOT}/media/comics" \ + "${ARR_ROOT}/media/manga" + +sudo chown -R "${PUID}:${PGID}" "${ARR_ROOT}" + +docker compose --env-file "${ENV_FILE}" -f "${SCRIPT_DIR}/docker-compose.yml" up -d + +cat < None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def request_json(method: str, url: str, api_key: str, payload: dict | None = None) -> object: + data = None + headers = {"X-Api-Key": api_key, "Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(request, timeout=15) as response: + body = response.read() + if not body: + return {} + return json.loads(body.decode("utf-8")) + + +def wait_for_app(name: str, base_url: str, api_key: str) -> None: + for attempt in range(1, 31): + try: + request_json("GET", f"{base_url}/api/v3/system/status", api_key) + return + except Exception as exc: # noqa: BLE001 + if attempt == 30: + raise RuntimeError(f"{name} did not become reachable at {base_url}") from exc + time.sleep(2) + + +def ensure_root_folder(name: str, base_url: str, api_key: str, path: str) -> None: + existing = request_json("GET", f"{base_url}/api/v3/rootfolder", api_key) + if isinstance(existing, list) and any(item.get("path") == path for item in existing if isinstance(item, dict)): + print(f"{name}: root folder already exists: {path}") + return + request_json("POST", f"{base_url}/api/v3/rootfolder", api_key, {"path": path}) + print(f"{name}: added root folder: {path}") + + +def schema_fields(base_url: str, api_key: str, endpoint: str, implementation: str) -> list[dict]: + schemas = request_json("GET", f"{base_url}/api/v3/{endpoint}/schema", api_key) + if not isinstance(schemas, list): + return [] + for schema in schemas: + if isinstance(schema, dict) and schema.get("implementation") == implementation: + fields = schema.get("fields") + return fields if isinstance(fields, list) else [] + return [] + + +def fill_fields(fields: list[dict], values: dict[str, object]) -> list[dict]: + hydrated = [] + for field in fields: + if not isinstance(field, dict): + continue + item = dict(field) + name = item.get("name") + if name in values: + item["value"] = values[name] + hydrated.append(item) + return hydrated + + +def ensure_qbittorrent(name: str, base_url: str, api_key: str, category: str) -> None: + clients = request_json("GET", f"{base_url}/api/v3/downloadclient", api_key) + if isinstance(clients, list) and any( + isinstance(item, dict) and item.get("implementation") == "QBittorrent" for item in clients + ): + print(f"{name}: qBittorrent download client already exists") + return + + fields = schema_fields(base_url, api_key, "downloadclient", "QBittorrent") + if not fields: + raise RuntimeError(f"{name}: could not find qBittorrent download client schema") + + port = int(os.environ.get("QBITTORRENT_PORT", "8080")) + values = { + "host": "qbittorrent", + "port": port, + "urlBase": "", + "username": os.environ.get("QBITTORRENT_USERNAME", "admin"), + "password": os.environ.get("QBITTORRENT_PASSWORD", ""), + "category": category, + "movieCategory": category, + "tvCategory": category, + "recentMoviePriority": 0, + "olderMoviePriority": 0, + "recentTvPriority": 0, + "olderTvPriority": 0, + "addPaused": False, + "useSsl": False, + } + payload = { + "enable": True, + "protocol": "torrent", + "priority": 1, + "removeCompletedDownloads": True, + "removeFailedDownloads": True, + "name": "qBittorrent", + "implementation": "QBittorrent", + "configContract": "QBittorrentSettings", + "fields": fill_fields(fields, values), + "tags": [], + } + request_json("POST", f"{base_url}/api/v3/downloadclient", api_key, payload) + print(f"{name}: added qBittorrent download client") + + +def apply_import_lists(name: str, base_url: str, api_key: str, path_env: str) -> None: + list_path = os.environ.get(path_env, "") + if not list_path: + return + path = Path(list_path) + if not path.exists(): + raise RuntimeError(f"{name}: {path_env} points to missing file {path}") + + desired = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(desired, list): + raise RuntimeError(f"{name}: {path} must contain a JSON array") + + existing = request_json("GET", f"{base_url}/api/v3/importlist", api_key) + existing_names = set() + if isinstance(existing, list): + existing_names = {item.get("name") for item in existing if isinstance(item, dict)} + for item in desired: + if not isinstance(item, dict) or not item.get("name"): + raise RuntimeError(f"{name}: every import-list entry must be an object with a name") + if item["name"] in existing_names: + print(f"{name}: import list already exists: {item['name']}") + continue + request_json("POST", f"{base_url}/api/v3/importlist", api_key, item) + print(f"{name}: added import list: {item['name']}") + + +def required_env(name: str) -> str: + value = os.environ.get(name, "") + if not value: + raise RuntimeError(f"Set {name} in {ENV_FILE} before running this script") + return value + + +def main() -> int: + load_env(ENV_FILE) + radarr_key = required_env("RADARR_API_KEY") + sonarr_key = required_env("SONARR_API_KEY") + + radarr_url = os.environ.get("RADARR_URL", "http://127.0.0.1:7878") + sonarr_url = os.environ.get("SONARR_URL", "http://127.0.0.1:8989") + + wait_for_app("Radarr", radarr_url, radarr_key) + wait_for_app("Sonarr", sonarr_url, sonarr_key) + ensure_root_folder("Radarr", radarr_url, radarr_key, os.environ.get("RADARR_ROOT_FOLDER", "/data/media/movies")) + ensure_root_folder("Sonarr", sonarr_url, sonarr_key, os.environ.get("SONARR_ROOT_FOLDER", "/data/media/tv")) + ensure_qbittorrent("Radarr", radarr_url, radarr_key, "radarr") + ensure_qbittorrent("Sonarr", sonarr_url, sonarr_key, "sonarr") + apply_import_lists("Radarr", radarr_url, radarr_key, "RADARR_IMPORT_LISTS_JSON") + apply_import_lists("Sonarr", sonarr_url, sonarr_key, "SONARR_IMPORT_LISTS_JSON") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (RuntimeError, urllib.error.URLError, urllib.error.HTTPError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/infra/arr-stack/docker-compose.yml b/infra/arr-stack/docker-compose.yml new file mode 100644 index 0000000..cd1aa18 --- /dev/null +++ b/infra/arr-stack/docker-compose.yml @@ -0,0 +1,122 @@ +name: homelab-arr + +x-linuxserver-common: &linuxserver-common + restart: unless-stopped + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-America/Mexico_City} + networks: + - arr + +services: + flaresolverr: + image: ${FLARESOLVERR_IMAGE:-ghcr.io/flaresolverr/flaresolverr:latest} + container_name: ${FLARESOLVERR_CONTAINER_NAME:-arr-flaresolverr} + restart: unless-stopped + environment: + LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info} + TZ: ${TZ:-America/Mexico_City} + ports: + - "${FLARESOLVERR_PORT:-8191}:8191" + networks: + - arr + + prowlarr: + <<: *linuxserver-common + image: ${PROWLARR_IMAGE:-lscr.io/linuxserver/prowlarr:latest} + container_name: ${PROWLARR_CONTAINER_NAME:-arr-prowlarr} + ports: + - "${PROWLARR_PORT:-9696}:9696" + volumes: + - ${ARR_ROOT:-/data/arr}/config/prowlarr:/config + + radarr: + <<: *linuxserver-common + image: ${RADARR_IMAGE:-lscr.io/linuxserver/radarr:latest} + container_name: ${RADARR_CONTAINER_NAME:-arr-radarr} + ports: + - "${RADARR_PORT:-7878}:7878" + volumes: + - ${ARR_ROOT:-/data/arr}/config/radarr:/config + - ${ARR_ROOT:-/data/arr}:/data + + sonarr: + <<: *linuxserver-common + image: ${SONARR_IMAGE:-lscr.io/linuxserver/sonarr:latest} + container_name: ${SONARR_CONTAINER_NAME:-arr-sonarr} + ports: + - "${SONARR_PORT:-8989}:8989" + volumes: + - ${ARR_ROOT:-/data/arr}/config/sonarr:/config + - ${ARR_ROOT:-/data/arr}:/data + + qbittorrent: + <<: *linuxserver-common + image: ${QBITTORRENT_IMAGE:-lscr.io/linuxserver/qbittorrent:latest} + container_name: ${QBITTORRENT_CONTAINER_NAME:-arr-qbittorrent} + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-America/Mexico_City} + WEBUI_PORT: ${QBITTORRENT_PORT:-8080} + TORRENTING_PORT: ${QBITTORRENT_TORRENTING_PORT:-6881} + ports: + - "${QBITTORRENT_PORT:-8080}:${QBITTORRENT_PORT:-8080}" + - "${QBITTORRENT_TORRENTING_PORT:-6881}:${QBITTORRENT_TORRENTING_PORT:-6881}" + - "${QBITTORRENT_TORRENTING_PORT:-6881}:${QBITTORRENT_TORRENTING_PORT:-6881}/udp" + volumes: + - ${ARR_ROOT:-/data/arr}/config/qbittorrent:/config + - ${ARR_ROOT:-/data/arr}:/data + + kapowarr: + image: ${KAPOWARR_IMAGE:-mrcas/kapowarr:latest} + container_name: ${KAPOWARR_CONTAINER_NAME:-arr-kapowarr} + restart: unless-stopped + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-America/Mexico_City} + ports: + - "${KAPOWARR_PORT:-5656}:5656" + volumes: + - ${ARR_ROOT:-/data/arr}/config/kapowarr:/app/db + - ${ARR_ROOT:-/data/arr}/downloads/torrents:/app/temp_downloads + - ${ARR_ROOT:-/data/arr}/media/comics:/comics + networks: + - arr + + suwayomi: + image: ${SUWAYOMI_IMAGE:-ghcr.io/suwayomi/suwayomi-server:preview} + container_name: ${SUWAYOMI_CONTAINER_NAME:-arr-suwayomi} + restart: unless-stopped + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-America/Mexico_City} + ports: + - "${SUWAYOMI_PORT:-4567}:4567" + volumes: + - ${ARR_ROOT:-/data/arr}/config/suwayomi:/home/suwayomi/.local/share/Suwayomi-Server + - ${ARR_ROOT:-/data/arr}/media/manga:/data/manga + networks: + - arr + + maintainerr: + image: ${MAINTAINERR_IMAGE:-ghcr.io/jorenn92/maintainerr:latest} + container_name: ${MAINTAINERR_CONTAINER_NAME:-arr-maintainerr} + restart: unless-stopped + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-America/Mexico_City} + ports: + - "${MAINTAINERR_PORT:-6246}:6246" + volumes: + - ${ARR_ROOT:-/data/arr}/config/maintainerr:/opt/data + networks: + - arr + +networks: + arr: + name: homelab-arr diff --git a/infra/arr-stack/docs/bootstrap.md b/infra/arr-stack/docs/bootstrap.md new file mode 100644 index 0000000..d651ee9 --- /dev/null +++ b/infra/arr-stack/docs/bootstrap.md @@ -0,0 +1,77 @@ +# ARR Stack Bootstrap Notes + +This stack is intentionally separate from `lab.sh`, OpenTofu, Argo CD, and the +Kubernetes app pipeline. + +## Storage Layout + +All persistent data lives under `/data/arr` by default: + +- `/data/arr/config/` for app databases and settings +- `/data/arr/downloads/complete` +- `/data/arr/downloads/incomplete` +- `/data/arr/downloads/torrents` +- `/data/arr/media/movies` +- `/data/arr/media/tv` +- `/data/arr/media/comics` +- `/data/arr/media/manga` + +Use the same in-container paths when wiring apps: + +- Radarr root folder: `/data/media/movies` +- Sonarr root folder: `/data/media/tv` +- qBittorrent completed downloads: `/data/downloads/complete` +- qBittorrent incomplete downloads: `/data/downloads/incomplete` + +## Initial Run + +```bash +cd infra/arr-stack +cp .env.example .env +./bootstrap.sh +``` + +After first boot, each app writes its own API key into its config. Retrieve the +keys from: + +```bash +grep -h '' /data/arr/config/radarr/config.xml /data/arr/config/sonarr/config.xml /data/arr/config/prowlarr/config.xml +``` + +Then copy the keys into `.env`. + +Run the optional safe baseline configurator: + +```bash +./configure-arr.py +``` + +It configures Radarr and Sonarr root folders and adds qBittorrent as a download +client by using the apps' own schemas. It does not configure indexers. + +## Public Lists + +Radarr and Sonarr can use import lists, but most useful providers require user +authorization or provider API keys. Do not hard-code third-party credentials or +piracy-oriented sources in this repo. + +Safe examples to configure from the UI: + +- Radarr: TMDb popular, trending, or collection lists with your TMDb API access +- Radarr/Sonarr: your own Trakt public lists after authorizing Trakt +- Sonarr: your own Trakt watched/collection/list sources after authorizing Trakt + +Recommended settings for any import list: + +- Set a tag such as `public-list` or `watchlist`. +- Disable automatic search until quality profiles and root folders are correct. +- Use `/data/media/movies` for Radarr and `/data/media/tv` for Sonarr. +- Keep monitoring conservative until you confirm disk usage and download rules. + +Prowlarr indexers and download-client credentials should be configured in the UI +or from a local, uncommitted automation file. Keep that material out of Git. + +If you want to automate import lists, export `RADARR_IMPORT_LISTS_JSON` or +`SONARR_IMPORT_LISTS_JSON` to a local JSON file that contains the exact payloads +for your authorized providers. `examples/import-lists.example.json` is only a +shape placeholder; do not use it as-is. diff --git a/infra/arr-stack/examples/import-lists.example.json b/infra/arr-stack/examples/import-lists.example.json new file mode 100644 index 0000000..b3235b4 --- /dev/null +++ b/infra/arr-stack/examples/import-lists.example.json @@ -0,0 +1,10 @@ +[ + { + "name": "Example import list placeholder", + "enable": false, + "implementation": "ReplaceWithProviderImplementation", + "configContract": "ReplaceWithProviderSettings", + "fields": [], + "tags": [] + } +]