refactor(fe): eliminate every explicit any; enforce no-explicit-any (R7 S3a·4)

Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
  narrowed at consumers (App language guard; NotificationsPanel shapes the
  notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
  (SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
  YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
  tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction

Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
This commit is contained in:
2026-07-27 22:57:33 +02:00
parent 2d4ecdb6c5
commit 90749a9e45
9 changed files with 102 additions and 32 deletions
+5 -4
View File
@@ -37,10 +37,11 @@ export default tseslint.config(
// finding is reported once, by one tool, and this config stays about hooks + refresh safety. // finding is reported once, by one tool, and this config stays about hooks + refresh safety.
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"no-undef": "off", "no-undef": "off",
// The 19 `any`s all live in the api.ts god-module, which R7 (Frontend API layer) rewrites. // R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
// Turning the rule on now would either block the gate on pre-existing debt or scatter 19 // catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
// suppressions — R7 flips this back to "error" when it types that layer. Tracked, not ignored. // types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
"@typescript-eslint/no-explicit-any": "off", // creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
"@typescript-eslint/no-explicit-any": "error",
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`, // Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else. // `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [ "@typescript-eslint/no-unused-expressions": [
+2 -1
View File
@@ -249,7 +249,8 @@ export default function App() {
setPlaylistsCollapsedState(prefsRec.playlistsCollapsed); setPlaylistsCollapsedState(prefsRec.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed); writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed);
} }
if (isSupported(prefs.language)) setLanguage(prefs.language); const lang = prefs.language;
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the // (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// dedicated effect above, which also covers accounts that have no stored preferences.) // dedicated effect above, which also covers accounts that have no stored preferences.)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+10 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { import {
@@ -223,7 +224,7 @@ function NotificationRow({
onOpenScheduler, onOpenScheduler,
}: { }: {
n: AppNotification; n: AppNotification;
t: (k: string, o?: any) => string; t: TFunction;
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void; onOpenScheduler: () => void;
@@ -239,15 +240,18 @@ function NotificationRow({
let title = n.title; let title = n.title;
let body = n.body; let body = n.body;
if (isMaintenance) { if (isMaintenance) {
// Guarded above (typeof count === "number"); shape the JSON `data` bag for typed access.
const d = n.data as { count: number };
title = t("inbox.maintenance.title"); title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: n.data!.count }); body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) { } else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body. // "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id); const d = n.data as { job_id: string; status?: string; summary?: string };
title = t(n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job, job,
}); });
body = n.data!.summary || n.body; body = d.summary || n.body;
} }
return ( return (
<div <div
@@ -320,7 +324,7 @@ function ClientActivityRow({
onRemove, onRemove,
}: { }: {
n: ClientNotif; n: ClientNotif;
t: (k: string, o?: any) => string; t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
+10 -8
View File
@@ -30,6 +30,7 @@ import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal"; import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api"; import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
import { import {
channelYouTubeUrl, channelYouTubeUrl,
formatDate, formatDate,
@@ -82,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
} }
// --- IFrame Player API loader (singleton) --- // --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null; let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<any> { function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise; if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => { apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window as any; const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT); if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady; const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => { w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev(); if (typeof prev === "function") prev();
resolve(w.YT); // The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
}; };
const tag = document.createElement("script"); const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api"; tag.src = "https://www.youtube.com/iframe_api";
@@ -158,7 +160,7 @@ export default function PlayerModal({
const resumeAt = const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a // Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever. // run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
@@ -706,7 +708,7 @@ export default function PlayerModal({
focusModal(); focusModal();
applyVolume(volumeRef.current, false); applyVolume(volumeRef.current, false);
}, },
onStateChange: (e: any) => { onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display. // 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) { if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run errorStreakRef.current = 0; // a successful play breaks any unplayable run
@@ -725,7 +727,7 @@ export default function PlayerModal({
}, },
// The embed couldn't play this video (embedding disabled, removed, private…). // The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch. // Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => { onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1); setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd() // The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but // never runs and an auto-advancing queue would freeze here. Skip forward instead — but
+5 -4
View File
@@ -34,7 +34,7 @@ import {
VolumeX, VolumeX,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type PlexMarker } from "../lib/api"; import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { import {
exitFullscreen, exitFullscreen,
@@ -314,14 +314,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// selected client-side after the manifest loads. // selected client-side after the manifest loads.
const m = multiAudioRef.current; const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m); sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
} catch (e: any) { } catch (e) {
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file // Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec // isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase). // needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError( setLoadError(
e?.status === 501 status === 501
? tRef.current("plex.player.errTranscode") ? tRef.current("plex.player.errTranscode")
: e?.status === 404 : status === 404
? tRef.current("plex.player.errNotFound") ? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"), : tRef.current("plex.player.errGeneric"),
); );
+5 -3
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, HttpError, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
@@ -393,8 +393,10 @@ function SignInMethods({ me }: { me: Me }) {
level: "success", level: "success",
message: t("settings.account.password.saved", { email: me.email }), message: t("settings.account.password.saved", { email: me.email }),
}); });
} catch (e: any) { } catch (e) {
setErr(e?.detail ?? t("settings.account.password.failed")); setErr(
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
);
} finally { } finally {
setBusy(false); setBusy(false);
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react"; import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api } from "../lib/api"; import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n"; import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
@@ -103,8 +103,8 @@ export default function SetupWizard() {
return; return;
} }
go(1); go(1);
} catch (e: any) { } catch (e) {
setError(e?.detail ?? t("setup.genericError")); setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
+3 -3
View File
@@ -17,7 +17,7 @@ export interface Me {
can_read: boolean; can_read: boolean;
can_write: boolean; can_write: boolean;
pending_invites: number; pending_invites: number;
preferences: Record<string, any>; preferences: Record<string, unknown>;
} }
export interface DemoWhitelistEntry { export interface DemoWhitelistEntry {
@@ -622,7 +622,7 @@ export interface AppNotification {
type: string; type: string;
title: string; title: string;
body: string | null; body: string | null;
data: Record<string, any> | null; data: Record<string, unknown> | null;
read: boolean; read: boolean;
dismissed: boolean; dismissed: boolean;
created_at: string | null; created_at: string | null;
@@ -1189,7 +1189,7 @@ export const api = {
pauseSync: () => req("/api/sync/pause", { method: "POST" }), pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip. // Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, any>) => savePrefs: (p: Record<string, unknown>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
// --- channel manager --- // --- channel manager ---
+59
View File
@@ -0,0 +1,59 @@
// Minimal typings for the YouTube IFrame Player API surface this app actually uses. The API is
// loaded at runtime from youtube.com/iframe_api and ships no official npm types; rather than pull in
// @types/youtube we declare just the methods/events PlayerModal calls, so tsc flags any new call
// that isn't covered here. See [[reference-yt-iframe-api-remote-control-map]] for what's driveable.
export interface YTVideoData {
title: string;
author: string;
video_id?: string;
}
export interface YTPlayer {
getPlayerState(): number;
getCurrentTime(): number;
getDuration(): number;
getVideoData(): YTVideoData;
getIframe(): HTMLIFrameElement;
getVolume(): number;
isMuted(): boolean;
setVolume(volume: number): void;
mute(): void;
unMute(): void;
playVideo(): void;
pauseVideo(): void;
loadVideoById(opts: { videoId: string; startSeconds?: number }): void;
seekTo(seconds: number, allowSeekAhead: boolean): void;
destroy(): void;
}
// The IFrame API fires events carrying a numeric `data` — a player-state code (onStateChange) or an
// error code (onError).
export interface YTPlayerEvent {
data: number;
target: YTPlayer;
}
export interface YTPlayerOptions {
width?: string | number;
height?: string | number;
videoId?: string;
playerVars?: Record<string, string | number | undefined>;
events?: {
onReady?: (e: YTPlayerEvent) => void;
onStateChange?: (e: YTPlayerEvent) => void;
onError?: (e: YTPlayerEvent) => void;
};
}
export interface YTNamespace {
Player: new (el: HTMLElement, opts: YTPlayerOptions) => YTPlayer;
}
// The globals the IFrame API script installs on `window` once loaded.
declare global {
interface Window {
YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void;
}
}