diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 6c2cd99..a65aafa 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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; + // 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": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 483cac7..454e26b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 6fee962..6b362b7 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -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) { // " 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 (
string; + t: TFunction; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFocusChannel: (name: string) => void; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index e49f550..23c5fd8 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 | null = null; -function loadYouTubeApi(): Promise { +let apiPromise: Promise | null = null; +function loadYouTubeApi(): Promise { if (apiPromise) return apiPromise; - apiPromise = new Promise((resolve) => { - const w = window as any; + apiPromise = new Promise((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(null); - const playerRef = useRef(null); + const playerRef = useRef(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 diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 110678f..4dcb550 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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"), ); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index a75a1f1..d65a5e4 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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); } diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 6236d35..e02e7c3 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -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); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 781939f..0ee8e16 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,7 +17,7 @@ export interface Me { can_read: boolean; can_write: boolean; pending_invites: number; - preferences: Record; + preferences: Record; } export interface DemoWhitelistEntry { @@ -622,7 +622,7 @@ export interface AppNotification { type: string; title: string; body: string | null; - data: Record | null; + data: Record | 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) => + savePrefs: (p: Record) => req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), // --- channel manager --- diff --git a/frontend/src/lib/youtube.ts b/frontend/src/lib/youtube.ts new file mode 100644 index 0000000..82c087c --- /dev/null +++ b/frontend/src/lib/youtube.ts @@ -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; + 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; + } +}