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.
|
// 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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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