diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index fef6a39..1fbb5e8 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, SlidersHorizontal, X, Volume2, VolumeX } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Suspense, type Dispatch, type FocusEvent, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { deleteFavorite, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client'; +import { deleteFavorite, deletePlayRecord, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; -import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, searchAnime } from '@/lib/danmaku/api'; +import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, loadDanmakuDisplayState, saveDanmakuDisplayState, searchAnime } from '@/lib/danmaku/api'; import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils'; @@ -14,11 +14,114 @@ import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; const TV_DANMAKU_LANES = 8; const TV_DANMAKU_SPAWN_GRACE = 0.6; +const TV_DANMAKU_SEEK_WINDOW = 8; +const TV_DANMAKU_MAX_ITEMS = 3000; +const TV_DANMAKU_SETTINGS_KEY = 'tv_danmaku_settings'; + +type TVDanmakuSettings = { + fontSize: number; + displayArea: number; + opacity: number; +}; + +const DEFAULT_TV_DANMAKU_SETTINGS: TVDanmakuSettings = { + fontSize: 30, + displayArea: 42, + opacity: 0.75, +}; + +function loadTVDanmakuSettings(): TVDanmakuSettings { + if (typeof window === 'undefined') return DEFAULT_TV_DANMAKU_SETTINGS; + + try { + const saved = localStorage.getItem(TV_DANMAKU_SETTINGS_KEY); + if (!saved) return DEFAULT_TV_DANMAKU_SETTINGS; + const parsed = JSON.parse(saved) as Partial; + return { + fontSize: typeof parsed.fontSize === 'number' ? parsed.fontSize : DEFAULT_TV_DANMAKU_SETTINGS.fontSize, + displayArea: typeof parsed.displayArea === 'number' ? parsed.displayArea : DEFAULT_TV_DANMAKU_SETTINGS.displayArea, + opacity: typeof parsed.opacity === 'number' ? parsed.opacity : DEFAULT_TV_DANMAKU_SETTINGS.opacity, + }; + } catch { + return DEFAULT_TV_DANMAKU_SETTINGS; + } +} function getTVDanmakuDuration(text: string) { return Math.max(6, 12 - Math.min(6, text.length / 6)); } +function blurTVPlayerControl() { + const active = document.activeElement; + if (active instanceof HTMLElement && active.closest('[data-tv-player-control]')) { + active.blur(); + } +} + +function scrollFocusedControlIntoView(event: FocusEvent) { + const target = event.target; + if (target instanceof HTMLElement) { + target.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); + } +} + +function updateTVDanmakuSetting( + field: keyof TVDanmakuSettings, + direction: -1 | 1, + setDanmakuSettings: Dispatch> +) { + setDanmakuSettings((prev) => { + if (field === 'opacity') { + const next = Math.max(0.25, Math.min(1, prev.opacity + direction * 0.05)); + return { ...prev, opacity: Math.round(next * 100) / 100 }; + } + + if (field === 'displayArea') { + const next = Math.max(24, Math.min(72, prev.displayArea + direction * 2)); + return { ...prev, displayArea: next }; + } + + const next = Math.max(20, Math.min(46, prev.fontSize + direction * 1)); + return { ...prev, fontSize: next }; + }); +} + +function getDanmakuSettingField(target: HTMLElement | null): keyof TVDanmakuSettings | null { + if (!(target instanceof HTMLInputElement) || target.type !== 'range') return null; + const field = target.dataset.tvDanmakuField; + if (field === 'fontSize' || field === 'displayArea' || field === 'opacity') return field; + return null; +} + +function getFocusableElementsInScope(scope: HTMLElement) { + return Array.from( + scope.querySelectorAll([ + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(',')) + ).filter((element) => !element.closest('[data-tv-no-focus="true"]')); +} + +function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') { + const elements = getFocusableElementsInScope(scope); + if (elements.length === 0) return; + + const active = document.activeElement; + const index = active instanceof HTMLElement ? elements.indexOf(active) : -1; + if (index === -1) { + elements[0].focus({ preventScroll: true }); + return; + } + + const nextIndex = direction === 'down' + ? Math.min(elements.length - 1, index + 1) + : Math.max(0, index - 1); + elements[nextIndex]?.focus({ preventScroll: true }); +} + function TVPlayClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -32,6 +135,7 @@ function TVPlayClient() { const [showPanel, setShowPanel] = useState(true); const [showEpisodes, setShowEpisodes] = useState(false); const [showDetail, setShowDetail] = useState(false); + const [showDanmakuSettings, setShowDanmakuSettings] = useState(false); const [toggleCommand, setToggleCommand] = useState(0); const [retryNonce, setRetryNonce] = useState(0); const [startTime, setStartTime] = useState(0); @@ -52,10 +156,13 @@ function TVPlayClient() { }); const [danmakuEnabled, setDanmakuEnabled] = useState(() => { if (typeof window === 'undefined') return true; - const saved = localStorage.getItem('tv_danmaku_enabled'); - return saved === null ? true : saved === 'true'; + const saved = loadDanmakuDisplayState(); + if (saved !== null) return saved; + const legacySaved = localStorage.getItem('tv_danmaku_enabled'); + return legacySaved === null ? true : legacySaved === 'true'; }); const [danmakuItems, setDanmakuItems] = useState>([]); + const [danmakuSettings, setDanmakuSettings] = useState(() => loadTVDanmakuSettings()); const [activeDanmakuItems, setActiveDanmakuItems] = useState>({}); const detailCloseButtonRef = useRef(null); + const danmakuFontSizeInputRef = useRef(null); const digitTimerRef = useRef(null); const idleTimerRef = useRef(null); const volumeHintTimerRef = useRef(null); const seekHintTimerRef = useRef(null); const spawnedDanmakuRef = useRef>(new Set()); const lastDanmakuTimeRef = useRef(0); + const suppressPlayRecordSaveKeyRef = useRef(null); const skippedIntroRef = useRef(''); const skippedOutroRef = useRef(''); const lastSavedRef = useRef<{ @@ -160,9 +269,16 @@ function TVPlayClient() { }, [adFilterEnabled]); useEffect(() => { - if (typeof window !== 'undefined') localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled)); + if (typeof window === 'undefined') return; + saveDanmakuDisplayState(danmakuEnabled); + localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled)); }, [danmakuEnabled]); + useEffect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(TV_DANMAKU_SETTINGS_KEY, JSON.stringify(danmakuSettings)); + }, [danmakuSettings]); + useEffect(() => { if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate)); }, [playbackRate]); @@ -189,7 +305,8 @@ function TVPlayClient() { searchKeyword: title || detail.title, }); if (!alive) return; - setDanmakuItems(convertDanmakuFormat(comments).slice(0, 250)); + lastDanmakuTimeRef.current = Math.max(0, timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1); + setDanmakuItems(convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS)); } catch { if (alive) setDanmakuItems([]); } @@ -209,9 +326,10 @@ function TVPlayClient() { const current = time.current; const previous = lastDanmakuTimeRef.current; const jumped = current < previous - 1 || current - previous > 2; - const spawnWindow = jumped ? TV_DANMAKU_SPAWN_GRACE : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2); + const spawnWindow = jumped ? TV_DANMAKU_SEEK_WINDOW : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2); const spawned = spawnedDanmakuRef.current; + if (jumped) spawned.clear(); const nextItems = danmakuItems .map((item, index) => { const id = `${index}-${item.time}-${item.text}`; @@ -225,8 +343,8 @@ function TVPlayClient() { }; }) .filter((item) => { - const lateBy = current - item.time; - return lateBy >= 0 && lateBy <= spawnWindow && !spawned.has(item.id); + const delta = jumped ? Math.abs(item.time - current) : current - item.time; + return delta >= 0 && delta <= spawnWindow && !spawned.has(item.id); }) .slice(0, TV_DANMAKU_LANES); @@ -285,6 +403,9 @@ function TVPlayClient() { useEffect(() => { if (!detail) return; const saveProgress = () => { + const storageKey = generateStorageKey(detail.source, detail.id); + if (suppressPlayRecordSaveKeyRef.current === storageKey) return; + const playTime = Math.floor(timeRef.current.current || 0); const totalTime = Math.floor(timeRef.current.duration || 0); @@ -392,6 +513,8 @@ function TVPlayClient() { idleTimerRef.current = window.setTimeout(() => { setShowPanel(false); setShowEpisodes(false); + setShowDanmakuSettings(false); + blurTVPlayerControl(); }, 10000); }, []); @@ -432,18 +555,24 @@ function TVPlayClient() { }, [muted, videoUrl, volume]); useEffect(() => { - if (showPanel || showEpisodes) revealPanel(); + if (showPanel || showEpisodes || showDanmakuSettings) revealPanel(); return () => { if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current); }; - }, [revealPanel, showEpisodes, showPanel]); + }, [revealPanel, showDanmakuSettings, showEpisodes, showPanel]); const switchSource = async (item: SearchResult) => { + if (!detail) return; + if (detail.source === item.source && detail.id === item.id) return; revealPanel(); setShowEpisodes(false); setLoading(true); setIsBuffering(false); const currentPlayTime = Math.floor(timeRef.current.current || 0); + const oldSource = detail.source; + const oldId = detail.id; + const oldStorageKey = generateStorageKey(oldSource, oldId); + suppressPlayRecordSaveKeyRef.current = oldStorageKey; try { let next = item; if (!item.episodes?.length) { @@ -455,7 +584,11 @@ function TVPlayClient() { setEpisodeIndex(targetIndex); setStartTime(currentPlayTime > 1 ? currentPlayTime : 0); setEpisodePage(Math.floor(targetIndex / 30)); + if (!(next.source === oldSource && next.id === oldId)) { + await deletePlayRecord(oldSource, oldId); + } } catch (err) { + suppressPlayRecordSaveKeyRef.current = null; setError(err instanceof Error ? err.message : '切换播放源失败'); } finally { setLoading(false); @@ -468,6 +601,12 @@ function TVPlayClient() { } }, [showDetail]); + useEffect(() => { + if (showDanmakuSettings) { + window.requestAnimationFrame(() => danmakuFontSizeInputRef.current?.focus({ preventScroll: true })); + } + }, [showDanmakuSettings]); + useEffect(() => { const onKey = (event: KeyboardEvent) => { if (showDetail && event.key === 'Escape') { @@ -477,12 +616,22 @@ function TVPlayClient() { return; } + if (showDanmakuSettings && event.key === 'Escape') { + event.preventDefault(); + setShowDanmakuSettings(false); + blurTVPlayerControl(); + revealPanel(); + return; + } + const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93; if (isMenuKey) { event.preventDefault(); - if (showPanel || showEpisodes) { + if (showPanel || showEpisodes || showDanmakuSettings) { setShowPanel(false); setShowEpisodes(false); + setShowDanmakuSettings(false); + blurTVPlayerControl(); } else { revealPanel(); } @@ -505,13 +654,18 @@ function TVPlayClient() { const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]')); if (!showPanel && !showEpisodes) { event.preventDefault(); + event.stopImmediatePropagation(); + blurTVPlayerControl(); setToggleCommand((value) => value + 1); return; } if (!isControlFocused) { event.preventDefault(); - if (showPanel || showEpisodes) revealPanel(); + event.stopImmediatePropagation(); + setToggleCommand((value) => value + 1); + return; } + return; } if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { @@ -533,8 +687,16 @@ function TVPlayClient() { if (event.key === 'Escape') { event.preventDefault(); if (showDetail) setShowDetail(false); - else if (showEpisodes) setShowEpisodes(false); - else if (showPanel) setShowPanel(false); + else if (showDanmakuSettings) { + setShowDanmakuSettings(false); + blurTVPlayerControl(); + } else if (showEpisodes) { + setShowEpisodes(false); + blurTVPlayerControl(); + } else if (showPanel) { + setShowPanel(false); + blurTVPlayerControl(); + } else router.back(); } if (event.key === 'PageUp') switchEpisode(episodeIndex - 1); @@ -542,7 +704,7 @@ function TVPlayClient() { }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDetail, showEpisodes, showPanel, volume]); + }, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDanmakuSettings, showDetail, showEpisodes, showPanel, volume]); useEffect(() => { if (!showEpisodes) return; @@ -591,12 +753,15 @@ function TVPlayClient() { } return ( -
+
{videoUrl ? setPlaybackError(true)} onPlayingChange={setIsPlaying} onBufferingChange={setIsBuffering} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
{resolving ? '正在解析播放地址...' : '准备播放...'}
)} {activeDanmakuItems.length > 0 && ( -
+
{activeDanmakuItems.map((item) => (
-
-
+
+
- + -
-
- {formatTVTime(time.current)} / {formatTVTime(time.duration)} + {formatTVTime(time.current)} / {formatTVTime(time.duration)}
- seekTo(Number(e.target.value))} className='tv-focusable h-3 w-full cursor-pointer accent-rose-600' /> + seekTo(Number(e.target.value))} className='h-3 w-full cursor-pointer accent-rose-600' />
{formatTVTime(time.current)} {time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'} @@ -706,6 +871,104 @@ function TVPlayClient() {
)} + {showDanmakuSettings && ( +
+
{ + const target = event.target; + if (!(target instanceof HTMLElement) || !target.closest('[data-tv-danmaku-settings]')) return; + + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + if (target instanceof HTMLInputElement && target.type === 'range') { + const field = getDanmakuSettingField(target); + if (!field) return; + event.preventDefault(); + event.stopPropagation(); + updateTVDanmakuSetting(field, event.key === 'ArrowRight' ? 1 : -1, setDanmakuSettings); + revealPanel(); + } + return; + } + + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + moveFocusWithinScope(event.currentTarget as HTMLElement, event.key === 'ArrowDown' ? 'down' : 'up'); + revealPanel(); + } + }} + > +
+

弹幕设置

+ +
+ +
+ + + + + +
+
+
+ )} {showDetail && (
diff --git a/src/components/tv/TVVirtualRemote.tsx b/src/components/tv/TVVirtualRemote.tsx index 4d35317..6033cb0 100644 --- a/src/components/tv/TVVirtualRemote.tsx +++ b/src/components/tv/TVVirtualRemote.tsx @@ -30,8 +30,17 @@ function isVisible(element: HTMLElement) { } function getFocusableElements() { + const scope = document.querySelector('[data-tv-focus-scope="active"]'); + if (scope) { + return Array.from(scope.querySelectorAll(focusableSelector)) + .filter((element) => !element.closest('[data-tv-remote]')) + .filter((element) => !element.closest('[data-tv-no-focus="true"]')) + .filter(isVisible); + } + return Array.from(document.querySelectorAll(focusableSelector)) .filter((element) => !element.closest('[data-tv-remote]')) + .filter((element) => !element.closest('[data-tv-no-focus="true"]')) .filter(isVisible); } @@ -232,6 +241,25 @@ export default function TVVirtualRemote() { if (event.defaultPrevented) return; if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const active = document.activeElement; + if (active instanceof HTMLElement && active.closest('[data-tv-danmaku-settings]')) { + return; + } + + if ( + active instanceof HTMLInputElement && + active.type === 'range' && + active.closest('[data-tv-danmaku-settings]') + ) { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + return; + } + } + + if (active instanceof HTMLInputElement && active.type === 'range' && active.closest('[data-tv-no-focus="true"]')) { + return; + } + event.preventDefault(); const direction = event.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right'; moveSpatialFocus(direction, lastFocusedRef.current); @@ -239,6 +267,11 @@ export default function TVVirtualRemote() { } if (event.key === 'Enter') { + const playerRoot = document.querySelector('[data-tv-player-root]'); + if (playerRoot?.dataset.tvControlsOpen === 'false') { + return; + } + const active = document.activeElement; if (active instanceof HTMLElement && !active.closest('input, textarea, select') && !active.closest('[data-tv-remote]')) { event.preventDefault(); diff --git a/src/components/tv/player/TVNativeVideo.tsx b/src/components/tv/player/TVNativeVideo.tsx index ff8cacf..f69fce3 100644 --- a/src/components/tv/player/TVNativeVideo.tsx +++ b/src/components/tv/player/TVNativeVideo.tsx @@ -235,14 +235,18 @@ export default function TVNativeVideo({ attach(); - let seekedInitialTime = false; + let triedInitialSeek = false; const seekToInitialTime = () => { - if (live || seekedInitialTime || !startTime || startTime <= 1) return; + if (live || triedInitialSeek || !startTime || startTime <= 1) return; + if ((videoEl.currentTime || 0) > 1) { + triedInitialSeek = true; + return; + } const duration = videoEl.duration || 0; const safeTime = duration > 30 ? Math.min(startTime, Math.max(0, duration - 8)) : startTime; try { videoEl.currentTime = safeTime; - seekedInitialTime = true; + triedInitialSeek = true; } catch { // ignore unsupported seek state } @@ -289,7 +293,7 @@ export default function TVNativeVideo({ videoEl.addEventListener('waiting', onBuffering); videoEl.addEventListener('stalled', onBuffering); videoEl.addEventListener('seeking', onBuffering); - videoEl.addEventListener('seeked', onLoaded); + videoEl.addEventListener('seeked', clearBuffering); videoEl.addEventListener('error', onError); videoEl.addEventListener('timeupdate', onTimeUpdate); @@ -305,7 +309,7 @@ export default function TVNativeVideo({ videoEl.removeEventListener('waiting', onBuffering); videoEl.removeEventListener('stalled', onBuffering); videoEl.removeEventListener('seeking', onBuffering); - videoEl.removeEventListener('seeked', onLoaded); + videoEl.removeEventListener('seeked', clearBuffering); videoEl.removeEventListener('error', onError); videoEl.removeEventListener('timeupdate', onTimeUpdate); clearBuffering(); diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 6511410..ba4fa23 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -18,6 +18,13 @@ export function createEmptyFeatureAccessMap(): FeatureAccessMap { }, {} as FeatureAccessMap); } +function createFullFeatureAccessMap(): FeatureAccessMap { + return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as FeatureAccessMap); +} + function isPrivilegedRole(role?: string) { return role === 'owner' || role === 'admin'; } @@ -25,10 +32,7 @@ function isPrivilegedRole(role?: string) { async function getUserFeatureAccessMap(username: string): Promise { const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; if (storageType === 'localstorage') { - return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {} as FeatureAccessMap); + return createFullFeatureAccessMap(); } const userInfo = await db.getUserInfoV2(username); @@ -37,10 +41,7 @@ async function getUserFeatureAccessMap(username: string): Promise { - acc[key] = true; - return acc; - }, {} as FeatureAccessMap); + return createFullFeatureAccessMap(); } const config = await getConfig(); @@ -48,10 +49,7 @@ async function getUserFeatureAccessMap(username: string): Promise { - acc[key] = true; - return acc; - }, {} as FeatureAccessMap); + return createFullFeatureAccessMap(); } const allowedPermissions = new Set(); @@ -71,7 +69,14 @@ async function getUserFeatureAccessMap(username: string): Promise { if (!username) return createEmptyFeatureAccessMap(); - return getUserFeatureAccessMap(username); + try { + return await getUserFeatureAccessMap(username); + } catch (error) { + console.error('[Permissions] Failed to load feature access:', error); + return username === process.env.USERNAME + ? createFullFeatureAccessMap() + : createEmptyFeatureAccessMap(); + } } export async function hasFeaturePermission( diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 8ff6920..4233a38 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -5,10 +5,27 @@ import { Redis } from '@upstash/redis'; import { UpstashRedisAdapter } from './redis-adapter'; import { BaseRedisStorage } from './redis-base.db'; +const DEFAULT_UPSTASH_TIMEOUT_MS = + process.env.NODE_ENV === 'development' ? 2500 : 8000; +const UPSTASH_TIMEOUT_MS = Math.max( + 1000, + Number(process.env.UPSTASH_TIMEOUT_MS || DEFAULT_UPSTASH_TIMEOUT_MS) +); +const DEFAULT_UPSTASH_RETRIES = + process.env.NODE_ENV === 'development' ? 1 : 3; +const UPSTASH_MAX_RETRIES = Math.max( + 1, + Number(process.env.UPSTASH_MAX_RETRIES || DEFAULT_UPSTASH_RETRIES) +); + +function createUpstashAbortSignal() { + return AbortSignal.timeout(UPSTASH_TIMEOUT_MS); +} + // 添加Upstash Redis操作重试包装器 async function withRetry( operation: () => Promise, - maxRetries = 3 + maxRetries = UPSTASH_MAX_RETRIES ): Promise { for (let i = 0; i < maxRetries; i++) { try { @@ -19,6 +36,10 @@ async function withRetry( err.message?.includes('Connection') || err.message?.includes('ECONNREFUSED') || err.message?.includes('ENOTFOUND') || + err.name === 'AbortError' || + err.name === 'TimeoutError' || + err.message?.includes('Aborted') || + err.message?.includes('Timeout') || err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.name === 'UpstashError'; @@ -68,11 +89,12 @@ function getUpstashRedisClient(): Redis { client = new Redis({ url: upstashUrl, token: upstashToken, + signal: createUpstashAbortSignal, // 可选配置 retry: { - retries: 3, + retries: UPSTASH_MAX_RETRIES, backoff: (retryCount: number) => - Math.min(1000 * Math.pow(2, retryCount), 30000), + Math.min(500 * Math.pow(2, retryCount), 5000), }, });