Compare commits
No commits in common. "dfda7fccfb8cae467f2d99b86357676959a4a412" and "09db6370b089468e9c80fac4f02635e2b91e350e" have entirely different histories.
dfda7fccfb
...
09db6370b0
|
|
@ -21,7 +21,3 @@ sops-age.key
|
||||||
|
|
||||||
# Ignore older source iterations
|
# Ignore older source iterations
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
# Ignore local Python bytecode from validation helpers
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -265,17 +265,14 @@ PV data, or VM disks, run:
|
||||||
./lab.sh stop-cluster
|
./lab.sh stop-cluster
|
||||||
```
|
```
|
||||||
|
|
||||||
This stops `kubelet`, stops Kubernetes CRI containers with `crictl` when
|
This stops `kubelet` and `containerd` on the Debian control plane, stops any
|
||||||
available, stops any workers listed in `WORKER_SSH_TARGETS`, and gracefully
|
workers listed in `WORKER_SSH_TARGETS`, and gracefully shuts down automated
|
||||||
shuts down automated Pimox worker VMs. It intentionally leaves the host
|
Pimox worker VMs. It does not run `kubeadm reset`, delete CNI files, or remove
|
||||||
`containerd`/Docker stack running so external Docker Compose services such as
|
state. Use `LAB_CLUSTER_STOP_VM_TIMEOUT_SECONDS` to change the Pimox shutdown
|
||||||
Gitea stay online. It does not run `kubeadm reset`, delete CNI files, or remove
|
timeout, `LAB_CLUSTER_STOP_FORCE=false` to avoid a hard `qm stop` after timeout,
|
||||||
state. Use `LAB_CLUSTER_STOP_CONTAINERD=true` only when you intentionally want
|
and the usual `LAB_PIMOX_WORKER_COUNT`, `LAB_PIMOX_WORKER_BASE_VMID`, and
|
||||||
to stop the host container runtime too. Use `LAB_CLUSTER_STOP_VM_TIMEOUT_SECONDS`
|
`LAB_PIMOX_SKIP_WORKER_INDEXES` variables to select VM workers. Resume the
|
||||||
to change the Pimox shutdown timeout, `LAB_CLUSTER_STOP_FORCE=false` to avoid a
|
runtime with:
|
||||||
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
|
```bash
|
||||||
./lab.sh start-cluster
|
./lab.sh start-cluster
|
||||||
|
|
@ -544,9 +541,7 @@ 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. The active host-level Compose
|
are retained only as migration reference.
|
||||||
definition lives under `infra/arr-stack` and stores persistent data under
|
|
||||||
`/data/arr`.
|
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ server {
|
||||||
limit_req zone=one burst=20 nodelay;
|
limit_req zone=one burst=20 nodelay;
|
||||||
client_max_body_size 512m;
|
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_http_version 1.1;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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=
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
#!/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)
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "Example import list placeholder",
|
|
||||||
"enable": false,
|
|
||||||
"implementation": "ReplaceWithProviderImplementation",
|
|
||||||
"configContract": "ReplaceWithProviderSettings",
|
|
||||||
"fields": [],
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
11
lab.sh
11
lab.sh
|
|
@ -3259,20 +3259,13 @@ cluster_worker_targets() {
|
||||||
stop_local_kubernetes_services() {
|
stop_local_kubernetes_services() {
|
||||||
echo "--> Stopping local Kubernetes services on the Debian control plane..."
|
echo "--> Stopping local Kubernetes services on the Debian control plane..."
|
||||||
sudo systemctl stop kubelet 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 truthy "${LAB_CLUSTER_STOP_CONTAINERD:-false}"; then
|
|
||||||
sudo systemctl stop containerd 2>/dev/null || true
|
sudo systemctl stop containerd 2>/dev/null || true
|
||||||
sudo systemctl reset-failed containerd 2>/dev/null || true
|
sudo systemctl reset-failed kubelet containerd 2>/dev/null || true
|
||||||
fi
|
|
||||||
sudo systemctl reset-failed kubelet 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_local_kubernetes_services() {
|
start_local_kubernetes_services() {
|
||||||
echo "--> Starting local Kubernetes services on the Debian control plane..."
|
echo "--> Starting local Kubernetes services on the Debian control plane..."
|
||||||
sudo systemctl start containerd
|
sudo systemctl start containerd
|
||||||
sudo systemctl start docker 2>/dev/null || true
|
|
||||||
sudo systemctl start kubelet
|
sudo systemctl start kubelet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3281,7 +3274,7 @@ stop_remote_kubernetes_services() {
|
||||||
|
|
||||||
echo "--> Stopping Kubernetes services on remote worker ${target}..."
|
echo "--> Stopping Kubernetes services on remote worker ${target}..."
|
||||||
ssh -o ConnectTimeout=5 "${target}" \
|
ssh -o ConnectTimeout=5 "${target}" \
|
||||||
"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"
|
"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"
|
||||||
}
|
}
|
||||||
|
|
||||||
start_remote_kubernetes_services() {
|
start_remote_kubernetes_services() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue