Compare commits
2 Commits
09db6370b0
...
dfda7fccfb
| Author | SHA1 | Date |
|---|---|---|
|
|
dfda7fccfb | |
|
|
f1c405aab4 |
|
|
@ -21,3 +21,7 @@ sops-age.key
|
|||
|
||||
# Ignore older source iterations
|
||||
*.old
|
||||
|
||||
# Ignore local Python bytecode from validation helpers
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -265,14 +265,17 @@ PV data, or VM disks, run:
|
|||
./lab.sh stop-cluster
|
||||
```
|
||||
|
||||
This stops `kubelet` and `containerd` on the Debian control plane, stops any
|
||||
workers listed in `WORKER_SSH_TARGETS`, and gracefully shuts down automated
|
||||
Pimox worker VMs. It does not run `kubeadm reset`, delete CNI files, or remove
|
||||
state. Use `LAB_CLUSTER_STOP_VM_TIMEOUT_SECONDS` to change the Pimox shutdown
|
||||
timeout, `LAB_CLUSTER_STOP_FORCE=false` to avoid a hard `qm stop` after timeout,
|
||||
and the usual `LAB_PIMOX_WORKER_COUNT`, `LAB_PIMOX_WORKER_BASE_VMID`, and
|
||||
`LAB_PIMOX_SKIP_WORKER_INDEXES` variables to select VM workers. Resume the
|
||||
runtime with:
|
||||
This stops `kubelet`, stops Kubernetes CRI containers with `crictl` when
|
||||
available, stops any workers listed in `WORKER_SSH_TARGETS`, and gracefully
|
||||
shuts down automated Pimox worker VMs. It intentionally leaves the host
|
||||
`containerd`/Docker stack running so external Docker Compose services such as
|
||||
Gitea stay online. It does not run `kubeadm reset`, delete CNI files, or remove
|
||||
state. Use `LAB_CLUSTER_STOP_CONTAINERD=true` only when you intentionally want
|
||||
to stop the host container runtime too. Use `LAB_CLUSTER_STOP_VM_TIMEOUT_SECONDS`
|
||||
to change the Pimox shutdown timeout, `LAB_CLUSTER_STOP_FORCE=false` to avoid a
|
||||
hard `qm stop` after timeout, and the usual `LAB_PIMOX_WORKER_COUNT`,
|
||||
`LAB_PIMOX_WORKER_BASE_VMID`, and `LAB_PIMOX_SKIP_WORKER_INDEXES` variables to
|
||||
select VM workers. Resume the runtime with:
|
||||
|
||||
```bash
|
||||
./lab.sh start-cluster
|
||||
|
|
@ -541,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
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ server {
|
|||
limit_req zone=one burst=20 nodelay;
|
||||
client_max_body_size 512m;
|
||||
|
||||
proxy_pass http://${gitea_backend_host}:${gitea_backend_port}/;
|
||||
proxy_pass http://${gitea_backend_host}:${gitea_backend_port};
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
13
lab.sh
13
lab.sh
|
|
@ -3259,13 +3259,20 @@ cluster_worker_targets() {
|
|||
stop_local_kubernetes_services() {
|
||||
echo "--> Stopping local Kubernetes services on the Debian control plane..."
|
||||
sudo systemctl stop kubelet 2>/dev/null || true
|
||||
sudo systemctl stop containerd 2>/dev/null || true
|
||||
sudo systemctl reset-failed kubelet containerd 2>/dev/null || true
|
||||
if command -v crictl >/dev/null 2>&1; then
|
||||
sudo crictl stop --all 2>/dev/null || true
|
||||
fi
|
||||
if truthy "${LAB_CLUSTER_STOP_CONTAINERD:-false}"; then
|
||||
sudo systemctl stop containerd 2>/dev/null || true
|
||||
sudo systemctl reset-failed containerd 2>/dev/null || true
|
||||
fi
|
||||
sudo systemctl reset-failed kubelet 2>/dev/null || true
|
||||
}
|
||||
|
||||
start_local_kubernetes_services() {
|
||||
echo "--> Starting local Kubernetes services on the Debian control plane..."
|
||||
sudo systemctl start containerd
|
||||
sudo systemctl start docker 2>/dev/null || true
|
||||
sudo systemctl start kubelet
|
||||
}
|
||||
|
||||
|
|
@ -3274,7 +3281,7 @@ stop_remote_kubernetes_services() {
|
|||
|
||||
echo "--> Stopping Kubernetes services on remote worker ${target}..."
|
||||
ssh -o ConnectTimeout=5 "${target}" \
|
||||
"sudo systemctl stop kubelet 2>/dev/null || true; sudo systemctl stop containerd 2>/dev/null || true; sudo systemctl reset-failed kubelet containerd 2>/dev/null || true"
|
||||
"sudo systemctl stop kubelet 2>/dev/null || true; if command -v crictl >/dev/null 2>&1; then sudo crictl stop --all 2>/dev/null || true; fi; if [ '${LAB_CLUSTER_STOP_CONTAINERD:-false}' = 'true' ]; then sudo systemctl stop containerd 2>/dev/null || true; sudo systemctl reset-failed containerd 2>/dev/null || true; fi; sudo systemctl reset-failed kubelet 2>/dev/null || true"
|
||||
}
|
||||
|
||||
start_remote_kubernetes_services() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue