diff --git a/src/App.vue b/src/App.vue index 916176a7..75f42af0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,7 +2,7 @@ import { usePreferredReducedMotion } from '@vueuse/core' import { useTheme } from 'vuetify' import { ensureRenderComplete, removeEl } from './@core/utils/dom' -import api, { type ConnectionAwareRequestConfig } from '@/api' +import api from '@/api' import { useAuthStore, useGlobalSettingsStore } from '@/stores' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { SupportedLocale } from '@/types/i18n' @@ -30,7 +30,8 @@ import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette import { getDisplayImageUrl } from '@/utils/imageUtils' import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { configureApexChartsTheme } from '@/utils/apexCharts' -import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' +import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus' +import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe' import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { loadMediaSources } from '@/composables/useMediaSources' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' @@ -159,6 +160,12 @@ const router = useRouter() const { initializePWA } = usePWA() const offlineStatus = useGlobalOfflineStatus() const { isRestarting: isSystemRestarting } = useSystemRestartStatus() +const serverConnectionProbe = useServerConnectionProbe({ + isLoggedIn: isLogin, + isRestarting: isSystemRestarting, + offlineStatus, + request: (path, config) => api.get(path, config), +}) // 全局设置store const globalSettingsStore = useGlobalSettingsStore() @@ -370,130 +377,12 @@ void router.isReady().then(() => { isInitialRouteReady.value = true }) -let heartbeatInterval: number | null = null -let connectionRetryTimer: number | null = null -let connectionProbePromise: Promise | null = null -let connectionProbeFailures = 0 let prefersColorSchemeMediaQuery: MediaQueryList | null = null -const SERVER_PROBE_TIMEOUT_MS = 8_000 -const SERVER_PROBE_FAILURE_THRESHOLD = 2 -const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const - -/** 清除等待中的服务重连任务。 */ -function clearConnectionRetryTimer() { - if (!connectionRetryTimer) return - - window.clearTimeout(connectionRetryTimer) - connectionRetryTimer = null -} - -/** 根据浏览器状态和请求错误判断本次探测失败原因。 */ -function resolveProbeFailureReason(error: unknown): ConnectionFailureReason { - if (!offlineStatus.browserOnline.value) return 'browser-offline' - - const errorCode = (error as { code?: string } | null)?.code - if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout' - - return 'server-unreachable' -} - -/** 按退避间隔安排下一次 MoviePilot 服务探测。 */ -function scheduleConnectionRetry() { - clearConnectionRetryTimer() - - const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1) - connectionRetryTimer = window.setTimeout(() => { - connectionRetryTimer = null - void probeServerConnection() - }, SERVER_RETRY_DELAYS_MS[retryIndex]) -} - -/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */ -async function probeServerConnection(showChecking = false): Promise { - if (!isLogin.value) return false - if (connectionProbePromise) return connectionProbePromise - - clearConnectionRetryTimer() - if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined) - - const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value - const probePromise = (async () => { - try { - await api.get('system/ping', { - feedback: 'silent', - skipNavigationCancellation: true, - skipConnectionTracking: true, - timeout: SERVER_PROBE_TIMEOUT_MS, - } as ConnectionAwareRequestConfig) - connectionProbeFailures = 0 - return true - } catch (error) { - if (!isLogin.value) { - offlineStatus.markServerOnline() - return false - } - - // 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。 - if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) { - connectionProbeFailures = 0 - return true - } - - connectionProbeFailures += 1 - const failureReason = resolveProbeFailureReason(error) - - // 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。 - if (isSystemRestarting.value) return false - - if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) { - offlineStatus.markServerOffline(failureReason) - } else { - offlineStatus.markConnectionChecking(failureReason) - } - - scheduleConnectionRetry() - return false - } - })() - - connectionProbePromise = probePromise - try { - return await probePromise - } finally { - if (connectionProbePromise === probePromise) connectionProbePromise = null - } -} - -/** 启动即时服务探测和五分钟在线心跳。 */ -function startHeartbeat() { - if (heartbeatInterval) window.clearInterval(heartbeatInterval) - - void probeServerConnection() - - heartbeatInterval = window.setInterval( - async () => { - if (isLogin.value) await probeServerConnection() - }, - 5 * 60 * 1000, - ) -} - -/** 停止心跳和等待中的自动重连任务。 */ -function stopHeartbeat() { - if (heartbeatInterval) { - window.clearInterval(heartbeatInterval) - heartbeatInterval = null - } - - clearConnectionRetryTimer() - connectionProbeFailures = 0 -} - watch( () => offlineStatus.connectionCheckRequestId.value, () => { - if (isLogin.value) void probeServerConnection(true) + if (isLogin.value) void serverConnectionProbe.probeServerConnection(true) }, ) @@ -501,8 +390,7 @@ watch( () => offlineStatus.connectionStatus.value, status => { if (status !== 'online') return - connectionProbeFailures = 0 - clearConnectionRetryTimer() + serverConnectionProbe.resetAfterServerOnline() }, ) @@ -1112,20 +1000,20 @@ onMounted(async () => { }) // 启动心跳 if (isLogin.value) { - startHeartbeat() + serverConnectionProbe.startHeartbeat() } // 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。 watch(isLogin, loggedIn => { if (loggedIn) { - startHeartbeat() + serverConnectionProbe.startHeartbeat() scheduleAuthenticatedStateInitialization() } else { if (authenticatedStateTimer) { window.clearTimeout(authenticatedStateTimer) authenticatedStateTimer = null } - stopHeartbeat() + serverConnectionProbe.stopHeartbeat() offlineStatus.markServerOnline() } }) @@ -1140,7 +1028,7 @@ onUnmounted(() => { authenticatedStateTimer = null } // 停止心跳 - stopHeartbeat() + serverConnectionProbe.stopHeartbeat() prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange) prefersColorSchemeMediaQuery = null document.removeEventListener('visibilitychange', handleVisibilityThemeSync) diff --git a/src/composables/__tests__/useServerConnectionProbe.spec.ts b/src/composables/__tests__/useServerConnectionProbe.spec.ts new file mode 100644 index 00000000..f31a22af --- /dev/null +++ b/src/composables/__tests__/useServerConnectionProbe.spec.ts @@ -0,0 +1,211 @@ +import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' +import { useServerConnectionProbe } from '@/composables/useServerConnectionProbe' +import { ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +function createOfflineStatus() { + const browserOnline = ref(true) + const connectionReason = ref(null) + const serverSuccessSequence = ref(0) + const markConnectionChecking = vi.fn((reason?: ConnectionFailureReason) => { + if (reason) connectionReason.value = reason + }) + const markServerOffline = vi.fn() + const markServerOnline = vi.fn() + + const offlineStatus = { + browserOnline, + connectionReason, + markConnectionChecking, + markServerOffline, + markServerOnline, + serverSuccessSequence, + } as unknown as ReturnType + + return { browserOnline, offlineStatus, serverSuccessSequence } +} + +describe('useServerConnectionProbe', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('未登录时不探测,并用固定静默选项去重并发 ping', async () => { + const isLoggedIn = ref(false) + const pendingRequest = deferred() + const request = vi.fn(() => pendingRequest.promise) + const probe = useServerConnectionProbe({ + isLoggedIn, + isRestarting: ref(false), + offlineStatus: createOfflineStatus().offlineStatus, + request, + }) + + await expect(probe.probeServerConnection()).resolves.toBe(false) + expect(request).not.toHaveBeenCalled() + + isLoggedIn.value = true + const firstProbe = probe.probeServerConnection() + const concurrentProbe = probe.probeServerConnection(true) + + expect(request).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith('system/ping', { + feedback: 'silent', + skipNavigationCancellation: true, + skipConnectionTracking: true, + timeout: 8_000, + }) + + pendingRequest.resolve({}) + await expect(Promise.all([firstProbe, concurrentProbe])).resolves.toEqual([true, true]) + }) + + it('普通失败先进入 checking,第二次失败才离线并按退避间隔重试', async () => { + const { offlineStatus } = createOfflineStatus() + const request = vi.fn().mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })) + const probe = useServerConnectionProbe({ + isLoggedIn: ref(true), + isRestarting: ref(false), + offlineStatus, + request, + }) + + await expect(probe.probeServerConnection(true)).resolves.toBe(false) + expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(1, undefined) + expect(offlineStatus.markConnectionChecking).toHaveBeenNthCalledWith(2, 'timeout') + expect(offlineStatus.markServerOffline).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + + await expect(probe.probeServerConnection()).resolves.toBe(false) + expect(offlineStatus.markServerOffline).toHaveBeenCalledWith('timeout') + expect(request).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(4_999) + expect(request).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(3) + + probe.stopHeartbeat() + }) + + it('按浏览器状态区分离线原因,并在任意接口已成功时忽略迟到失败', async () => { + const { browserOnline, offlineStatus, serverSuccessSequence } = createOfflineStatus() + const pendingRequest = deferred() + const request = vi.fn(() => pendingRequest.promise) + const probe = useServerConnectionProbe({ + isLoggedIn: ref(true), + isRestarting: ref(false), + offlineStatus, + request, + }) + + browserOnline.value = false + const pendingProbe = probe.probeServerConnection() + serverSuccessSequence.value += 1 + pendingRequest.reject(new Error('offline')) + + await expect(pendingProbe).resolves.toBe(true) + expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled() + expect(offlineStatus.markServerOffline).not.toHaveBeenCalled() + + request.mockRejectedValueOnce(new Error('offline')) + await expect(probe.probeServerConnection()).resolves.toBe(false) + expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('browser-offline') + + probe.stopHeartbeat() + }) + + it('探测期间退出登录时恢复在线状态且不安排重试', async () => { + const isLoggedIn = ref(true) + const pendingRequest = deferred() + const { offlineStatus } = createOfflineStatus() + const probe = useServerConnectionProbe({ + isLoggedIn, + isRestarting: ref(false), + offlineStatus, + request: () => pendingRequest.promise, + }) + + const pendingProbe = probe.probeServerConnection() + isLoggedIn.value = false + pendingRequest.reject(new Error('signed out')) + + await expect(pendingProbe).resolves.toBe(false) + expect(offlineStatus.markServerOnline).toHaveBeenCalledOnce() + expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('重启期间的失败不提示、不重试,也不累计到重启后的离线阈值', async () => { + const isRestarting = ref(true) + const { offlineStatus } = createOfflineStatus() + const request = vi.fn().mockRejectedValue(new Error('restarting')) + const probe = useServerConnectionProbe({ + isLoggedIn: ref(true), + isRestarting, + offlineStatus, + request, + }) + + await probe.probeServerConnection() + await probe.probeServerConnection() + expect(offlineStatus.markConnectionChecking).not.toHaveBeenCalled() + expect(offlineStatus.markServerOffline).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + + isRestarting.value = false + await probe.probeServerConnection() + + expect(offlineStatus.markConnectionChecking).toHaveBeenCalledWith('server-unreachable') + expect(offlineStatus.markServerOffline).not.toHaveBeenCalled() + probe.stopHeartbeat() + }) + + it('启动时立即探测并按五分钟心跳运行,在线恢复和停止都会清理等待任务', async () => { + const request = vi.fn().mockResolvedValue({}) + const probe = useServerConnectionProbe({ + isLoggedIn: ref(true), + isRestarting: ref(false), + offlineStatus: createOfflineStatus().offlineStatus, + request, + }) + + probe.startHeartbeat() + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + expect(request).toHaveBeenCalledTimes(2) + + probe.startHeartbeat() + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(0) + + request.mockRejectedValueOnce(new Error('offline')) + await expect(probe.probeServerConnection()).resolves.toBe(false) + expect(vi.getTimerCount()).toBe(2) + + probe.resetAfterServerOnline() + expect(vi.getTimerCount()).toBe(1) + probe.stopHeartbeat() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/composables/useServerConnectionProbe.ts b/src/composables/useServerConnectionProbe.ts new file mode 100644 index 00000000..da450367 --- /dev/null +++ b/src/composables/useServerConnectionProbe.ts @@ -0,0 +1,147 @@ +import type { ConnectionAwareRequestConfig } from '@/api' +import type { ConnectionFailureReason, useGlobalOfflineStatus } from '@/composables/useOfflineStatus' +import { toValue, type MaybeRefOrGetter } from 'vue' + +interface ServerConnectionProbeOptions { + /** 当前会话是否已具备访问 MoviePilot API 的登录态。 */ + isLoggedIn: MaybeRefOrGetter + /** 系统是否处于主动重启流程。 */ + isRestarting: MaybeRefOrGetter + /** 全局连接状态及其唯一写入操作。 */ + offlineStatus: ReturnType + /** 使用静默连接选项请求 MoviePilot API。 */ + request: (path: string, config: ConnectionAwareRequestConfig) => Promise +} + +const SERVER_PROBE_TIMEOUT_MS = 8_000 +const SERVER_PROBE_FAILURE_THRESHOLD = 2 +const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const +const SERVER_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000 + +/** 编排 MoviePilot 服务探测、退避重试和在线心跳。 */ +export function useServerConnectionProbe(options: ServerConnectionProbeOptions) { + let heartbeatInterval: number | null = null + let connectionRetryTimer: number | null = null + let connectionProbePromise: Promise | null = null + let connectionProbeFailures = 0 + + /** 清除等待中的服务重连任务。 */ + function clearConnectionRetryTimer() { + if (!connectionRetryTimer) return + + window.clearTimeout(connectionRetryTimer) + connectionRetryTimer = null + } + + /** 根据浏览器状态和请求错误判断本次探测失败原因。 */ + function resolveProbeFailureReason(error: unknown): ConnectionFailureReason { + if (!options.offlineStatus.browserOnline.value) return 'browser-offline' + + const errorCode = (error as { code?: string } | null)?.code + if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout' + + return 'server-unreachable' + } + + /** 按退避间隔安排下一次 MoviePilot 服务探测。 */ + function scheduleConnectionRetry() { + clearConnectionRetryTimer() + + const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1) + connectionRetryTimer = window.setTimeout(() => { + connectionRetryTimer = null + void probeServerConnection() + }, SERVER_RETRY_DELAYS_MS[retryIndex]) + } + + /** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */ + async function probeServerConnection(showChecking = false): Promise { + if (!toValue(options.isLoggedIn)) return false + if (connectionProbePromise) return connectionProbePromise + + clearConnectionRetryTimer() + if (showChecking) { + options.offlineStatus.markConnectionChecking(options.offlineStatus.connectionReason.value ?? undefined) + } + + const successSequenceAtProbeStart = options.offlineStatus.serverSuccessSequence.value + const probePromise = (async () => { + try { + await options.request('system/ping', { + feedback: 'silent', + skipNavigationCancellation: true, + skipConnectionTracking: true, + timeout: SERVER_PROBE_TIMEOUT_MS, + }) + connectionProbeFailures = 0 + return true + } catch (error) { + if (!toValue(options.isLoggedIn)) { + options.offlineStatus.markServerOnline() + return false + } + + // 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。 + if (options.offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) { + connectionProbeFailures = 0 + return true + } + + // 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。 + if (toValue(options.isRestarting)) return false + + connectionProbeFailures += 1 + const failureReason = resolveProbeFailureReason(error) + + if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) { + options.offlineStatus.markServerOffline(failureReason) + } else { + options.offlineStatus.markConnectionChecking(failureReason) + } + + scheduleConnectionRetry() + return false + } + })() + + connectionProbePromise = probePromise + try { + return await probePromise + } finally { + if (connectionProbePromise === probePromise) connectionProbePromise = null + } + } + + /** 清除失败计数和等待中的重试,用于任意 API 已证明服务恢复在线的场景。 */ + function resetAfterServerOnline() { + connectionProbeFailures = 0 + clearConnectionRetryTimer() + } + + /** 启动即时服务探测和五分钟在线心跳。 */ + function startHeartbeat() { + if (heartbeatInterval) window.clearInterval(heartbeatInterval) + + void probeServerConnection() + heartbeatInterval = window.setInterval(() => { + if (toValue(options.isLoggedIn)) void probeServerConnection() + }, SERVER_HEARTBEAT_INTERVAL_MS) + } + + /** 停止心跳、重试和失败计数。 */ + function stopHeartbeat() { + if (heartbeatInterval) { + window.clearInterval(heartbeatInterval) + heartbeatInterval = null + } + + resetAfterServerOnline() + } + + return { + probeServerConnection, + resetAfterServerOnline, + startHeartbeat, + stopHeartbeat, + } +} diff --git a/vite.config.ts b/vite.config.ts index afaf1302..e2f945b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -337,6 +337,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/composables/useMediaSubscribe.ts', 'src/composables/useLlmProviderDirectory.ts', 'src/composables/useOfflineStatus.ts', + 'src/composables/useServerConnectionProbe.ts', 'src/composables/useTorrentFilter.ts', 'src/layouts/default/components/OfflinePage.vue', 'src/components/cards/SubscribeCard.vue', @@ -433,6 +434,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/composables/useServerConnectionProbe.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/layouts/default/components/OfflinePage.vue': { branches: 75, functions: 80,