Files
arr-init/scripts/common.py
Simon Gardling a7d9b269df refactor: extract Python scripts into standalone files
Move embedded Python scripts out of Nix string interpolation into
standalone files under scripts/.  Each script reads its configuration
from a JSON file passed as the first CLI argument.

Shared utilities (API key reading, API polling, health check loop)
are consolidated into common.py, eliminating three copies of
read_api_key and wait_for_api.

Implementation improvements included in the extraction:
- Remove pyarr dependency; all HTTP calls use raw requests
- Add update semantics: download clients and synced apps are now
  compared against desired state and updated on drift via PUT
- Bazarr configure_provider compares API keys and updates stale ones
- Narrow health_check_loop exception clause from bare Exception to
  (RequestException, ValueError, KeyError)
- Fix double resp.json() call in resolve_profile_id (jellyseerr)
- Replace os.system with subprocess.run for Jellyseerr restart
- Handle Servarr fields with missing 'value' key
- Skip masked fields (privacy=apiKey/password) in drift detection
  to prevent spurious updates every run
2026-04-16 17:28:44 -04:00

141 lines
4.1 KiB
Python

"""Shared utilities for arr-init scripts."""
import json
import sys
import time
import xml.etree.ElementTree as ET
import requests as http
import yaml
def load_config():
"""Load JSON configuration from the path given as the first CLI argument."""
if len(sys.argv) < 2:
print("Usage: script <config.json>", file=sys.stderr)
sys.exit(1)
with open(sys.argv[1]) as f:
return json.load(f)
def read_api_key_xml(config_xml_path):
"""Extract <ApiKey> from a Servarr config.xml file."""
tree = ET.parse(config_xml_path)
node = tree.find("ApiKey")
if node is None or not node.text:
raise ValueError(f"Could not find ApiKey in {config_xml_path}")
return node.text
def read_api_key_yaml(config_yaml_path):
"""Extract the apikey from Bazarr's config.yaml (auth section)."""
with open(config_yaml_path) as fh:
data = yaml.safe_load(fh)
try:
return data["auth"]["apikey"]
except (KeyError, TypeError) as exc:
raise ValueError(
f"Could not find auth.apikey in {config_yaml_path}"
) from exc
def wait_for_api(
base_url,
api_key,
timeout,
name,
*,
header_name="X-Api-Key",
status_path="/system/status",
):
"""Poll a status endpoint until the API responds or timeout.
Args:
base_url: Base URL including any API version prefix.
api_key: API key for authentication.
timeout: Maximum seconds to wait.
name: Human-readable service name for log messages.
header_name: HTTP header name for the API key.
status_path: Path appended to base_url for the health probe.
"""
print(f"Waiting for {name} API (timeout: {timeout}s)...")
for i in range(1, timeout + 1):
try:
resp = http.get(
f"{base_url}{status_path}",
headers={header_name: api_key},
timeout=5,
)
if resp.ok:
print(f"{name} API is ready")
return
except (http.ConnectionError, http.Timeout):
pass
if i == timeout:
print(
f"{name} API not available after {timeout} seconds",
file=sys.stderr,
)
sys.exit(1)
time.sleep(1)
def health_check_loop(url, api_key, entity_name, svc_name, max_retries, interval):
"""POST to a testall endpoint with retry logic.
Exits the process on permanent failure so the systemd unit reflects the error.
"""
attempt = 0
while True:
healthy = True
last_error = ""
try:
resp = http.post(
url,
headers={
"X-Api-Key": api_key,
"Content-Type": "application/json",
},
timeout=30,
)
result = resp.json()
failures = [
item for item in result if not item.get("isValid", True)
]
if failures:
healthy = False
last_error = "\n".join(
f" - ID {f['id']}: "
+ ", ".join(
v["errorMessage"]
for v in f.get("validationFailures", [])
)
for f in failures
)
except (http.RequestException, ValueError, KeyError) as exc:
healthy = False
last_error = (
f"could not reach {svc_name} API for {entity_name} test: {exc}"
)
if healthy:
print(f"All {entity_name}s healthy")
return
attempt += 1
if attempt > max_retries:
print(
f"Health check FAILED after {attempt} attempts: "
f"{entity_name}(s) unreachable:",
file=sys.stderr,
)
print(last_error, file=sys.stderr)
sys.exit(1)
print(
f"{entity_name.capitalize()} health check failed "
f"(attempt {attempt}/{max_retries}), "
f"retrying in {interval}s..."
)
time.sleep(interval)