Add external arr compose stack
This commit is contained in:
parent
f1c405aab4
commit
dfda7fccfb
|
|
@ -21,3 +21,7 @@ sops-age.key
|
||||||
|
|
||||||
# Ignore older source iterations
|
# Ignore older source iterations
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Ignore local Python bytecode from validation helpers
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
persistent volumes under `/data`, where large media paths, qBittorrent writes,
|
||||||
and service config directories can survive cluster rebuilds without OpenEBS
|
and service config directories can survive cluster rebuilds without OpenEBS
|
||||||
node-affinity or PVC migration concerns. The legacy `apps/arr-stack` manifests
|
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
|
## Storage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 <<EOF
|
||||||
|
ARR stack requested.
|
||||||
|
|
||||||
|
Storage root:
|
||||||
|
${ARR_ROOT}
|
||||||
|
|
||||||
|
Web UIs:
|
||||||
|
Prowlarr: http://127.0.0.1:${PROWLARR_PORT:-9696}
|
||||||
|
Radarr: http://127.0.0.1:${RADARR_PORT:-7878}
|
||||||
|
Sonarr: http://127.0.0.1:${SONARR_PORT:-8989}
|
||||||
|
qBittorrent: http://127.0.0.1:${QBITTORRENT_PORT:-8080}
|
||||||
|
FlareSolverr: http://127.0.0.1:${FLARESOLVERR_PORT:-8191}
|
||||||
|
Kapowarr: http://127.0.0.1:${KAPOWARR_PORT:-5656}
|
||||||
|
Suwayomi: http://127.0.0.1:${SUWAYOMI_PORT:-4567}
|
||||||
|
Maintainerr: http://127.0.0.1:${MAINTAINERR_PORT:-6246}
|
||||||
|
|
||||||
|
Next:
|
||||||
|
Update ${ENV_FILE} with generated API keys, then use docs/bootstrap.md for
|
||||||
|
safe app wiring and import-list guidance.
|
||||||
|
EOF
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Configure safe baseline ARR app settings from local environment values."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
ENV_FILE = Path(os.environ.get("ARR_ENV_FILE", SCRIPT_DIR / ".env"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path: Path) -> 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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/<app>` 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 '<ApiKey>' /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.
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Example import list placeholder",
|
||||||
|
"enable": false,
|
||||||
|
"implementation": "ReplaceWithProviderImplementation",
|
||||||
|
"configContract": "ReplaceWithProviderSettings",
|
||||||
|
"fields": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue