Skip to content

Commit 2fd97e3

Browse files
feat: icon overhaul + multi-source library support
Merge feature/icon-overhaul → master ### Icons - badge_renderer: generated channel badges (glyph + stamped name, Tabler MIT + Anton OFL) - icon_engine: verified TMDB searches — name must match query; kind-gated policy - fetch_images rewrite: kind-gated TMDB + badge fallback; pinned channels skipped - Tunarr helpers: multipart image upload + set/clear channel icon - Channels-editor icon control: badge / TMDB / custom / reset with pin support - POST /channels/{number}/icon endpoint ### Multi-source library support - export.py: auto-supplements primary Plex export from all other Tunarr sources - channel_engine: indexes all Tunarr Plex sources (not just first) - pipeline_router: library picker aggregates all Tunarr-connected sources - Export step: divider-labelled source groups in the library picker ### Planner - Nuke mode now clears candidate selections on setup screen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents b6ec8d5 + 272c239 commit 2fd97e3

62 files changed

Lines changed: 3587 additions & 620 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ All config lives in `config.json` (gitignored — in `data/` for Docker, project
136136
See `config.json.example` for the full shape. Keys:
137137

138138
- `tunarr_url`, `plex_url`, `plex_token` — required connection settings.
139-
- `tmdb_api_key` — optional; only `fetch_images.py` uses it. Free key at https://www.themoviedb.org/settings/api
139+
- `tmdb_api_key` — optional; used by `fetch_images.py` for verified TMDB logo lookups.
140+
Without it, every channel gets a generated badge instead (icons still work). Free key at
141+
https://www.themoviedb.org/settings/api
140142
- `auth_username` / `auth_password` — optional HTTP Basic Auth. **Both blank = auth disabled.** When set, every backend request requires them.
141143
- `recipes_enabled` (bool, default `false`), `recipe_interval_hours` (number, default `12`) — live-channel scheduler (see Live Channels).
142144
- `tunarr_channel_group` (string, optional) — Tunarr `groupTitle` for all created channels (default `"tunarr"`).
@@ -159,7 +161,20 @@ and the code — don't restate them here.
159161
- **`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.
160162
- **`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.
161163
- **`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.
162-
- **`fetch_images.py`** — for solo-title channels, finds the best TMDB clearlogo and sets the Tunarr channel `icon.path` so Plex shows a real logo in the guide. Multi-title channels are skipped. Dry-run by default; `--apply` to commit. Requires `tmdb_api_key`.
164+
- **`fetch_images.py`** — sets every channel's Tunarr icon. Verified TMDB logos for
165+
solo-title/marathon/franchise/network/studio channels (the result's name must match the
166+
query after normalization — never `results[0]`); generated badge art for every other kind
167+
and any TMDB miss. Badges upload via Tunarr `POST /api/upload/image`. Channels pinned from
168+
the Channels editor (`"icon": {"pinned": true}` in channels.json) are skipped; the script
169+
never writes channels.json. `tmdb_api_key` is optional — without it everything badges.
170+
Dry-run by default; `--apply` to commit.
171+
- **`icon_engine.py`** — shared, **pure, importable** icon policy + verified TMDB searches +
172+
Tunarr upload/icon helpers (no `config.json`/argv/`sys.exit`). Imported by `fetch_images.py`
173+
and in-process by `channels_router.py`. **Must stay in the Dockerfile `COPY` line.**
174+
- **`badge_renderer.py`** — shared, **pure** Pillow badge rendering from committed
175+
`badge_assets/` (Tabler glyphs MIT, Anton font OFL; regenerate via
176+
`scripts/make_badge_assets.py`). Badges carry the channel name because Plex hides text
177+
labels once an icon is set. **Module + `badge_assets/` must stay in the Dockerfile `COPY` lines.**
163178
- **`sync_plex.py`** — reconciles Tunarr's XMLTV channel list into Plex's DVR mapping (read-then-update; **never deletes** the DVR). Falls back to printing the XMLTV URL + manual steps.
164179

165180
## Channel Numbering Scheme
@@ -277,6 +292,12 @@ the basis for future era-matched pooling (90s ads → 90s channel; see `docs/ide
277292
**Mid-roll (ads inside a show) is deliberately not used — it doesn't stream on hardware-accelerated
278293
(QSV) Tunarr; see [`docs/tunarr-commercials-findings.md`](docs/tunarr-commercials-findings.md).**
279294

295+
**Icon pin (optional).** A channel may carry `"icon": {"mode": "badge"|"tmdb"|"custom",
296+
"url": "…", "pinned": true}` — written only by `POST /api/channels/{number}/icon` (the
297+
Channels-editor icon control). `fetch_images.py` skips pinned channels and never writes
298+
this field; removing it (the editor's "Reset to automatic") returns the channel to the
299+
automatic art pass.
300+
280301
## API Endpoints
281302

282303
All endpoint tables — **Pipeline**, **Recipe**, **Tunarr**, **TMDB**, **Plex** — live in
@@ -303,10 +324,12 @@ AI" toggle is on; Collections only if opted in.
303324
- **Saved on every change** via a debounced (~500ms) PUT in `PlannerStep` (guarded by
304325
`restoredRef` so the first-mount restore never overwrites the file).
305326
- **Restored on every Planner mount** (both Nuke and Edit modes) — `isEdit` gate removed.
306-
- **Nuke does NOT clear picks**`handleNuke` is intentionally a no-op for state.
307-
- **"Clear all"** in the Planner build bar is the only thing that resets picks: clears
308-
`selected`, `curate`, active genre/decade toggles and all batch toggles to defaults, then
309-
calls `DELETE /pipeline/planner-state`.
327+
- **Nuke clears candidate selections**`handleNuke` resets `selected`, `curate`, and
328+
`aiExtras`; genres/decades/comm/autoUpdate are preserved. The debounced save persists the
329+
blank selections to `planner_state.json` automatically.
330+
- **"Clear all"** in the Planner build bar does a full reset: clears `selected`, `curate`,
331+
active genre/decade toggles and all batch toggles to defaults, then calls
332+
`DELETE /pipeline/planner-state`.
310333
Contains: `activeGenres, activeDecades, selected, curate, aiExtras, commEnabled, commListId,
311334
commPad, autoUpdate`. API: `GET/PUT/DELETE /api/pipeline/planner-state`.
312335
- **The Planner is deterministic:** selected candidates post as `CandidateSpec[]` to
@@ -345,9 +368,10 @@ The blow-by-blow of each step's components and props is the code's job — read
345368
**Plex guide shows channel icons, not text names.** When Plex receives a channel with any icon
346369
in the XMLTV feed, it renders only the icon and suppresses the text label — a Plex design
347370
decision, not a Programmarr bug. Tunarr injects a default icon for every channel, so without
348-
custom icons the guide is a wall of identical icons. `fetch_images.py` gives solo-title channels
349-
real TMDB logos; multi-title channels still show the Tunarr default until a logo strategy exists
350-
for them. Refreshing/restarting Plex does not change this.
371+
custom icons the guide is a wall of identical icons. `fetch_images.py` now gives **every**
372+
channel an icon: verified TMDB logos where trustworthy, generated name-stamped badges
373+
everywhere else — so the guide is readable even though Plex hides the text labels.
374+
Refreshing/restarting Plex does not change the icon-suppression behavior itself.
351375

352376
## Git Workflow
353377

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ COPY backend/ ./backend/
2525

2626
# Pipeline scripts, prompt template, and static data catalogs
2727
COPY export.py create.py channel_engine.py channel_blocks.py generate_no_ai.py generate_from_collections.py \
28-
fetch_images.py sync_plex.py PROMPT.md programming_blocks.json themed_keywords.json ./
28+
fetch_images.py sync_plex.py icon_engine.py badge_renderer.py PROMPT.md programming_blocks.json themed_keywords.json ./
29+
COPY badge_assets/ ./badge_assets/
2930

3031
# Built React app → served as static files by FastAPI
3132
COPY --from=frontend-build /build/dist ./backend/static/

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ fastapi>=0.110.0
22
uvicorn[standard]>=0.27.0
33
python-multipart>=0.0.9
44
aiofiles>=23.2.1
5+
pillow>=10.0.0

backend/routers/channels_router.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import channel_engine # noqa: E402
1616
import scheduler # noqa: E402 (shared deploy_lock)
17+
import badge_renderer # noqa: E402
18+
import icon_engine # noqa: E402
1719

1820
router = APIRouter()
1921
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent.parent))
@@ -142,6 +144,87 @@ def _do():
142144
raise HTTPException(409, str(e))
143145

144146

147+
@router.post("/channels/{number}/icon")
148+
async def channel_icon(number: int, body: dict):
149+
"""Set or pin a channel's Tunarr icon.
150+
151+
body: {"mode": "badge" | "tmdb" | "custom" | "clear", "url": "..."(custom only)}
152+
badge/tmdb/custom write a pin into channels.json ("icon": {..., "pinned": true})
153+
so automatic art passes (fetch_images.py) skip the channel; "clear" resets the
154+
Tunarr icon to default and removes the pin (back to automatic).
155+
"""
156+
mode = (body or {}).get("mode")
157+
if mode not in ("badge", "tmdb", "custom", "clear"):
158+
raise HTTPException(422, "mode must be one of: badge, tmdb, custom, clear")
159+
160+
data = load()
161+
ch = next((c for c in data.get("channels", []) if c.get("number") == number), None)
162+
if ch is None:
163+
raise HTTPException(404, f"Channel {number} not in channels.json")
164+
165+
cfg = _load_config()
166+
tunarr_url = cfg.get("tunarr_url", "").rstrip("/")
167+
if not tunarr_url:
168+
raise HTTPException(400, "Tunarr not configured")
169+
170+
name = (ch.get("name") or "").strip()
171+
172+
def _do():
173+
summary = channel_engine.find_channel_by_number(tunarr_url, number)
174+
if summary is None:
175+
raise channel_engine.ChannelEngineError(
176+
f"Channel #{number} not in Tunarr — deploy it first")
177+
tch = icon_engine.get_full_channel(tunarr_url, summary["id"])
178+
if tch is None:
179+
raise channel_engine.ChannelEngineError("Could not read channel from Tunarr")
180+
181+
if mode == "clear":
182+
if not icon_engine.clear_tunarr_channel_icon(tunarr_url, tch):
183+
raise channel_engine.ChannelEngineError("Tunarr icon reset failed")
184+
return ""
185+
186+
if mode == "custom":
187+
url = (body.get("url") or "").strip()
188+
if not url:
189+
raise channel_engine.ChannelEngineError("url required for custom mode")
190+
elif mode == "badge":
191+
spec = icon_engine.load_spec_hints(
192+
DATA_DIR / "planner_state.json").get(name.lower(), {})
193+
png = badge_renderer.render_badge(
194+
name, kind=spec.get("kind"), genre=icon_engine.spec_genre(spec))
195+
url = icon_engine.upload_image_to_tunarr(
196+
tunarr_url, png, f"programmarr-ch{number}-{os.urandom(4).hex()}.png")
197+
else: # tmdb
198+
key = cfg.get("tmdb_api_key", "")
199+
if not key:
200+
raise channel_engine.ChannelEngineError(
201+
"tmdb_api_key not configured — use a badge or custom URL")
202+
spec = icon_engine.load_spec_hints(
203+
DATA_DIR / "planner_state.json").get(name.lower(), {})
204+
url = icon_engine.resolve_tmdb_logo(
205+
icon_engine.icon_attempts(ch, spec.get("kind")), key)
206+
if not url:
207+
raise channel_engine.ChannelEngineError(
208+
"No verified TMDB logo for this channel — use a badge or custom URL")
209+
210+
if not icon_engine.set_tunarr_channel_icon(tunarr_url, tch, url):
211+
raise channel_engine.ChannelEngineError("Tunarr icon update failed")
212+
return url
213+
214+
try:
215+
async with scheduler.deploy_lock:
216+
url = await asyncio.to_thread(_do)
217+
except channel_engine.ChannelEngineError as e:
218+
raise HTTPException(409, str(e))
219+
220+
if mode == "clear":
221+
ch.pop("icon", None)
222+
else:
223+
ch["icon"] = {"mode": mode, "url": url, "pinned": True}
224+
save(data)
225+
return {"ok": True, "mode": mode, "url": url}
226+
227+
145228
@router.get("/library/titles")
146229
def library_titles():
147230
csv = DATA_DIR / "plex_library.csv"

backend/routers/config_router.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class ConfigModel(BaseModel):
1515
tunarr_url: str = ""
1616
plex_url: str = ""
1717
plex_token: str = ""
18+
# List of {name, url, token} Plex server entries. Replaces the legacy scalar
19+
# plex_url/plex_token pair; both are kept for CLI backward compat.
20+
plex_servers: list = []
1821
tmdb_api_key: str = ""
1922
auth_username: str = ""
2023
auth_password: str = ""
@@ -47,6 +50,11 @@ def get_config():
4750
config["plex_token"] = MASK
4851
if config.get("tmdb_api_key"):
4952
config["tmdb_api_key"] = MASK
53+
if config.get("plex_servers"):
54+
config["plex_servers"] = [
55+
{**s, "token": MASK} if s.get("token") else s
56+
for s in config["plex_servers"]
57+
]
5058
return config
5159

5260

@@ -57,11 +65,19 @@ def save_config(config: ConfigModel):
5765
for field in ("auth_password", "plex_token", "tmdb_api_key"):
5866
if data.get(field) == MASK:
5967
data[field] = existing.get(field, "")
60-
# channel_order is a list, not a clearable string field: only overwrite it when the
61-
# caller actually sends a non-empty list. An empty [] (e.g. an Onboarding save that
62-
# skips the order step) must NOT wipe a previously-saved order — so handle it before
63-
# the blank-field pruning below.
68+
# Lists must bypass the falsy-prune below ([] is falsy but meaningful as "no change").
69+
# channel_order: only overwrite when non-empty so an Onboarding save can't wipe it.
6470
order = data.pop("channel_order", None) or []
71+
# plex_servers: restore masked tokens by matching on URL, then write if non-empty.
72+
plex_servers = data.pop("plex_servers", None) or []
73+
if plex_servers:
74+
existing_by_url = {s.get("url", ""): s for s in existing.get("plex_servers", [])}
75+
restored = []
76+
for srv in plex_servers:
77+
if srv.get("token") == MASK:
78+
srv = {**srv, "token": existing_by_url.get(srv.get("url", ""), {}).get("token", "")}
79+
restored.append(srv)
80+
plex_servers = restored
6581
# Booleans must bypass the falsy-prune below (False is falsy → would be deleted).
6682
update_check = bool(data.pop("update_check_enabled", True))
6783
# Merge onto existing so keys the UI form doesn't manage are preserved — e.g.
@@ -76,6 +92,22 @@ def save_config(config: ConfigModel):
7692
merged.pop(k, None)
7793
if order:
7894
merged["channel_order"] = order
95+
if plex_servers:
96+
merged["plex_servers"] = plex_servers
97+
# Keep legacy scalar fields in sync with the primary server for CLI compat.
98+
merged["plex_url"] = plex_servers[0].get("url", "")
99+
merged["plex_token"] = plex_servers[0].get("token", "")
100+
elif merged.get("plex_servers"):
101+
# Legacy-format save (plex_url/plex_token fields) — sync plex_servers[0] to match
102+
# so both representations stay consistent.
103+
url = merged.get("plex_url", "")
104+
token = merged.get("plex_token", "")
105+
primary = dict(merged["plex_servers"][0])
106+
if url:
107+
primary["url"] = url
108+
if token:
109+
primary["token"] = token
110+
merged["plex_servers"] = [primary] + merged["plex_servers"][1:]
79111
merged["update_check_enabled"] = update_check
80112
# Preserve old channel_blocks key silently (don't crash; just ignore it).
81113
with open(_path(), "w") as f:
@@ -86,10 +118,11 @@ def save_config(config: ConfigModel):
86118
@router.get("/config/status")
87119
def config_status():
88120
config = load_config()
121+
has_plex = bool(config.get("plex_servers")) or bool(
122+
config.get("plex_url") and config.get("plex_token")
123+
)
89124
return {
90-
"configured": bool(
91-
config.get("tunarr_url") and config.get("plex_url") and config.get("plex_token")
92-
),
125+
"configured": bool(config.get("tunarr_url") and has_plex),
93126
"has_tmdb": bool(config.get("tmdb_api_key")),
94127
"has_auth": bool(config.get("auth_username") and config.get("auth_password")),
95128
}

backend/routers/pipeline_router.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -156,27 +156,54 @@ async def _locked_stream(script: str, args: list[str], tag: str) -> AsyncGenerat
156156

157157
class ExportOptions(BaseModel):
158158
no_crossref: bool = False
159-
movie_sections: Optional[list[str]] = None # None = auto-detect; [] = skip type entirely
159+
movie_sections: Optional[list[str]] = None # primary Plex section keys; None = auto, [] = skip
160160
tv_sections: Optional[list[str]] = None
161+
tunarr_movie_libs: Optional[list[str]] = None # Tunarr library UUIDs for extra sources
162+
tunarr_tv_libs: Optional[list[str]] = None
161163

162164

163165
@router.get("/pipeline/libraries")
164166
def list_libraries():
165167
cfg = _load_config()
166-
plex_url = cfg.get("plex_url", "").rstrip("/")
168+
plex_url = cfg.get("plex_url", "").rstrip("/").lower()
167169
plex_token = cfg.get("plex_token", "")
170+
tunarr_url = cfg.get("tunarr_url", "").rstrip("/")
168171
if not plex_url or not plex_token:
169172
raise HTTPException(400, "Plex not configured")
173+
174+
result = []
175+
176+
# ── Primary Plex sections (via Plex API, key prefixed "plex:") ─────────────
177+
srv_name = (cfg.get("plex_servers") or [{}])[0].get("name", "Your Library")
170178
try:
171179
data = _plex_get(plex_url, plex_token, "/library/sections")
172-
sections = data["MediaContainer"].get("Directory", [])
173-
except Exception as e:
174-
raise HTTPException(502, f"Could not reach Plex: {e}")
175-
return [
176-
{"key": s["key"], "title": s["title"], "type": s["type"]}
177-
for s in sections
178-
if s.get("type") in ("movie", "show")
179-
]
180+
for s in data["MediaContainer"].get("Directory", []):
181+
if s.get("type") not in ("movie", "show"):
182+
continue
183+
result.append({"key": f"plex:{s['key']}", "title": s["title"],
184+
"type": s["type"], "server": srv_name})
185+
except Exception:
186+
pass # unreachable — UI shows error from libError fallback
187+
188+
# ── All other Tunarr-connected sources (key prefixed "tunarr:") ────────────
189+
if tunarr_url:
190+
for source in (channel_engine.api(tunarr_url, "GET", "/api/media-sources") or []):
191+
# Skip the primary Plex — its sections are already listed above.
192+
if source.get("type") == "plex" and source.get("uri", "").rstrip("/").lower() == plex_url:
193+
continue
194+
source_name = source.get("name", source.get("type", "Unknown"))
195+
for lib in source.get("libraries", []):
196+
if not lib.get("enabled"):
197+
continue
198+
media_type = lib.get("mediaType", "")
199+
lib_type = ("movie" if media_type in ("movie", "movies")
200+
else "show" if media_type == "shows" else None)
201+
if not lib_type:
202+
continue
203+
result.append({"key": f"tunarr:{lib['id']}", "title": lib.get("name", lib["id"]),
204+
"type": lib_type, "server": source_name})
205+
206+
return result
180207

181208

182209
@router.post("/pipeline/export")
@@ -188,6 +215,10 @@ async def run_export(opts: ExportOptions = ExportOptions()):
188215
args += ["--movie-sections", ",".join(opts.movie_sections)]
189216
if opts.tv_sections is not None:
190217
args += ["--tv-sections", ",".join(opts.tv_sections)]
218+
if opts.tunarr_movie_libs is not None:
219+
args += ["--tunarr-movie-libs", ",".join(opts.tunarr_movie_libs)]
220+
if opts.tunarr_tv_libs is not None:
221+
args += ["--tunarr-tv-libs", ",".join(opts.tunarr_tv_libs)]
191222
return _sse(_stream("export.py", args, "export"))
192223

193224

0 commit comments

Comments
 (0)