Skip to content

Commit 8141de1

Browse files
committed
Streamline automation paths and align setup behavior
1 parent c699a93 commit 8141de1

19 files changed

Lines changed: 279 additions & 115 deletions

.github/workflows/validate.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,22 @@ jobs:
2020
2121
- name: Validate python scripts
2222
run: |
23-
python3 -m py_compile scripts/download-watchdog.py
23+
while IFS= read -r -d '' f; do
24+
python3 -m py_compile "$f"
25+
done < <(find scripts -type f -name '*.py' -print0)
26+
27+
- name: Validate media path usage
28+
run: |
29+
# Automation scripts must resolve MEDIA_DIR from env/.env, not hardcode $HOME/Media.
30+
! rg -n '\$HOME/Media' \
31+
scripts/auto-heal.sh \
32+
scripts/backup.sh \
33+
scripts/download-watchdog.py \
34+
scripts/install-launchd-jobs.sh \
35+
scripts/install-vpn-failover.sh \
36+
scripts/log-prune.sh \
37+
scripts/setup-music.sh \
38+
scripts/vpn-failover-watch.sh
2439
2540
- name: Validate compose config
2641
run: |

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
There are dozens of *arr stack Docker Compose repos on GitHub. Almost all of them dump a compose file and leave you to figure out the rest. This one is different:
3333

3434
- **One command to install.** Clone, configure, and start everything with a single `curl | bash`. No 45-minute manual setup.
35-
- **Auto-configures itself.** The configure script wires up all 13 services via their APIs. No clicking through web UIs.
35+
- **Auto-configures itself.** The configure script wires up the core request/download stack via API (qBittorrent, Prowlarr, Radarr, Sonarr, Seerr). No clicking through those web UIs.
3636
- **Built for macOS.** Native paths, launchd instead of systemd, OrbStack or Docker Desktop instead of bare Docker. Not a Linux guide with "should work on Mac" in the footnotes.
3737
- **Self-healing.** Hourly health checks, download watchdog, VPN failover between providers. Runs unattended.
3838
- **Quality automation.** TRaSH Guides profiles filter out bad releases. Kometa keeps Plex metadata clean. Tdarr saves disk space with automatic transcoding.
@@ -75,9 +75,12 @@ Then open Tidarr at `http://localhost:8484` to authenticate with your Tidal acco
7575
| Download watchdog | Every 15 min | Detects stalled/slow torrents, auto-fixes or swaps them |
7676
| Kometa | Every 4 hours | Updates Plex collections and metadata overlays |
7777
| Log prune | Daily | Removes log files older than 30 days |
78-
| Franchise sort | After Kometa | Sorts franchise collection movies by release date in Plex |
78+
| Franchise sort | Optional/manual | Sorts franchise collection movies by release date in Plex |
7979
| VPN failover | Every 2 min (optional) | Auto-switches between ProtonVPN and NordVPN on sustained failure |
80-
| Watchtower | Weekly (optional) | Auto-pulls latest container images and recreates updated services |
80+
| Watchtower | Daily at 04:00 (optional) | Auto-pulls latest container images and recreates updated services |
81+
82+
Franchise sorting is kept manual by default because it requires your Plex token:
83+
`PLEX_TOKEN=... python3 scripts/franchise-sort.py`
8184

8285
## One-Command Install
8386

@@ -115,7 +118,7 @@ bash scripts/configure.sh
115118
bash scripts/install-launchd-jobs.sh
116119
```
117120

118-
The `watchtower` line above enables automatic container image updates (checks weekly). It's optional but recommended so your services stay patched without manual pulls.
121+
The `watchtower` line above enables automatic container image updates (scheduled daily at 04:00 in compose). It's optional but recommended so your services stay patched without manual pulls.
119122

120123
## Full Setup Guide
121124

@@ -221,7 +224,7 @@ docker compose --profile music up -d
221224

222225
### Tidarr Download Config
223226

224-
The setup script creates a default `tiddl` config at `~/Media/config/tidarr/.tiddl/config.toml`. Key settings:
227+
The setup script creates a default `tiddl` config at `<MEDIA_DIR>/config/tidarr/.tiddl/config.toml` (default `<MEDIA_DIR>` is `~/Media`). Key settings:
225228

226229
- **Quality:** `max` (24-bit Hi-Res FLAC when available, falls back to 16-bit/44.1kHz)
227230
- **Download path:** Your Plex music folder (files go directly to the library)
@@ -282,7 +285,7 @@ bash scripts/archive-media.sh --execute --archive /Volumes/External/Media-Archiv
282285
bash scripts/archive-media.sh --execute --archive /Volumes/External/Media-Archive --only-watched
283286
```
284287

285-
**Protecting favorites:** `scripts/setup.sh` creates `~/Media/config/archive-exceptions.txt` for you. Add one title per line and anything listed won't be archived regardless of age or size. See `configs/archive-exceptions.txt.example` for the format.
288+
**Protecting favorites:** `scripts/setup.sh` creates `<MEDIA_DIR>/config/archive-exceptions.txt` for you (default `<MEDIA_DIR>` is `~/Media`). Add one title per line and anything listed won't be archived regardless of age or size. See `configs/archive-exceptions.txt.example` for the format.
286289

287290
The script verifies file counts after copying and only deletes the source if the counts match. If verification fails, your original files are untouched.
288291

SETUP.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ Or download from https://orbstack.dev. Open it once after installing.
6565

6666
Both options use the same `docker` and `docker compose` commands. Everything in this guide works identically with either one.
6767

68+
If you use a custom media location (`MEDIA_DIR` in `.env`), replace any `~/Media` path below with that value.
69+
6870
---
6971

7072
## Step 2: Download and Setup
@@ -154,7 +156,7 @@ bash scripts/configure.sh
154156
```
155157

156158
This configures qBittorrent, Prowlarr (indexers), Radarr, Sonarr, and Seerr. It will print your API keys at the end. **Save them.**
157-
It also writes credentials/API keys to `~/Media/state/first-run-credentials.txt` (mode `600`).
159+
It also writes credentials/API keys to `<MEDIA_DIR>/state/first-run-credentials.txt` (mode `600`, default path `~/Media/state/first-run-credentials.txt`).
158160

159161
---
160162

@@ -171,7 +173,7 @@ It also writes credentials/API keys to `~/Media/state/first-run-credentials.txt`
171173

172174
### Recyclarr (TRaSH quality profiles)
173175

174-
The setup script copied a template to `~/Media/config/recyclarr/recyclarr.yml`. Open it and replace the API key placeholders with the keys printed by configure.sh:
176+
The setup script copied a template to `<MEDIA_DIR>/config/recyclarr/recyclarr.yml` (default: `~/Media/config/recyclarr/recyclarr.yml`). Open it and replace the API key placeholders with the keys printed by configure.sh:
175177

176178
```bash
177179
open -a TextEdit ~/Media/config/recyclarr/recyclarr.yml
@@ -228,8 +230,9 @@ This installs:
228230
- Nightly backup (configs + databases, 14-day retention)
229231
- Download watchdog (stalled torrent auto-fix every 15 min)
230232
- Kometa scheduler (metadata refresh every 4 hours)
233+
- Log prune (daily log cleanup with 30-day retention)
231234

232-
Automation logs go to `~/Media/logs/` and launchd stdout/stderr logs go to `~/Media/logs/launchd/`.
235+
Automation logs go to `<MEDIA_DIR>/logs/` and launchd stdout/stderr logs go to `<MEDIA_DIR>/logs/launchd/` (default `~/Media/...`).
233236

234237
### Optional: VPN Failover
235238

@@ -277,6 +280,7 @@ bash scripts/health-check.sh
277280
tail -50 ~/Media/logs/auto-heal.log
278281
tail -50 ~/Media/logs/download-watchdog.log
279282
tail -50 ~/Media/logs/vpn-failover.log
283+
tail -50 ~/Media/logs/log-prune.log
280284
```
281285

282286
**Manual VPN switch:**

bootstrap.sh

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ YELLOW='\033[1;33m'
1010
CYAN='\033[0;36m'
1111
NC='\033[0m'
1212

13+
BOOTSTRAP_DIR="$(cd "$(dirname "$0")" && pwd)"
1314
MEDIA_DIR="$HOME/Media"
1415
INSTALL_DIR="$HOME/mac-media-stack-advanced"
1516
NON_INTERACTIVE=false
@@ -75,60 +76,65 @@ echo "======================================="
7576
echo ""
7677

7778
# Detect container runtime
78-
detect_installed_runtime() {
79-
local has_orbstack=0
80-
local has_docker_desktop=0
81-
82-
if [[ -d "/Applications/OrbStack.app" ]] || command -v orbstack &>/dev/null; then
83-
has_orbstack=1
84-
fi
85-
if [[ -d "/Applications/Docker.app" ]]; then
86-
has_docker_desktop=1
87-
fi
88-
89-
if [[ $has_orbstack -eq 1 && $has_docker_desktop -eq 1 ]]; then
90-
echo "OrbStack or Docker Desktop"
91-
elif [[ $has_orbstack -eq 1 ]]; then
92-
echo "OrbStack"
93-
elif [[ $has_docker_desktop -eq 1 ]]; then
94-
echo "Docker Desktop"
95-
else
96-
echo "none"
97-
fi
98-
}
79+
if [[ -f "$BOOTSTRAP_DIR/scripts/lib/runtime.sh" ]]; then
80+
# shellcheck source=scripts/lib/runtime.sh
81+
source "$BOOTSTRAP_DIR/scripts/lib/runtime.sh"
82+
else
83+
detect_installed_runtime() {
84+
local has_orbstack=0
85+
local has_docker_desktop=0
9986

100-
detect_running_runtime() {
101-
local os_name
102-
os_name=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)
103-
if [[ "$os_name" == *"OrbStack"* ]]; then
104-
echo "OrbStack"
105-
elif [[ "$os_name" == *"Docker Desktop"* ]]; then
106-
echo "Docker Desktop"
107-
else
108-
echo "Docker"
109-
fi
110-
}
87+
if [[ -d "/Applications/OrbStack.app" ]] || command -v orbstack &>/dev/null; then
88+
has_orbstack=1
89+
fi
90+
if [[ -d "/Applications/Docker.app" ]]; then
91+
has_docker_desktop=1
92+
fi
11193

112-
wait_for_service() {
113-
local name="$1"
114-
local url="$2"
115-
local max_attempts="${3:-45}"
116-
local attempt=0
117-
local status
118-
119-
while [[ $attempt -lt $max_attempts ]]; do
120-
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$url" 2>/dev/null || true)
121-
if [[ "$status" =~ ^(200|301|302|401|403)$ ]]; then
122-
echo -e " ${GREEN}OK${NC} $name is reachable"
123-
return 0
94+
if [[ $has_orbstack -eq 1 && $has_docker_desktop -eq 1 ]]; then
95+
echo "OrbStack or Docker Desktop"
96+
elif [[ $has_orbstack -eq 1 ]]; then
97+
echo "OrbStack"
98+
elif [[ $has_docker_desktop -eq 1 ]]; then
99+
echo "Docker Desktop"
100+
else
101+
echo "none"
124102
fi
125-
sleep 2
126-
attempt=$((attempt + 1))
127-
done
103+
}
104+
105+
detect_running_runtime() {
106+
local os_name
107+
os_name=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)
108+
if [[ "$os_name" == *"OrbStack"* ]]; then
109+
echo "OrbStack"
110+
elif [[ "$os_name" == *"Docker Desktop"* ]]; then
111+
echo "Docker Desktop"
112+
else
113+
echo "Docker"
114+
fi
115+
}
116+
117+
wait_for_service() {
118+
local name="$1"
119+
local url="$2"
120+
local max_attempts="${3:-45}"
121+
local attempt=0
122+
local status
123+
124+
while [[ $attempt -lt $max_attempts ]]; do
125+
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$url" 2>/dev/null || true)
126+
if [[ "$status" =~ ^(200|301|302|401|403)$ ]]; then
127+
echo -e " ${GREEN}OK${NC} $name is reachable"
128+
return 0
129+
fi
130+
sleep 2
131+
attempt=$((attempt + 1))
132+
done
128133

129-
echo -e " ${YELLOW}WARN${NC} $name is not reachable yet (continuing anyway)"
130-
return 1
131-
}
134+
echo -e " ${YELLOW}WARN${NC} $name is not reachable yet (continuing anyway)"
135+
return 1
136+
}
137+
fi
132138

133139
INSTALLED_RUNTIME=$(detect_installed_runtime)
134140

scripts/archive-media.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
set -euo pipefail
66

7+
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
8+
# shellcheck source=scripts/lib/media-path.sh
9+
source "$PROJECT_DIR/scripts/lib/media-path.sh"
10+
711
EXECUTE=0
812
DAYS=180
913
MIN_SIZE_GB=8
1014
TYPE="both"
11-
SOURCE_ROOT="$HOME/Media"
15+
SOURCE_ROOT="$(resolve_media_dir "$PROJECT_DIR")"
1216
ARCHIVE_ROOT=""
1317
LOG_FILE=""
1418
ONLY_WATCHED=0

scripts/auto-heal.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
# Media Stack Auto-Healer
33
# Runs hourly via launchd. Checks VPN and container health, restarts what's broken.
44

5-
BASE_DIR="$HOME/Media"
5+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
7+
# shellcheck source=scripts/lib/media-path.sh
8+
source "$SCRIPT_DIR/lib/media-path.sh"
9+
10+
BASE_DIR="$(resolve_media_dir "$PROJECT_DIR")"
611
LOG_DIR="$BASE_DIR/logs"
712
mkdir -p "$LOG_DIR"
813
LOG="$LOG_DIR/auto-heal.log"

scripts/backup.sh

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44

55
set -euo pipefail
66

7-
BASE_DIR="$HOME/Media"
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
9+
# shellcheck source=scripts/lib/media-path.sh
10+
source "$SCRIPT_DIR/lib/media-path.sh"
11+
12+
BASE_DIR="$(resolve_media_dir "$PROJECT_DIR")"
813
BACKUP_ROOT="$BASE_DIR/backups"
914
TS="$(date +%Y%m%d-%H%M%S)"
1015
OUT="$BACKUP_ROOT/$TS"
1116

1217
mkdir -p "$OUT"/{configs,dbs,state}
1318

1419
# Backup compose and env
15-
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
16-
cp "$SCRIPT_DIR/docker-compose.yml" "$OUT/" 2>/dev/null || true
20+
cp "$PROJECT_DIR/docker-compose.yml" "$OUT/" 2>/dev/null || true
1721

1822
# Backup config files
1923
find "$BASE_DIR/config" -maxdepth 2 -type f \( -name 'config.xml' -o -name 'config.yml' -o -name 'settings.json' -o -name '*.conf' \) -print0 | while IFS= read -r -d '' f; do
@@ -30,7 +34,7 @@ find "$BASE_DIR/config" -maxdepth 2 -type f -name '*.db' -print0 | while IFS= re
3034
done
3135

3236
# Snapshot container status
33-
if ! docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps > "$OUT/state/compose-ps.txt" 2>/dev/null; then
37+
if ! docker compose -f "$PROJECT_DIR/docker-compose.yml" ps > "$OUT/state/compose-ps.txt" 2>/dev/null; then
3438
echo "$(date '+%F %T') WARN: could not capture docker compose status snapshot" >> "$BASE_DIR/logs/backup.log"
3539
fi
3640

scripts/configure.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ echo " Prowlarr API Key: $PROWLARR_KEY"
325325
echo " Saved credentials: $CREDS_FILE"
326326
echo ""
327327
echo -e " ${YELLOW}Save these!${NC} You'll need the API keys for:"
328-
echo " - configs/recyclarr.yml (copy to ~/Media/config/recyclarr/)"
328+
echo " - configs/recyclarr.yml (copy to $MEDIA_DIR/config/recyclarr/)"
329329
echo " - .env (UN_SONARR_0_API_KEY and UN_RADARR_0_API_KEY for Unpackerr)"
330330
echo ""
331331
echo " Seerr: http://localhost:5055"

scripts/download-watchdog.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,23 @@
1818
import http.cookiejar
1919
from pathlib import Path
2020

21-
BASE_DIR = Path(os.path.expanduser("~/Media"))
21+
def load_media_dir() -> Path:
22+
env_media = os.getenv("MEDIA_DIR")
23+
if env_media:
24+
return Path(os.path.expanduser(env_media))
25+
26+
env_path = Path(__file__).resolve().parents[1] / ".env"
27+
if env_path.exists():
28+
for line in env_path.read_text(errors="ignore").splitlines():
29+
if line.startswith("MEDIA_DIR="):
30+
value = line.split("=", 1)[1].strip().strip('"').strip("'")
31+
if value:
32+
return Path(os.path.expanduser(value))
33+
34+
return Path.home() / "Media"
35+
36+
37+
BASE_DIR = load_media_dir()
2238
STATE_FILE = BASE_DIR / "state" / "download-watchdog-state.json"
2339
LOG_FILE = BASE_DIR / "logs" / "download-watchdog.log"
2440
RADARR_CFG = BASE_DIR / "config" / "radarr" / "config.xml"

scripts/franchise-sort.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,28 @@
2323
import logging
2424
import os
2525
import sys
26+
from pathlib import Path
27+
28+
def load_default_media_log_dir() -> str:
29+
env_media = os.getenv("MEDIA_DIR")
30+
if env_media:
31+
return os.path.expanduser(os.path.join(env_media, "logs"))
32+
33+
env_path = Path(__file__).resolve().parents[1] / ".env"
34+
if env_path.exists():
35+
for line in env_path.read_text(errors="ignore").splitlines():
36+
if line.startswith("MEDIA_DIR="):
37+
value = line.split("=", 1)[1].strip().strip('"').strip("'")
38+
if value:
39+
return os.path.expanduser(os.path.join(value, "logs"))
40+
41+
return os.path.expanduser("~/Media/logs")
42+
2643

2744
PLEX_URL = os.environ.get("PLEX_URL", "http://localhost:32400")
2845
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
2946
SECTION_ID = os.environ.get("PLEX_SECTION_ID", "1")
30-
LOG_DIR = os.environ.get("MEDIA_LOG_DIR", os.path.expanduser("~/Media/logs"))
47+
LOG_DIR = os.environ.get("MEDIA_LOG_DIR", load_default_media_log_dir())
3148
DRY_RUN = False
3249

3350

0 commit comments

Comments
 (0)