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
+10 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
@@ -223,7 +224,7 @@ function NotificationRow({
onOpenScheduler,
}: {
n: AppNotification;
t: (k: string, o?: any) => string;
t: TFunction;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
@@ -239,15 +240,18 @@ function NotificationRow({
let title = n.title;
let body = n.body;
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");
body = t("inbox.maintenance.body", { count: n.data!.count });
body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) {
// "<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);
title = t(n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
const d = n.data as { job_id: string; status?: string; summary?: string };
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job,
});
body = n.data!.summary || n.body;
body = d.summary || n.body;
}
return (
<div
@@ -320,7 +324,7 @@ function ClientActivityRow({
onRemove,
}: {
n: ClientNotif;
t: (k: string, o?: any) => string;
t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void;
+10 -8
View File
@@ -30,6 +30,7 @@ import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
@@ -82,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
}
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null;
function loadYouTubeApi(): Promise<any> {
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => {
const w = window as any;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
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");
tag.src = "https://www.youtube.com/iframe_api";
@@ -158,7 +160,7 @@ export default function PlayerModal({
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// 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.
@@ -706,7 +708,7 @@ export default function PlayerModal({
focusModal();
applyVolume(volumeRef.current, false);
},
onStateChange: (e: any) => {
onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
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…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => {
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// 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
+5 -4
View File
@@ -34,7 +34,7 @@ import {
VolumeX,
X,
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import {
exitFullscreen,
@@ -314,14 +314,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// selected client-side after the manifest loads.
const m = multiAudioRef.current;
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
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError(
e?.status === 501
status === 501
? tRef.current("plex.player.errTranscode")
: e?.status === 404
: status === 404
? tRef.current("plex.player.errNotFound")
: 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 { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
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 Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
@@ -393,8 +393,10 @@ function SignInMethods({ me }: { me: Me }) {
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
});
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} catch (e) {
setErr(
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
);
} finally {
setBusy(false);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
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 LanguageSwitcher from "./LanguageSwitcher";
@@ -103,8 +103,8 @@ export default function SetupWizard() {
return;
}
go(1);
} catch (e: any) {
setError(e?.detail ?? t("setup.genericError"));
} catch (e) {
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
} finally {
setBusy(false);
}