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:
@@ -37,10 +37,11 @@ export default tseslint.config(
|
||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
// The 19 `any`s all live in the api.ts god-module, which R7 (Frontend API layer) rewrites.
|
||||
// Turning the rule on now would either block the gate on pre-existing debt or scatter 19
|
||||
// suppressions — R7 flips this back to "error" when it types that layer. Tracked, not ignored.
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
|
||||
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
|
||||
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
|
||||
// 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()`,
|
||||
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
|
||||
"@typescript-eslint/no-unused-expressions": [
|
||||
|
||||
@@ -249,7 +249,8 @@ export default function App() {
|
||||
setPlaylistsCollapsedState(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
|
||||
// dedicated effect above, which also covers accounts that have no stored preferences.)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface Me {
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
preferences: Record<string, any>;
|
||||
preferences: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DemoWhitelistEntry {
|
||||
@@ -622,7 +622,7 @@ export interface AppNotification {
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
data: Record<string, any> | null;
|
||||
data: Record<string, unknown> | null;
|
||||
read: boolean;
|
||||
dismissed: boolean;
|
||||
created_at: string | null;
|
||||
@@ -1189,7 +1189,7 @@ export const api = {
|
||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||
// 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 }),
|
||||
|
||||
// --- channel manager ---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user