From 3fe0897fd32a5063ecca186cbd6582ff3c22f93f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 03:47:55 +0200 Subject: [PATCH 1/2] build(frontend): enable the zero-fallout compiler flags (S3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three strictness flags that the codebase already satisfies (0 errors each): - noFallthroughCasesInSwitch on src + e2e — a missing `break` is now a type error. - noUnusedLocals + noUnusedParameters on the e2e project, so it matches src and node and dead spec scaffolding can't accumulate (it was the one project without them). - allowJs + checkJs on the node project, pulling tailwind.config.js and postcss.config.js under the type checker (the last hole in C-4.5) — a real type error in either is now caught (verified: `content: 123` fails; Config stays permissive on plugin keys by design). noUncheckedIndexedAccess is NOT here — it has 98 call-site fixes and lands as its own reviewable sub-sprint (S3b). --- frontend/e2e/tsconfig.json | 3 +++ frontend/tsconfig.json | 3 ++- frontend/tsconfig.node.json | 10 ++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json index 05fda24d..246d917f 100644 --- a/frontend/e2e/tsconfig.json +++ b/frontend/e2e/tsconfig.json @@ -8,6 +8,9 @@ "strict": true, "skipLibCheck": true, "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true }, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c2afdb29..ffa21f61 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -13,7 +13,8 @@ "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true }, "include": ["src"], // Editors resolve a file to the nearest tsconfig that INCLUDES it; vite.config.ts / vitest.config.ts diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 9e7f5953..60b25c23 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -22,7 +22,13 @@ "skipLibCheck": true, "noUnusedLocals": true, "noUnusedParameters": true, - "resolveJsonModule": true + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + // The build-tool configs include two hand-written .js files (tailwind/postcss). checkJs runs + // the type checker over them via their `@type` JSDoc, so a bad tailwind/postcss option is + // caught by the gate instead of silently mis-building CSS. Was the last hole in C-4.5. + "allowJs": true, + "checkJs": true }, - "include": ["vite.config.ts", "vitest.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.js", "postcss.config.js"] } From e006cc879fdbac293875dc7babae8cce40cdb003 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 04:05:49 +0200 Subject: [PATCH 2/2] refactor(frontend): satisfy noUncheckedIndexedAccess (S3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18 files. Every fix is behaviour-preserving — the flag flagged reads TypeScript couldn't prove in-bounds, all of which were already guarded by a length/index check, a findIndex result, or a non-empty invariant: - read-once so a ternary's narrowing sticks (Feed overrides, linkify mention); - non-null assertion AFTER an existing guard (PlayerModal/VideoEditor queue+cut-list indexing, modules step, useUndoable stacks) with a comment stating the invariant; - nullish fallback where undefined is a real possibility (GlassTuner slider ?? def, ConfigPanel active group ?? [], descriptionLinks id ?? null); - optional chaining where the entry genuinely can be absent (PlexBrowse IO entry); - a non-empty tuple type for the RELATIVE_UNITS constant (encodes "always has [0]"). The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ?? video` (always Video), clearing 34 of the 45 errors in that file at once. Verified: tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite exercises the feed/player/channel components touched here). --- frontend/src/components/ConfigPanel.tsx | 5 +++-- frontend/src/components/Feed.tsx | 10 ++++++++-- frontend/src/components/GlassTuner.tsx | 7 ++++++- frontend/src/components/PlayerModal.tsx | 20 +++++++++---------- frontend/src/components/PlaylistsRail.tsx | 2 +- frontend/src/components/PlexBrowse.tsx | 2 +- frontend/src/components/PlexPlayer.tsx | 6 +++--- frontend/src/components/PlexPlaylistView.tsx | 4 ++-- frontend/src/components/VideoEditor.tsx | 21 ++++++++++---------- frontend/src/i18n/index.ts | 1 + frontend/src/lib/descriptionLinks.tsx | 9 ++++++--- frontend/src/lib/format.ts | 7 ++++++- frontend/src/lib/linkify.tsx | 10 +++++++--- frontend/src/lib/modules.ts | 5 +++-- frontend/src/lib/useUndoable.ts | 5 +++-- frontend/tsconfig.json | 3 ++- 16 files changed, 73 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index ce03d2d5..74c5b32e 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -81,6 +81,7 @@ export default function ConfigPanel() { try { for (const key of dirtyKeys) { const it = byKey[key]; + if (!it) continue; // dirtyKeys are all config-item keys; skip if somehow not found const raw = draft[key] ?? ""; if (it.secret) { if (secretReset[key] && !raw.trim()) await api.resetConfig(key); @@ -170,14 +171,14 @@ export default function ConfigPanel() {

{t("config.intro")}

- +
{activeGroup && (
- {data.groups[activeGroup].map((item) => ( + {(data.groups[activeGroup] ?? []).map((item) => ( list - .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) - .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); + .map((v) => { + const status = overrides[v.id]; // read once so the narrowing sticks + return status ? { ...v, status } : v; + }) + .map((v) => { + const saved = savedOverrides[v.id]; + return saved !== undefined ? { ...v, saved } : v; + }); const merged: Video[] = withOverrides(loaded); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 0f84fd03..62ca5add 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -339,7 +339,12 @@ export default function GlassTuner() { {tab === "glass" && (
{SLIDERS.map((s) => ( - setVar(s.k, v)} /> + setVar(s.k, v)} + /> ))}
)} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 5394d459..e90e0716 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -124,7 +124,7 @@ export default function PlayerModal({ // where a just-watched item could drop out and snap playback to queue[0]). let index = queue ? queue.findIndex((v) => v.id === playingId) : 0; if (index < 0) index = 0; - const active = queue && queue[index] ? queue[index] : video; + const active = queue?.[index] ?? video; // the queue item at index, else the opened video (never undefined) const hasQueue = !!queue && queue.length > 1; // startAt only applies to the item we opened with; advanced items resume their own pos. const resumeAt = @@ -242,11 +242,11 @@ export default function PlayerModal({ navRef.current = { queue, index }; const goPrev = () => { const { queue: q, index: i } = navRef.current; - if (q && i > 0) setPlayingId(q[i - 1].id); + if (q && i > 0) setPlayingId(q[i - 1]!.id); }; const goNext = () => { const { queue: q, index: i } = navRef.current; - if (q && i < q.length - 1) setPlayingId(q[i + 1].id); + if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id); }; // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. const nudgeSeek = (delta: number) => { @@ -277,12 +277,12 @@ export default function PlayerModal({ ); }; const cycleAuto = () => { - const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]; + const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!; setAutoMode(next); persistPref({ playerAutoAdvance: next }); }; const cycleLoop = () => { - const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]; + const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!; setLoopMode(next); persistPref({ playerLoop: next }); }; @@ -303,21 +303,21 @@ export default function PlayerModal({ if (!q || q.length === 0) return; const wrap = l === "all"; if (a === "next") { - if (i < q.length - 1) setPlayingId(q[i + 1].id); + if (i < q.length - 1) setPlayingId(q[i + 1]!.id); else if (wrap) { if (q.length === 1) replayCurrent(); - else setPlayingId(q[0].id); + else setPlayingId(q[0]!.id); } } else if (a === "prev") { - if (i > 0) setPlayingId(q[i - 1].id); + if (i > 0) setPlayingId(q[i - 1]!.id); else if (wrap) { if (q.length === 1) replayCurrent(); - else setPlayingId(q[q.length - 1].id); + else setPlayingId(q[q.length - 1]!.id); } } else if (a === "random" && q.length > 1) { let r = i; while (r === i) r = Math.floor(Math.random() * q.length); - setPlayingId(q[r].id); + setPlayingId(q[r]!.id); } else if (a === "random" && wrap) { replayCurrent(); } diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index ffa6ea06..d705d2f2 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -69,7 +69,7 @@ export default function PlaylistsRail({ return; } if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0].id); + setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlists, selectedId, listQuery.data]); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index cea29757..8b46ff1d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -183,7 +183,7 @@ export default function PlexBrowse() { if (!sentinelEl) return; const io = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); + if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { rootMargin: "600px" }, ); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 9d7215d9..8e9fccd4 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -54,7 +54,7 @@ function audioOrdForLang(streams: AudioStream[], lang: string): number | null { // absent (-1) or already the FIRST track (0, which the backend plays by default) → no override. // (Match by index, NOT the `default` flag — some files mark a non-first track default, which made // the restored language silently fall back to track 0.) - return i <= 0 ? null : streams[i].ord; + return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists } function subOrdForLang(subs: SubStream[], lang: string): number | null { if (!lang) return null; // "" = subtitles off @@ -666,14 +666,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const streams = detail?.audio_streams ?? []; if (streams.length < 2) return; const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0)); - const next = streams[(idx + 1) % streams.length]; + const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds changeAudio(next.ord === 0 ? null : next.ord); }, [detail, changeAudio]); const cycleSubtitle = useCallback(() => { const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text); const order: (number | null)[] = [null, ...subs.map((s) => s.ord)]; const i = order.findIndex((o) => o === subOrd); - changeSubtitle(order[(i + 1) % order.length]); + changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty }, [detail, subOrd, changeSubtitle]); // Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so // dragging doesn't respawn ffmpeg on every step. diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 69571b16..1b525b57 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -406,7 +406,7 @@ export default function PlexPlaylistView({ const set = new Set(keys); setItems((prev) => prev.filter((i) => !set.has(i.id))); try { - if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]); + if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!); else await api.plexPlaylistRemoveBulk(playlistId, keys); invalidate(); } finally { @@ -560,7 +560,7 @@ export default function PlexPlaylistView({ {items.length > 0 && (