Skip to content

Commit 216d9c8

Browse files
Add library picker to Export step: choose which Plex libraries to scan
Previously export.py always auto-detected the first movie and first TV library it found in Plex. Users with multiple libraries (4K Movies, Kids TV, 3D Movies, etc.) had no way to control which content went into the CSV — everything or nothing. Changes: - export.py: new --movie-sections and --tv-sections flags accept comma-separated Plex section keys. When provided, only those sections are fetched; titles are deduplicated across sections so a title in both 'Movies' and '4K Movies' appears once. Omitting a flag still auto-detects (first matching section), preserving CLI behaviour. Empty string for a flag explicitly skips that content type. - pipeline_router.py: new GET /api/pipeline/libraries endpoint probes Plex and returns all movie/show sections as {key, title, type}. ExportOptions gains optional movie_sections and tv_sections fields (None = auto-detect, [] = skip that type, ["1","2"] = use those keys). Uses Optional[list[str]] so Pydantic can distinguish absent from empty. - Run.tsx ExportStep: fetches libraries on mount, renders a compact 'Libraries to scan' card with checkboxes grouped by Movies / TV Shows. All checked by default. Gracefully degrades if Plex is unreachable (shows warning, export falls back to auto-detect). Run Export button disabled only when libraries loaded successfully but none are checked. Fixed React synthetic event bug: captures e.currentTarget.checked into a local variable before the setState updater runs, preventing crashes when currentTarget is nullified after the event handler returns. - client.ts: added PlexLibrary type and api.getLibraries(). - CLAUDE.md and README.md updated to reflect new flags, endpoint, and UI behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1d45aca commit 216d9c8

6 files changed

Lines changed: 156 additions & 18 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ See `config.json.example` for the template.
158158
- Fetches full metadata directly from Plex API (`/library/sections/{key}/all`)
159159
- Fields: title, year, contentRating, genres, directors, season/episode counts
160160
- Cross-references against Tunarr to flag unsynced content
161+
- Supports multiple sections per type: `--movie-sections KEY1,KEY2` and `--tv-sections KEY1,KEY2` (comma-separated Plex section keys). Deduplicates titles across sections. Omit flags to auto-detect (first movie + first TV section).
161162
- Output: `plex_library.csv` + `export_summary.json` (movies/tv_shows/skipped counts for the UI stats card)
162163

163164
**`generate_no_ai.py`** (Option B — no AI required)
@@ -265,7 +266,8 @@ A title can appear on multiple channels — this is intentional and expected.
265266

266267
| Method | Path | Description |
267268
|--------|------|-------------|
268-
| POST | `/api/pipeline/export` | SSE-stream `export.py`; optional JSON body `{"no_crossref": true}` passes `--no-crossref` to skip Tunarr title matching |
269+
| GET | `/api/pipeline/libraries` | List Plex library sections filtered to `movie` and `show` types (`{key, title, type}`) |
270+
| 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 |
269271
| GET | `/api/pipeline/csv` | Download `plex_library.csv` |
270272
| GET | `/api/pipeline/csv/info` | Stats: rows, movies, tv_shows, skipped counts, preview lines |
271273
| GET | `/api/pipeline/prompt` | Fetch `PROMPT.md` with `{TARGET}` and preferences injected |
@@ -290,14 +292,14 @@ A title can appear on multiple channels — this is intentional and expected.
290292
- Stepper navigation is locked: only completed steps are clickable, future steps are grayed out
291293

292294
**AI Path (6 steps):**
293-
1. **Export**runs `export.py`, shows compact stats card (movies / TV shows / skipped / size) above terminal; manual Continue
295+
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
294296
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
295297
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`
296298
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`
297299
5. **Fetch Images** — skippable step; runs `fetch_images.py --apply`
298300
6. **Sync Plex** — skippable step; runs `sync_plex.py`; post-deploy stats + links to Tunarr and Plex Live TV
299301

300-
**No-AI Path (6 steps):** Same as AI but step 2 runs `generate_no_ai.py` instead of LLM handoff.
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.
301303

302304
**Collections Path (4 steps):** Collections → Deploy → Fetch Images → Sync Plex (no export or LLM).
303305

README.md

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

148148
### Also included
149149

150+
- **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
150151
- **Channel logo fetching** — pulls TMDB clearlogos for single-show/movie channels
151152
- **Plex DVR sync** — maps new channels into the Plex Live TV guide automatically
152153
- **Channel editor** — edit names, numbers, shuffle mode, and content lists in the browser

backend/routers/pipeline_router.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,38 @@ def _sse(gen: AsyncGenerator[str, None]) -> StreamingResponse:
9393

9494
class ExportOptions(BaseModel):
9595
no_crossref: bool = False
96+
movie_sections: Optional[list[str]] = None # None = auto-detect; [] = skip type entirely
97+
tv_sections: Optional[list[str]] = None
98+
99+
100+
@router.get("/pipeline/libraries")
101+
def list_libraries():
102+
cfg = _load_config()
103+
plex_url = cfg.get("plex_url", "").rstrip("/")
104+
plex_token = cfg.get("plex_token", "")
105+
if not plex_url or not plex_token:
106+
raise HTTPException(400, "Plex not configured")
107+
try:
108+
data = _plex_get(plex_url, plex_token, "/library/sections")
109+
sections = data["MediaContainer"].get("Directory", [])
110+
except Exception as e:
111+
raise HTTPException(502, f"Could not reach Plex: {e}")
112+
return [
113+
{"key": s["key"], "title": s["title"], "type": s["type"]}
114+
for s in sections
115+
if s.get("type") in ("movie", "show")
116+
]
117+
96118

97119
@router.post("/pipeline/export")
98120
async def run_export(opts: ExportOptions = ExportOptions()):
99-
args = ["--no-crossref"] if opts.no_crossref else []
121+
args = []
122+
if opts.no_crossref:
123+
args.append("--no-crossref")
124+
if opts.movie_sections is not None:
125+
args += ["--movie-sections", ",".join(opts.movie_sections)]
126+
if opts.tv_sections is not None:
127+
args += ["--tv-sections", ",".join(opts.tv_sections)]
100128
return _sse(_stream("export.py", args, "export"))
101129

102130

export.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ def main():
163163
parser = argparse.ArgumentParser(description="Export Plex library to CSV")
164164
parser.add_argument("--out", default=OUTPUT_FILE, help="Output CSV path")
165165
parser.add_argument("--no-crossref", action="store_true", help="Skip Tunarr sync check")
166+
parser.add_argument("--movie-sections", default=None, help="Comma-separated section keys for movies (auto-detect if omitted, empty string = skip)")
167+
parser.add_argument("--tv-sections", default=None, help="Comma-separated section keys for TV shows (auto-detect if omitted, empty string = skip)")
166168
args = parser.parse_args()
167169

168170
cfg = load_config()
@@ -177,23 +179,49 @@ def main():
177179
print("ERROR: Could not reach Plex or no sections found")
178180
sys.exit(1)
179181

180-
movie_section = next((s for s in sections if s.get("type") == "movie"), None)
181-
tv_section = next((s for s in sections if s.get("type") == "show"), None)
182+
if args.movie_sections is not None:
183+
keys = {k.strip() for k in args.movie_sections.split(",") if k.strip()}
184+
movie_sections = [s for s in sections if s.get("key") in keys and s.get("type") == "movie"]
185+
else:
186+
first = next((s for s in sections if s.get("type") == "movie"), None)
187+
movie_sections = [first] if first else []
182188

183-
if not movie_section:
184-
print("ERROR: No movie library found in Plex")
185-
sys.exit(1)
186-
if not tv_section:
187-
print("ERROR: No TV show library found in Plex")
189+
if args.tv_sections is not None:
190+
keys = {k.strip() for k in args.tv_sections.split(",") if k.strip()}
191+
tv_sections = [s for s in sections if s.get("key") in keys and s.get("type") == "show"]
192+
else:
193+
first = next((s for s in sections if s.get("type") == "show"), None)
194+
tv_sections = [first] if first else []
195+
196+
if not movie_sections and not tv_sections:
197+
print("ERROR: No movie or TV library sections found or selected")
188198
sys.exit(1)
189199

190-
print(f" Movie section: [{movie_section['key']}] {movie_section['title']}")
191-
print(f" TV section: [{tv_section['key']}] {tv_section['title']}")
200+
for s in movie_sections:
201+
print(f" Movie section: [{s['key']}] {s['title']}")
202+
for s in tv_sections:
203+
print(f" TV section: [{s['key']}] {s['title']}")
192204

193205
# ── Fetch Plex content ─────────────────────────────────────────────────────
194206
print("\n[2/4] Fetching Plex content...")
195-
plex_movies = fetch_plex_movies(plex_url, plex_token, movie_section["key"])
196-
plex_shows = fetch_plex_shows(plex_url, plex_token, tv_section["key"])
207+
208+
plex_movies: list = []
209+
seen_movie_titles: set = set()
210+
for sec in movie_sections:
211+
for item in fetch_plex_movies(plex_url, plex_token, sec["key"]):
212+
t = item.get("title", "").lower().strip()
213+
if t not in seen_movie_titles:
214+
seen_movie_titles.add(t)
215+
plex_movies.append(item)
216+
217+
plex_shows: list = []
218+
seen_show_titles: set = set()
219+
for sec in tv_sections:
220+
for item in fetch_plex_shows(plex_url, plex_token, sec["key"]):
221+
t = item.get("title", "").lower().strip()
222+
if t not in seen_show_titles:
223+
seen_show_titles.add(t)
224+
plex_shows.append(item)
197225

198226
# ── Cross-reference with Tunarr ────────────────────────────────────────────
199227
tunarr_movies = None

frontend/src/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const api = {
5252
getLogs: () => req<LogEntry[]>('/logs'),
5353
getLog: (name: string) => req<{ name: string; content: string }>(`/logs/${name}`),
5454

55+
getLibraries: () => req<PlexLibrary[]>('/pipeline/libraries'),
5556
getCollections: () => req<PlexCollection[]>('/pipeline/collections'),
5657
applyCollections: (selections: CollectionSelection[]) =>
5758
req<{ ok: boolean; added: number }>('/pipeline/collections/apply', {
@@ -83,6 +84,7 @@ export interface CsvInfo {
8384
skipped_shows?: number;
8485
}
8586
export interface ValidateResult { ok: boolean; count?: number; error?: string; channels?: Channel[] }
87+
export interface PlexLibrary { key: string; title: string; type: 'movie' | 'show' }
8688
export interface PlexCollection { id: string; name: string; count: number; section: string; summary: string; has_poster: boolean }
8789
export interface CollectionSelection { name: string; channel_number: number; include: boolean }
8890
export interface LogEntry { name: string; size: number; modified: number }

frontend/src/pages/Run.tsx

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
IconExternalLink, IconPlayerPlay, IconUpload, IconX,
1111
} from '@tabler/icons-react';
1212
import { useEffect, useState } from 'react';
13-
import { api, streamPipeline, StreamEvent, PlexCollection, CollectionSelection } from '../api/client';
13+
import { api, streamPipeline, StreamEvent, PlexCollection, PlexLibrary, CollectionSelection } from '../api/client';
1414
import type { Channel } from '../api/client';
1515
import TerminalOutput from '../components/TerminalOutput';
1616

@@ -63,19 +63,40 @@ function parseRunStats(lines: string[]) {
6363
// ── Step: Export ───────────────────────────────────────────────────────────────
6464

6565
function ExportStep({ onDone }: { onDone: () => void }) {
66+
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
67+
const [libSels, setLibSels] = useState<Record<string, boolean>>({});
68+
const [libLoading, setLibLoading] = useState(true);
69+
const [libError, setLibError] = useState<string | null>(null);
70+
6671
const [lines, setLines] = useState<string[]>([]);
6772
const [running, setRunning] = useState(false);
6873
const [done, setDone] = useState(false);
6974
const [success, setSuccess] = useState(false);
7075
const [summary, setSummary] = useState<Awaited<ReturnType<typeof api.getCsvInfo>> | null>(null);
7176
const [noCrossref, setNoCrossref] = useState(false);
7277

78+
useEffect(() => {
79+
api.getLibraries()
80+
.then(libs => {
81+
setLibraries(libs);
82+
setLibSels(Object.fromEntries(libs.map(l => [l.key, true])));
83+
})
84+
.catch(err => setLibError(err.message))
85+
.finally(() => setLibLoading(false));
86+
}, []);
87+
88+
const movieLibs = libraries.filter(l => l.type === 'movie');
89+
const tvLibs = libraries.filter(l => l.type === 'show');
90+
const selectedCount = Object.values(libSels).filter(Boolean).length;
91+
7392
async function run() {
93+
const movieSections = movieLibs.filter(l => libSels[l.key]).map(l => l.key);
94+
const tvSections = tvLibs.filter(l => libSels[l.key]).map(l => l.key);
7495
setLines([]); setDone(false); setSummary(null); setRunning(true);
7596
try {
7697
const code = await streamPipeline('/pipeline/export', {}, (ev: StreamEvent) => {
7798
if (ev.type === 'line') setLines(l => [...l, ev.text]);
78-
}, noCrossref ? { no_crossref: true } : undefined);
99+
}, { no_crossref: noCrossref, movie_sections: movieSections, tv_sections: tvSections });
79100
const ok = code === 0;
80101
setSuccess(ok); setDone(true);
81102
if (ok) setSummary(await api.getCsvInfo());
@@ -91,8 +112,64 @@ function ExportStep({ onDone }: { onDone: () => void }) {
91112

92113
return (
93114
<Stack gap="md">
115+
{/* Library picker */}
116+
<Card withBorder p="md">
117+
<Text fw={700} mb="sm">Libraries to scan</Text>
118+
{libLoading && (
119+
<Group gap="sm">
120+
<Loader size="xs" color="orange" />
121+
<Text size="sm" c="dimmed">Fetching Plex libraries…</Text>
122+
</Group>
123+
)}
124+
{!libLoading && libError && (
125+
<Alert color="yellow" variant="light" icon={<IconAlertCircle size={16} />}>
126+
Could not load libraries — export will auto-detect: {libError}
127+
</Alert>
128+
)}
129+
{!libLoading && !libError && libraries.length > 0 && (
130+
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
131+
{movieLibs.length > 0 && (
132+
<Stack gap={6}>
133+
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Movies</Text>
134+
{movieLibs.map(lib => (
135+
<Checkbox
136+
key={lib.key}
137+
label={lib.title}
138+
checked={libSels[lib.key] ?? true}
139+
onChange={(e) => { const v = e.currentTarget.checked; setLibSels(s => ({ ...s, [lib.key]: v })); }}
140+
size="sm"
141+
disabled={running}
142+
/>
143+
))}
144+
</Stack>
145+
)}
146+
{tvLibs.length > 0 && (
147+
<Stack gap={6}>
148+
<Text size="xs" fw={600} c="dimmed" tt="uppercase">TV Shows</Text>
149+
{tvLibs.map(lib => (
150+
<Checkbox
151+
key={lib.key}
152+
label={lib.title}
153+
checked={libSels[lib.key] ?? true}
154+
onChange={(e) => { const v = e.currentTarget.checked; setLibSels(s => ({ ...s, [lib.key]: v })); }}
155+
size="sm"
156+
disabled={running}
157+
/>
158+
))}
159+
</Stack>
160+
)}
161+
</SimpleGrid>
162+
)}
163+
</Card>
164+
94165
<Group align="center">
95-
<Button leftSection={<IconPlayerPlay size={15} />} color="orange" onClick={run} loading={running}>
166+
<Button
167+
leftSection={<IconPlayerPlay size={15} />}
168+
color="orange"
169+
onClick={run}
170+
loading={running}
171+
disabled={!libLoading && !libError && selectedCount === 0}
172+
>
96173
{running ? 'Exporting…' : done ? 'Re-run Export' : 'Run Export'}
97174
</Button>
98175
{done && !success && <Button variant="subtle" color="red" onClick={run}>Retry</Button>}

0 commit comments

Comments
 (0)