Skip to content

Commit c1279b7

Browse files
Bump version to v0.2.1 — channel protection and offset features
## Channel protection (--protect flag) Users can now preserve specific Tunarr channels during a deploy instead of always nuking everything. Protected channel numbers are passed to create.py via --protect N1,N2,... and skipped during the deletion phase. - create.py: added --protect NUMS argparse flag; delete_channels() accepts a protect=set[int] and skips those numbers, printing "Preserving #N name (protected)" - pipeline_router.py: deploy endpoint accepts protected= query param and no_delete bool; DeployRequest Pydantic model adds protected_numbers: list[int] and no_delete: bool; deploy-selective passes --protect and --no-delete to create.py as needed ## Channel offset (--start flag) generate_no_ai.py now accepts --start N (default 10) which offsets all block ranges by N-10, so users can leave lower channel numbers free for channels they want to keep. The web UI passes this automatically based on the user's selection in the Channel Planner step. - generate_no_ai.py: --start N flag; all hardcoded block numbers (TV Marathons at 10, TV Blocks at 20, Movies at 30–49, Franchise at 50–69, Specialty at 70–71) now add offset; renamed loop var start -> yr_start to avoid shadowing - pipeline_router.py: get_prompt accepts start param and replaces block range strings in PROMPT.md (**10–19** etc.) and example numbers; run_no_ai passes --start N when start != 10 ## Channel Planner UI (AI path step 2, formerly "LLM Handoff") The LLM Handoff step was renamed to Channel Planner and now serves as the decision point for what to do with existing channels before generating a new lineup. This makes the workflow feel more intentional: see your current lineup, decide what to keep, then generate channels that fit around what you're keeping. - Shows all current Tunarr channels as a scrollable checklist on mount - All channels checked by default (keep all); uncheck to clear and replace - Summary text turns yellow with explicit "cleared / rebuilt as new stations" language when any channel will be deleted - "Channels start at" auto-calculates from the highest checked channel, rounded up to the nearest 10 (e.g. highest kept is #24 → start at 30). Auto-updates live as checkboxes are toggled. Resets to 1 when all unchecked. - Checked channel numbers are passed up to AIPath as protectedNums and down to DeployStep as inheritedProtectedNums, bypassing the per-deploy panel ## Deploy step simplification Removed the "Replace existing channels / Add new channels only" SegmentedControl toggle. The checkbox model in Channel Planner subsumes both modes: - All checked = keep all (equivalent to "add new only") - None checked = replace all (equivalent to "replace existing") - Mix = granular per-channel control the old toggle couldn't offer - DeployStep: removed deployMode prop/state/SegmentedControl; added inheritedProtectedNums?: number[] prop; shows inherited protection summary when provided (AI path) or own protection panel when not (No-AI/Collections) - Protection panel in Deploy (No-AI/Collections path): channels not being redeployed are protected by default; channels being redeployed are not - Conflict detection unchanged: red highlight + disabled deploy button when a protected number collides with a deploy channel number Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d300ea2 commit c1279b7

9 files changed

Lines changed: 417 additions & 110 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ See `config.json.example` for the template.
166166
- Auto-generates decade channels (year filtering) and genre channels (genre tag matching)
167167
- Auto-generates TV marathon channels for shows with 50+ episodes
168168
- Writes placeholder entries for franchise/themed channels (user fills manually)
169+
- `--start N` — offsets all block ranges by `N - 10` (default: 10). E.g. `--start 30` shifts TV Marathons to 30–39, TV Blocks to 40–49, etc., leaving lower numbers free for pre-existing channels. Passed automatically by the web UI.
169170
- Output: `channels.json`
170171

171172
**`generate_from_collections.py`** (Option C — Plex collections as channels)
@@ -180,6 +181,7 @@ See `config.json.example` for the template.
180181
- Reads `channels.json`
181182
- Indexes Tunarr library (exact title matching, case-insensitive)
182183
- Deletes all existing channels then creates new ones (use `--from N` to scope to channels >= N, preserving lower channels and their custom images)
184+
- `--protect N1,N2,...` — comma-separated channel numbers to skip during deletion; these channels remain in Tunarr untouched regardless of scope. Printed as "Preserving #N name (protected)" during the run.
183185
- Builds Tunarr random-schedule payloads (30-day rolling window — channels loop forever, no dead air)
184186
- Output: channels live in Tunarr
185187

@@ -270,15 +272,15 @@ A title can appear on multiple channels — this is intentional and expected.
270272
| POST | `/api/pipeline/export` | SSE-stream `export.py`; JSON body `{"no_crossref": bool, "movie_sections": ["1","2"], "tv_sections": ["3"]}` — sections are Plex section keys; `null` = auto-detect, `[]` = skip that type |
271273
| GET | `/api/pipeline/csv` | Download `plex_library.csv` |
272274
| GET | `/api/pipeline/csv/info` | Stats: rows, movies, tv_shows, skipped counts, preview lines |
273-
| GET | `/api/pipeline/prompt` | Fetch `PROMPT.md` with `{TARGET}` and preferences injected |
275+
| GET | `/api/pipeline/prompt` | Fetch `PROMPT.md` with `{TARGET}`, preferences, and `start` (block offset) injected; query params: `target`, `preferences`, `start` |
274276
| POST | `/api/pipeline/validate` | Parse/validate LLM output (file upload or raw text), write `channels.json` |
275-
| POST | `/api/pipeline/no-ai` | SSE-stream `generate_no_ai.py` |
277+
| POST | `/api/pipeline/no-ai` | SSE-stream `generate_no_ai.py`; query param `start=N` passed as `--start N` |
276278
| GET | `/api/pipeline/collections` | Fetch all Plex collections (id, name, count, section, summary, has_poster) |
277279
| GET | `/api/pipeline/collections/{id}/poster` | Proxy Plex collection poster image |
278280
| POST | `/api/pipeline/collections/apply` | Write selected collections into `channels.json` |
279281
| POST | `/api/pipeline/probe` | SSE-stream `create.py --probe` |
280-
| POST | `/api/pipeline/deploy` | SSE-stream `create.py` (full deploy) |
281-
| POST | `/api/pipeline/deploy-selective` | Filter channels by `DeploySelection[]`, write `deploy_temp.json`, SSE-stream `create.py --json deploy_temp.json` |
282+
| POST | `/api/pipeline/deploy` | SSE-stream `create.py`; query params: `protected` (comma-separated channel numbers to preserve), `no_delete` (bool) |
283+
| POST | `/api/pipeline/deploy-selective` | JSON body `DeployRequest{selections, protected_numbers, no_delete}`; filters channels.json to selected entries, writes `deploy_temp.json`, SSE-streams `create.py --json deploy_temp.json [--protect N1,N2,...] [--no-delete]` |
282284
| POST | `/api/pipeline/images` | SSE-stream `fetch_images.py --apply` |
283285
| POST | `/api/pipeline/sync` | SSE-stream `sync_plex.py` |
284286

@@ -293,15 +295,27 @@ A title can appear on multiple channels — this is intentional and expected.
293295

294296
**AI Path (6 steps):**
295297
1. **Export** — fetches Plex library list on mount; shows "Libraries to scan" card with checkboxes grouped by Movies / TV Shows (all checked by default, supports multi-select across libraries of the same type); runs `export.py` with selected section keys; shows compact stats card on success; manual Continue
296-
2. **LLM Handoff** — config card (channel count NumberInput + theme Textarea); side-by-side prompt copy + CSV download; paste/upload LLM output; post-validate results card showing channel breakdown; "Add Plex Collections" or "Skip to Deploy" buttons
297-
3. **Add Plex Collections** — fetches collections on mount; 2-column grid with poster, name, count, editable channel number, checkbox (all checked by default); applies selections to `channels.json`
298-
4. **Deploy** — probe explainer card; runs probe; after probe completes: 2-column layout [terminal | scrollable channel review card with checkboxes + editable channel numbers]; selective deploy via `/pipeline/deploy-selective`
298+
2. **Channel Planner**shows current Tunarr lineup as a scrollable checkable list (all checked by default = keep; uncheck = delete and replace with new station); auto-calculates "Channels start at" from the highest checked channel number, rounded up to the nearest 10 (e.g. highest checked is #24 → start at 30); summary line turns yellow and uses explicit "cleared / rebuilt" language when channels will be deleted; config card (target channel count NumberInput + "Channels start at" NumberInput + theme Textarea); side-by-side prompt copy + CSV download; paste/upload LLM output; post-validate results card showing channel breakdown; "Add Plex Collections" or "Skip to Deploy" buttons. Checked channel numbers are passed to Deploy step as `inheritedProtectedNums`.
299+
3. **Add Plex Collections** — fetches collections on mount; list with poster, name, count, editable channel number, checkbox (all checked by default); applies selections to `channels.json`
300+
4. **Deploy** — probe explainer card; shows note about how many channels will be kept (from Channel Planner); runs probe; after probe completes: 2-column layout [terminal | scrollable channel review card with checkboxes + editable channel numbers]; conflict detection highlights red when a deploy number collides with a protected number; selective deploy via `/pipeline/deploy-selective` with `protected_numbers` from Channel Planner
299301
5. **Fetch Images** — skippable step; runs `fetch_images.py --apply`
300302
6. **Sync Plex** — skippable step; runs `sync_plex.py`; post-deploy stats + links to Tunarr and Plex Live TV
301303

302-
**No-AI Path (6 steps):** Same as AI but step 2 runs `generate_no_ai.py` instead of LLM handoff. Step 1 (Export) has the same library picker.
303-
304-
**Collections Path (4 steps):** Collections → Deploy → Fetch Images → Sync Plex (no export or LLM).
304+
**No-AI Path (6 steps):**
305+
1. **Export** — same as AI path
306+
2. **Generate** — "Channels start at" NumberInput (default 1); runs `generate_no_ai.py --start N`; shows channel count on success
307+
3. **Collections** — same as AI path
308+
4. **Deploy** — same as AI Deploy step but with its own protection panel (no inherited protection): shows all existing Tunarr channels after probe, with channels NOT being redeployed checked (protected) by default; user adjusts before deploying
309+
5. **Fetch Images** — same as AI path
310+
6. **Sync Plex** — same as AI path
311+
312+
**Collections Path (4 steps):** Collections → Deploy (with own protection panel) → Fetch Images → Sync Plex (no export or LLM).
313+
314+
**Channel protection model:**
315+
- AI path: user decides which existing channels to keep in the **Channel Planner** step, before even generating the LLM prompt. Checked = protected (passed to deploy as `inheritedProtectedNums`). Deploy step shows a summary and skips the per-deploy panel.
316+
- No-AI / Collections path: user decides in the **Deploy** step's own protection panel after the probe. Channels not in the current deploy are protected by default; channels being redeployed are unprotected by default.
317+
- In both cases, protected channel numbers are passed to `create.py` via `--protect N1,N2,...`.
318+
- Conflict detection: if a protected number equals a deploy channel's `deployNumber`, the row highlights red and the deploy button is disabled until resolved (renumber or unprotect).
305319

306320
**`deploy_temp.json`:** Written by `/pipeline/deploy-selective` when the user excludes channels from a deploy session. The original `channels.json` is not modified — only the channels the user chose are deployed.
307321

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ All three paths end with a probe (dry run) → deploy to Tunarr. The app streams
147147

148148
### Also included
149149

150+
- **Channel protection** — before deploying, choose which existing Tunarr channels to keep. Checked = preserved; unchecked = cleared and rebuilt as new stations. The "Channels start at" number auto-adjusts to leave room for whatever you keep.
151+
- **Channel offset** — set a starting channel number so new channels don't collide with ones you want to keep. Auto-calculated from your highest protected channel, rounded to the nearest 10.
150152
- **Library picker** — choose which Plex libraries to scan (Movies, 4K Movies, Kids TV, etc.) before each export; supports mixing multiple libraries of the same type
151153
- **Channel logo fetching** — pulls TMDB clearlogos for single-show/movie channels
152154
- **Plex DVR sync** — maps new channels into the Plex Live TV guide automatically

backend/routers/pipeline_router.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def csv_info():
182182

183183

184184
@router.get("/pipeline/prompt")
185-
def get_prompt(target: str = "", preferences: str = ""):
185+
def get_prompt(target: str = "", preferences: str = "", start: int = 10):
186186
for candidate in [DATA_DIR / "PROMPT.md", SCRIPTS_DIR / "PROMPT.md"]:
187187
if candidate.exists():
188188
content = candidate.read_text(encoding="utf-8")
@@ -197,6 +197,16 @@ def get_prompt(target: str = "", preferences: str = ""):
197197
f"{preferences}\n"
198198
)
199199
content = content.replace("## Channel Numbering Scheme", inj + "\n## Channel Numbering Scheme")
200+
if start != 10:
201+
o = start - 10
202+
content = content.replace("**10–19**", f"**{10+o}{19+o}**")
203+
content = content.replace("**20–29**", f"**{20+o}{29+o}**")
204+
content = content.replace("**30–49**", f"**{30+o}{49+o}**")
205+
content = content.replace("**50–69**", f"**{50+o}{69+o}**")
206+
content = content.replace("**70–79**", f"**{70+o}{79+o}**")
207+
content = content.replace('"number": 10,', f'"number": {10+o},')
208+
content = content.replace('"number": 20,', f'"number": {20+o},')
209+
content = content.replace('"number": 30,', f'"number": {30+o},')
200210
return {"content": content}
201211
raise HTTPException(404, "PROMPT.md not found")
202212

@@ -238,8 +248,11 @@ async def validate(file: Optional[UploadFile] = File(None), content: Optional[st
238248

239249

240250
@router.post("/pipeline/no-ai")
241-
async def run_no_ai():
242-
return _sse(_stream("generate_no_ai.py", [], "no_ai"))
251+
async def run_no_ai(start: int = Query(10)):
252+
args = []
253+
if start != 10:
254+
args += ["--start", str(start)]
255+
return _sse(_stream("generate_no_ai.py", args, "no_ai"))
243256

244257

245258
@router.post("/pipeline/collections")
@@ -263,23 +276,33 @@ async def run_probe(from_channel: Optional[str] = Query(None)):
263276

264277

265278
@router.post("/pipeline/deploy")
266-
async def run_deploy(from_channel: Optional[str] = Query(None)):
279+
async def run_deploy(from_channel: Optional[str] = Query(None), protected: str = Query(""), no_delete: bool = Query(False)):
267280
args = []
281+
if no_delete:
282+
args.append("--no-delete")
268283
if from_channel:
269284
args += ["--from", from_channel]
285+
if protected:
286+
args += ["--protect", protected]
270287
return _sse(_stream("create.py", args, "deploy"))
271288

272289

290+
class DeployRequest(BaseModel):
291+
selections: list[DeploySelection]
292+
protected_numbers: list[int] = []
293+
no_delete: bool = False
294+
295+
273296
@router.post("/pipeline/deploy-selective")
274-
async def run_deploy_selective(selections: list[DeploySelection]):
297+
async def run_deploy_selective(req: DeployRequest):
275298
channels_path = DATA_DIR / "channels.json"
276299
if not channels_path.exists():
277300
raise HTTPException(404, "channels.json not found")
278301

279302
with open(channels_path, encoding="utf-8") as f:
280303
data = json.load(f)
281304

282-
sel_map = {s.original_number: s for s in selections if s.include}
305+
sel_map = {s.original_number: s for s in req.selections if s.include}
283306
new_channels = [
284307
{**ch, "number": sel_map[ch["number"]].deploy_number}
285308
for ch in data.get("channels", [])
@@ -291,7 +314,12 @@ async def run_deploy_selective(selections: list[DeploySelection]):
291314
with open(temp_path, "w", encoding="utf-8") as f:
292315
json.dump(data, f, indent=2, ensure_ascii=False)
293316

294-
return _sse(_stream("create.py", ["--json", "deploy_temp.json"], "deploy"))
317+
args = ["--json", "deploy_temp.json"]
318+
if req.no_delete:
319+
args.append("--no-delete")
320+
if req.protected_numbers:
321+
args += ["--protect", ",".join(str(n) for n in req.protected_numbers)]
322+
return _sse(_stream("create.py", args, "deploy"))
295323

296324

297325
@router.post("/pipeline/images")

create.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -246,25 +246,31 @@ def build_schedule(shuffle_type, resolved_items):
246246

247247
# ── Channel operations ─────────────────────────────────────────────────────────
248248

249-
def delete_channels(tunarr_url, probe, from_ch=None):
249+
def delete_channels(tunarr_url, probe, from_ch=None, protect=None):
250+
protect = protect or set()
250251
existing = api(tunarr_url, "GET", "/api/channels") or []
251252
if not existing:
252253
print(" No existing channels to delete")
253254
return
254-
targets = [ch for ch in existing if from_ch is None or ch.get("number", 0) >= from_ch]
255-
if not targets:
255+
in_scope = [ch for ch in existing if from_ch is None or ch.get("number", 0) >= from_ch]
256+
targets = [ch for ch in in_scope if ch.get("number", 0) not in protect]
257+
preserved = [ch for ch in in_scope if ch.get("number", 0) in protect]
258+
if not targets and not preserved:
256259
print(f" No channels >= {from_ch} to delete")
257260
return
258261
scope = f">= #{from_ch}" if from_ch is not None else "all"
259-
print(f" Deleting {len(targets)} channels ({scope})...")
260-
for ch in targets:
261-
if probe:
262-
print(f" [PROBE] Would delete #{ch['number']} {ch['name']}")
263-
else:
264-
result = api(tunarr_url, "DELETE", f"/api/channels/{ch['id']}")
265-
if result is not None:
266-
print(f" Deleted #{ch['number']} {ch['name']}")
267-
time.sleep(0.1)
262+
if targets:
263+
print(f" Deleting {len(targets)} channels ({scope})...")
264+
for ch in targets:
265+
if probe:
266+
print(f" [PROBE] Would delete #{ch['number']} {ch['name']}")
267+
else:
268+
result = api(tunarr_url, "DELETE", f"/api/channels/{ch['id']}")
269+
if result is not None:
270+
print(f" Deleted #{ch['number']} {ch['name']}")
271+
time.sleep(0.1)
272+
for ch in preserved:
273+
print(f" {'[PROBE] ' if probe else ''}Preserving #{ch['number']} {ch['name']} (protected)")
268274

269275

270276
def create_channel(tunarr_url, number, name, transcode_id):
@@ -321,6 +327,8 @@ def main():
321327
parser.add_argument("--no-delete", action="store_true", help="Skip deleting existing channels")
322328
parser.add_argument("--from", dest="from_ch", type=int, default=None, metavar="N",
323329
help="Only operate on channels numbered N and above (preserves lower channels)")
330+
parser.add_argument("--protect", dest="protect", default="", metavar="NUMS",
331+
help="Comma-separated channel numbers to protect from deletion")
324332
args = parser.parse_args()
325333

326334
cfg = load_config()
@@ -374,9 +382,17 @@ def main():
374382
sys.exit(1)
375383

376384
# ── Delete existing channels ───────────────────────────────────────────────
385+
protect_set: set[int] = set()
386+
if args.protect:
387+
for n in args.protect.split(","):
388+
try:
389+
protect_set.add(int(n.strip()))
390+
except ValueError:
391+
pass
392+
377393
if not args.no_delete:
378394
print("\nDeleting existing channels...")
379-
delete_channels(tunarr_url, args.probe, from_ch=args.from_ch)
395+
delete_channels(tunarr_url, args.probe, from_ch=args.from_ch, protect=protect_set)
380396

381397
# ── Create channels ────────────────────────────────────────────────────────
382398
print(f"\n{'[PROBE] ' if args.probe else ''}Creating {len(channels)} channels...")

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
services:
22
programmarr:
33
image: ghcr.io/alpinearchitecture/programmarr:latest
4-
# build: . # uncomment to build from source instead
4+
# build: . # uncomment for local testing
55
container_name: programmarr
66
restart: unless-stopped
77
ports:

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "programmarr",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"private": true,
55
"type": "module",
66
"scripts": {

frontend/src/api/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ export const api = {
3030
getLibraryTitles: () => req<string[]>('/library/titles'),
3131

3232
getCsvInfo: () => req<CsvInfo>('/pipeline/csv/info'),
33-
getPrompt: (target?: string, prefs?: string) => {
33+
getPrompt: (target?: string, prefs?: string, start?: number) => {
3434
const p = new URLSearchParams();
3535
if (target) p.set('target', target);
3636
if (prefs) p.set('preferences', prefs);
37+
if (start !== undefined && start !== 10) p.set('start', String(start));
3738
return req<{ content: string }>(`/pipeline/prompt?${p}`);
3839
},
3940
validateText: async (content: string) => {

0 commit comments

Comments
 (0)