Skip to content

Commit e969def

Browse files
fix(hardening): corrections found by running against a real Tunarr and Plex
The previous three commits were verified by tests only. Running them against a live 156-channel Tunarr (v1.3.13) and a real Plex found two things tests could not have caught, because both depend on the shape of actual data and on how a real Plex server is configured. Backups were 151 MB each A real snapshot of 156 channels is 20,567 programs of full metadata — 151 MB uncompressed. At the retention of 10 I'd picked, that's 1.5 GB quietly accumulating in a user's data directory, which on a NAS is a genuine problem and precisely the kind of thing a "safety net" should not cause. Backups are now gzipped: 151 MB -> 12 MB (10.2x) in about 1.4s of compression, and retention drops from 10 to 3. ~36 MB total instead of 1.5 GB. The measured numbers are recorded in the code comment and CLAUDE.md so nobody raises either value without re-measuring. Dropped the indent= while here — the file is read by a restore, not a human. Verified end to end against the live server: 156 channels, 20,567 programs, 12.0 MB, reads back cleanly from gzip with all programming intact. A wrong Plex token passed the connection test check_plex reported ok:True for the literal token "garbage". Not a code bug — this Plex has "Allow unauthenticated access on the local network" enabled, so every local request succeeds even with NO token at all. That's a common self-hosted configuration, which makes it a common false green: onboarding would bless a wrong token and the user would hit the failure much later. check_plex now does one extra tokenless request when the first succeeds. If that also succeeds, the token was not the reason we got in, and the result carries token_unverified + an explanatory note. The onboarding alert renders yellow rather than green in that case. Programmarr can still read the library either way, so this is a caveat, not a failure — but claiming the token was validated when it demonstrably wasn't is the kind of small dishonesty that costs trust later. Also: - .gitignore now covers tunarr_backup_* at the project ROOT. data/* already covered the Docker path, but CLI runs write to the working directory, so a 12 MB snapshot was one `git add -A` away from being committed. - README gains a Backups section documenting where snapshots go, how big they are, how long they're kept, and how to unzip one. A safety net nobody knows how to use is not a safety net. States plainly there's no restore command yet, rather than implying one exists. Verified against real infrastructure, all read-only (nothing deployed, nothing deleted): Tunarr 1.3.13 detected via /api/version; all four required endpoints answer 200; build_library_index returned 4100 movies + 401 shows across 4 Plex sources; create.py --probe resolved all 59 channels with content. 345 tests pass. Still unproven: Tunarr basic auth end-to-end, since this Tunarr has auth disabled — the header is sent, but nothing has enforced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 97a812b commit e969def

10 files changed

Lines changed: 122 additions & 20 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
config.json
33
config*.json
44
channels.json
5+
# Pre-delete Tunarr lineup snapshots. ~12 MB each on a 156-channel server, and
6+
# CLI runs write them to the project root (Docker writes them under data/).
7+
tunarr_backup_*.json
8+
tunarr_backup_*.json.gz
59
channels*.json
610
*.csv
711
backup_*.json

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ and the code — don't restate them here.
165165
- **`channel_blocks.py`** — shared, **pure, importable** channel-numbering logic (no `config.json`/argv). `assign_numbers(order, counts, start)` packs categories tight sequentially; `resolve_order(configured)` validates/fills the configured order against `CANONICAL_ORDER`. Single source of truth for compose, the LLM prompt, and `generate_no_ai`. **Must stay in the Dockerfile `COPY` line.**
166166
- **`generate_from_collections.py`** — one channel per Plex collection via `{"collection":"Name"}`. Manages the collection block (default ch 80+): keeps everything below `--base`, regenerates from `--base` up. Re-run any time Kometa changes collections.
167167
- **`channel_engine.py`** — shared, **pure, importable** resolution engine (no `config.json`/argv/`sys.exit`), so it's safe to import into the long-lived FastAPI process. Holds the resolution helpers, franchise `match_titles` (word-boundary), and the in-place live-channel updaters (`read_channel_programming`, `update_channel_in_place`). Imported by `create.py` at runtime and in-process by `recipes_router.py` — **must stay in the Dockerfile `COPY` line**. `build_library_index` indexes **all** enabled movie and shows libraries (not just the first — a Plex server can expose several, e.g. `TV Shows` + `Cartoons`), and indexes a show that appears in more than one library **once**, preferring the copy with the most playable (non-`missing`) episodes so a dead duplicate can't shadow the real one or inflate the live-diff into churn. Tunarr auth lives here as module state (`set_tunarr_auth` / `set_tunarr_auth_from_config` / `tunarr_headers`) rather than a parameter on ~20 functions — callers pass the values in, so the no-`config.json` rule still holds; **every** caller that loads a config must call `set_tunarr_auth_from_config(cfg)` before hitting Tunarr. `build_library_index` distinguishes a **failed** library fetch from an **empty** one: all libraries failing raises rather than returning an empty index (which would otherwise deploy channels with no content); a partial failure warns and continues.
168-
- **`create.py`** — thin CLI wrapper around `channel_engine`. Reads `channels.json`, indexes the Tunarr library (case-insensitive exact title match), and deploys (delete-then-create; `--from N` scopes, `--protect N1,N2` preserves specific channels). Builds 30-day rolling random schedules (no dead air). The delete/recreate path is **initial-deploy only** — never for live channels. Before any destructive delete it writes a timestamped `tunarr_backup_*.json` (channel + raw `/programming` payload, last 10 kept) — the only way back from a wipe of a lineup Programmarr didn't create. Probe runs never write one.
168+
- **`create.py`** — thin CLI wrapper around `channel_engine`. Reads `channels.json`, indexes the Tunarr library (case-insensitive exact title match), and deploys (delete-then-create; `--from N` scopes, `--protect N1,N2` preserves specific channels). Builds 30-day rolling random schedules (no dead air). The delete/recreate path is **initial-deploy only** — never for live channels. Before any destructive delete it writes a timestamped **gzipped** `tunarr_backup_*.json.gz` (channel + raw `/programming` payload, last **3** kept) — the only way back from a wipe of a lineup Programmarr didn't create. Probe runs never write one. **Measured on a real 156-channel Tunarr: 151 MB raw, 12 MB gzipped, ~20s** — that measurement is why it's compressed and why retention is 3, not 10; don't raise either without re-measuring.
169169
- **`fetch_images.py`** — sets every channel's Tunarr icon. Verified TMDB logos for
170170
solo-title/marathon/franchise/network/studio channels (the result's name must match the
171171
query after normalization — never `results[0]`); generated badge art for every other kind

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ First run shows an onboarding wizard — create a login, enter your Tunarr and P
6464

6565
> **Heads up on your first deploy.** Programmarr's default deploy mode replaces the channels it
6666
> manages. If you already have a hand-built Tunarr lineup, use **Add/Edit** mode rather than
67-
> **Nuke**. Either way, Programmarr writes a `tunarr_backup_*.json` snapshot into your data
68-
> directory before deleting anything.
67+
> **Nuke**. Either way, Programmarr snapshots your existing lineup before deleting anything —
68+
> see [Backups](#backups) below.
6969
7070
### TrueNAS
7171

@@ -228,6 +228,32 @@ It ships **off**. Turn it on in **Settings → Live Channels**, flip the **"Auto
228228

229229
---
230230

231+
## Backups
232+
233+
Before Programmarr deletes any Tunarr channel, it writes a compressed snapshot of everything
234+
it's about to remove — each channel plus its full programming — to your data directory:
235+
236+
```
237+
data/tunarr_backup_20260819T195358Z.json.gz
238+
```
239+
240+
On a 156-channel server that's about 12 MB and takes ~20 seconds. The **3 most recent** are
241+
kept; older ones are removed automatically. Probe/dry runs never write one.
242+
243+
**This is a safety net, not a feature** — there's no restore button yet. If you need to go
244+
back, the file is plain JSON once unzipped, containing everything Tunarr's API needs:
245+
246+
```bash
247+
gunzip -c data/tunarr_backup_20260819T195358Z.json.gz > lineup.json
248+
```
249+
250+
Each entry has the original `channel` object (including its Tunarr `id`) and the raw
251+
`programming` payload. If you ever need to recover a lineup, open an
252+
[issue](https://github.com/AlpineArchitecture/programmarr/issues) with that file's structure
253+
and I'll help — and if it happens to anyone, a proper restore command moves to the top of the list.
254+
255+
---
256+
231257
## Configuration
232258

233259
Everything is set through the UI. Config is stored in `./data/config.json` (bind-mounted, never baked into the image).

backend/routers/status_router.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,25 @@ def check_plex(url: str, token: str) -> dict:
116116
return {"ok": False, "error": "No Plex URL given"}
117117
if not token:
118118
return {"ok": False, "error": "No Plex token given"}
119-
# /library/sections proves the token works AND that there's a library to read,
120-
# which is what Programmarr actually needs — a bare / accepts any token.
119+
# /library/sections proves we can read the library, which is what Programmarr
120+
# actually needs — a bare / accepts any token.
121121
result = {**probe(f"{url}/library/sections?X-Plex-Token={token}"), "url": url}
122+
123+
if result.get("ok"):
124+
# Many self-hosted Plex servers have "Allow unauthenticated access on the
125+
# local network" on, which makes EVERY local request succeed — including
126+
# one with a garbage token. Reporting that as "token valid" would be a
127+
# false green. One extra request tells us whether the token was actually
128+
# the reason we got in.
129+
no_token = probe(f"{url}/library/sections")
130+
if no_token.get("ok"):
131+
result["token_unverified"] = True
132+
result["note"] = (
133+
"Your Plex allows unauthenticated access from this network, so the "
134+
"token itself could not be verified. Programmarr can read your "
135+
"library either way — but double-check the token if you later "
136+
"restrict local access."
137+
)
122138
return result
123139

124140

backend/tests/test_connection_check.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,39 @@ def _f(req, timeout=None):
115115

116116
sr.check_plex("http://plex:32400", "tok")
117117
assert "/library/sections" in seen["url"]
118+
119+
120+
# ── LAN-permissive Plex: a green result that can't vouch for the token ────────
121+
122+
def test_lan_permissive_plex_flags_unverified_token(monkeypatch):
123+
"""Many self-hosted Plex servers allow unauthenticated local access, so EVERY
124+
local request succeeds — including one with a garbage token. Found on a real
125+
server: check_plex returned ok for the string 'garbage'. Reporting that as a
126+
validated token is a false green."""
127+
monkeypatch.setattr(sr.urllib.request, "urlopen", urlopen_returning(200))
128+
129+
out = sr.check_plex("http://plex:32400", "garbage")
130+
assert out["ok"] is True # Programmarr CAN read the library
131+
assert out["token_unverified"] is True
132+
assert "could not be verified" in out["note"]
133+
134+
135+
def test_locked_down_plex_does_verify_the_token(monkeypatch):
136+
"""When Plex actually enforces the token, a successful call means something —
137+
no caveat should be attached."""
138+
def _f(req, timeout=None):
139+
if "X-Plex-Token" not in req.full_url:
140+
raise http_error(401)
141+
return FakeResp(200)
142+
monkeypatch.setattr(sr.urllib.request, "urlopen", _f)
143+
144+
out = sr.check_plex("http://plex:32400", "good-token")
145+
assert out["ok"] is True
146+
assert "token_unverified" not in out
147+
assert "note" not in out
148+
149+
150+
def test_bad_token_on_a_locked_down_plex_fails(monkeypatch):
151+
monkeypatch.setattr(sr.urllib.request, "urlopen", urlopen_raising(http_error(401)))
152+
out = sr.check_plex("http://plex:32400", "bad")
153+
assert out["ok"] is False

backend/tests/test_delete_backup.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
the first DELETE goes out. Probe runs must never write one.
88
"""
99

10+
import gzip
1011
import json
1112
import sys
1213
from pathlib import Path
@@ -48,10 +49,11 @@ def test_backup_written_before_delete(tmp_path, monkeypatch):
4849

4950
create.delete_channels("http://tunarr", probe=False)
5051

51-
backups = list(tmp_path.glob("tunarr_backup_*.json"))
52+
backups = list(tmp_path.glob("tunarr_backup_*.json.gz"))
5253
assert len(backups) == 1, "a destructive delete must leave exactly one snapshot"
5354

54-
data = json.loads(backups[0].read_text(encoding="utf-8"))
55+
with gzip.open(backups[0], "rt", encoding="utf-8") as fh:
56+
data = json.load(fh)
5557
assert len(data["channels"]) == 2
5658
names = {c["channel"]["name"] for c in data["channels"]}
5759
assert names == {"Sitcom Marathon", "80s Action"}
@@ -71,7 +73,7 @@ def test_probe_writes_no_backup(tmp_path, monkeypatch):
7173

7274
create.delete_channels("http://tunarr", probe=True)
7375

74-
assert not list(tmp_path.glob("tunarr_backup_*.json"))
76+
assert not list(tmp_path.glob("tunarr_backup_*.json.gz"))
7577
assert not [c for c in calls if c[0] == "DELETE"]
7678

7779

@@ -96,9 +98,9 @@ def test_rotation_keeps_last_n(tmp_path, monkeypatch):
9698
monkeypatch.chdir(tmp_path)
9799
monkeypatch.setattr(create, "BACKUP_KEEP", 3)
98100
for i in range(5):
99-
(tmp_path / f"tunarr_backup_2020010{i}T000000Z.json").write_text("{}", encoding="utf-8")
101+
(tmp_path / f"tunarr_backup_2020010{i}T000000Z.json.gz").write_text("{}", encoding="utf-8")
100102
monkeypatch.setattr(create, "api", fake_api([]))
101103

102104
create.backup_channels("http://tunarr", CHANNELS)
103105

104-
assert len(list(tmp_path.glob("tunarr_backup_*.json"))) == 3
106+
assert len(list(tmp_path.glob("tunarr_backup_*.json.gz"))) == 3

create.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import argparse
1616
import glob
17+
import gzip
1718
import json
1819
import os
1920
import sys
@@ -52,7 +53,12 @@ def load_config():
5253

5354
# ── Destructive-op backup ──────────────────────────────────────────────────────
5455

55-
BACKUP_KEEP = 10
56+
# Measured on a real 156-channel Tunarr: the uncompressed snapshot was 151 MB
57+
# (full program metadata for ~1000 programs per channel). Ten of those would put
58+
# 1.5 GB in a user's data directory, so backups are gzipped (10x, ~1.4s) and we
59+
# keep fewer of them. 3 x ~15 MB is a safety net; 10 x 151 MB is a disk problem.
60+
BACKUP_KEEP = 3
61+
BACKUP_GLOB = "tunarr_backup_*.json.gz"
5662

5763

5864
def backup_channels(tunarr_url, channels):
@@ -77,21 +83,23 @@ def backup_channels(tunarr_url, channels):
7783
snapshot.append(entry)
7884

7985
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
80-
path = f"tunarr_backup_{ts}.json"
81-
with open(path, "w", encoding="utf-8") as f:
86+
path = f"tunarr_backup_{ts}.json.gz"
87+
# No indent: this file is read by a restore, not by a human, and pretty
88+
# printing 150 MB of JSON buys nothing.
89+
with gzip.open(path, "wt", encoding="utf-8") as f:
8290
json.dump({"saved_at": ts, "tunarr_url": tunarr_url,
83-
"channels": snapshot}, f, indent=2)
91+
"channels": snapshot}, f)
8492

8593
# ponytail: keep the last N by filename (timestamps sort lexically); no
8694
# rotation config until someone asks for one.
87-
old = sorted(glob.glob("tunarr_backup_*.json"))[:-BACKUP_KEEP]
88-
for stale in old:
95+
for stale in sorted(glob.glob(BACKUP_GLOB))[:-BACKUP_KEEP]:
8996
try:
9097
os.remove(stale)
9198
except OSError:
9299
pass
93100

94-
print(f" Backed up {len(snapshot)} channels -> {os.path.abspath(path)}")
101+
size_mb = os.path.getsize(path) / 1e6
102+
print(f" Backed up {len(snapshot)} channels ({size_mb:.1f} MB) -> {os.path.abspath(path)}")
95103
return path
96104
except Exception as e:
97105
print(f" ! WARNING: could not write pre-delete backup: {e}")

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ change; don't restate it back into `CLAUDE.md`.
1010
| Method | Path | Description |
1111
|--------|------|-------------|
1212
| GET | `/api/status` | Connection status: `{tunarr:{ok,url,error?}, plex:{ok,url,error?}}` for the **saved** config. An HTTP error is `ok:false` — a 401 from an auth-protected Tunarr is a failure, not a healthy server |
13-
| POST | `/api/test-connection` | Body `{tunarr_url, tunarr_username, tunarr_password, plex_url, plex_token}`. Tests credentials the user has typed but has **not saved** — this is what lets Onboarding refuse to green-light a typo. Only the sides supplied are tested. A secret sent as the mask falls back to the stored value. Never leaves the tested credential applied to the process |
13+
| POST | `/api/test-connection` | Body `{tunarr_url, tunarr_username, tunarr_password, plex_url, plex_token}`. Tests credentials the user has typed but has **not saved** — this is what lets Onboarding refuse to green-light a typo. Only the sides supplied are tested. A secret sent as the mask falls back to the stored value. Never leaves the tested credential applied to the process. Plex results may carry `token_unverified: true` + a `note` when the server allows unauthenticated local access — the library is readable but the token itself was not proven |
1414
| GET | `/api/guide` | Fetch and parse Tunarr's XMLTV feed. Returns `{channels:[{number,name,icon?}], programmes:[{number,start,stop,title,episode?}], error?}`. Channels sorted by number; timestamps as ISO 8601. Never throws — returns `error` field on failure. |
1515
| GET | `/api/tunarr/channels` | Live channel list from Tunarr: `[{number,name,id?}]` |
1616
| GET | `/api/tunarr/filler-lists` | Filler lists in Tunarr: `[{id,name,contentCount}]` — powers the Commercials picker |

frontend/src/api/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,14 @@ export const api = {
172172
// ── Types ──────────────────────────────────────────────────────────────────────
173173

174174
export interface PlexServer { name: string; url: string; token: string }
175-
export interface ConnStatus { ok: boolean; url: string; error?: string }
175+
export interface ConnStatus {
176+
ok: boolean; url: string; error?: string;
177+
version?: string | null;
178+
// Set when Plex let us in WITHOUT a token (LAN-permissive server), so a
179+
// green result does not actually vouch for the token.
180+
token_unverified?: boolean;
181+
note?: string;
182+
}
176183
export interface UpdateInfo {
177184
enabled: boolean;
178185
update_available?: boolean;

frontend/src/pages/Onboarding.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ export default function Onboarding({ onComplete }: Props) {
313313
return (
314314
<Alert
315315
key={k}
316-
color={r.ok ? 'green' : 'red'}
316+
color={!r.ok ? 'red' : r.note ? 'yellow' : 'green'}
317317
icon={r.ok ? <IconCheck size={16} /> : <IconAlertTriangle size={16} />}
318318
p="xs"
319319
>
@@ -323,6 +323,9 @@ export default function Onboarding({ onComplete }: Props) {
323323
{!r.ok && r.error && (
324324
<Text size="xs" c="dimmed">{r.error}</Text>
325325
)}
326+
{r.ok && r.note && (
327+
<Text size="xs" c="dimmed">{r.note}</Text>
328+
)}
326329
</Alert>
327330
);
328331
})}

0 commit comments

Comments
 (0)