mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
优化,排查项目问题
This commit is contained in:
@@ -114,26 +114,31 @@ function LayoutHeaderSearch() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// 递增序号守卫:快速连续输入时丢弃过期响应
|
||||
const searchSeqRef = useRef(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
searchSeqRef.current += 1
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++searchSeqRef.current
|
||||
setLoading(true)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
if (seq !== searchSeqRef.current) return
|
||||
setResults(res.items || [])
|
||||
setIsOpen(true)
|
||||
} catch {
|
||||
setResults([])
|
||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (seq === searchSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, 250)
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SSEEvent } from '../types'
|
||||
|
||||
type SSEEventHandler = (event: SSEEvent) => void
|
||||
|
||||
/**
|
||||
* useSSE hook - 管理 Server-Sent Events 连接
|
||||
*
|
||||
* @param onEvent - 事件处理函数
|
||||
* @param options - 配置选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { connect, disconnect } = useSSE((event) => {
|
||||
* if (event.type === 'scan') {
|
||||
* updateScanProgress(event.payload)
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* connect()
|
||||
* return () => disconnect()
|
||||
* }, [])
|
||||
*
|
||||
* return <div>SSE Demo</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSSE(
|
||||
onEvent: SSEEventHandler,
|
||||
options: { autoConnect?: boolean } = {}
|
||||
) {
|
||||
const { autoConnect = true } = options
|
||||
const onEventRef = useRef(onEvent)
|
||||
onEventRef.current = onEvent
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isConnectedRef = useRef(false)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const maxReconnectAttempts = 5
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// 如果已有连接,先断开
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (!token) {
|
||||
console.warn('Cannot connect to SSE: No auth token')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `/api/events?token=${encodeURIComponent(token)}`
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnectedRef.current = true
|
||||
reconnectAttemptsRef.current = 0
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SSEEvent
|
||||
onEventRef.current(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnectedRef.current = false
|
||||
eventSource.close()
|
||||
|
||||
// 尝试重连
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
||||
reconnectAttemptsRef.current++
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay)
|
||||
} else {
|
||||
console.error('SSE connection failed after max attempts')
|
||||
}
|
||||
}
|
||||
|
||||
}, [])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
isConnectedRef.current = false
|
||||
reconnectAttemptsRef.current = 0
|
||||
}, [])
|
||||
|
||||
const isConnected = useCallback(() => {
|
||||
return isConnectedRef.current
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect()
|
||||
}
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [autoConnect, connect, disconnect])
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5
|
||||
// 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃,
|
||||
// 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present, but stops after repeated failures so an expired token
|
||||
// cannot create an endless /api/ws 401 loop.
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
// slow 60s retry loop instead of giving up permanently.
|
||||
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
|
||||
const ref = useRef<WebSocket | null>(null)
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -22,7 +25,6 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
|
||||
const ws = new WebSocket(url)
|
||||
@@ -43,7 +45,12 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
reconnectAttempts += 1
|
||||
const delay = Math.min(3_000 * reconnectAttempts, 30_000)
|
||||
// 快速阶段保持原有线性退避,之后固定 60s 慢速重试;
|
||||
// timer 始终只有一个在途,cleanup 时统一清除,不会堆积。
|
||||
const delay =
|
||||
reconnectAttempts <= FAST_RECONNECT_ATTEMPTS
|
||||
? Math.min(3_000 * reconnectAttempts, 30_000)
|
||||
: SLOW_RECONNECT_INTERVAL
|
||||
timer = window.setTimeout(open, delay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function AdminLibraryPanel() {
|
||||
coverURL={createForm.coverURL}
|
||||
roots={createForm.roots}
|
||||
createPerSubfolder={createForm.createPerSubfolder}
|
||||
creating={createForm.creating}
|
||||
onNameChange={createForm.setName}
|
||||
onTypeChange={createForm.setType}
|
||||
onCoverURLChange={createForm.setCoverURL}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { Folder, Plus, Trash2 } from 'lucide-react'
|
||||
import { Folder, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import type { RootDraft } from './adminLibraryPanelModel'
|
||||
@@ -10,6 +10,7 @@ type CreateFormProps = {
|
||||
coverURL: string
|
||||
roots: RootDraft[]
|
||||
createPerSubfolder: boolean
|
||||
creating: boolean
|
||||
onNameChange: (value: string) => void
|
||||
onTypeChange: (value: string) => void
|
||||
onCoverURLChange: (value: string) => void
|
||||
@@ -26,6 +27,7 @@ export function AdminLibraryCreateForm({
|
||||
coverURL,
|
||||
roots,
|
||||
createPerSubfolder,
|
||||
creating,
|
||||
onNameChange,
|
||||
onTypeChange,
|
||||
onCoverURLChange,
|
||||
@@ -108,8 +110,9 @@ export function AdminLibraryCreateForm({
|
||||
批处理模式:仅取上方第一个路径作为父级目录,会为其中每个子文件夹分别创建媒体库,可自选类型用于整体推断。
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
{createPerSubfolder ? '按目录批量创建' : '新建 / 追加路径'}
|
||||
<button type="submit" className="neon-button md:col-span-4 disabled:opacity-50" disabled={creating}>
|
||||
{creating && <Loader2 size={16} className="animate-spin" />}
|
||||
{creating ? '创建中…' : createPerSubfolder ? '按目录批量创建' : '新建 / 追加路径'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Plus, Save } from 'lucide-react'
|
||||
import { Loader2, Plus, Save } from 'lucide-react'
|
||||
|
||||
type AdminUsersFormProps = {
|
||||
usersCount: number
|
||||
maxUsers: number
|
||||
maxUsersDraft: string
|
||||
savingLimit: boolean
|
||||
creating: boolean
|
||||
username: string
|
||||
password: string
|
||||
userLimitReached: boolean
|
||||
@@ -21,6 +22,7 @@ export function AdminUsersForm({
|
||||
maxUsers,
|
||||
maxUsersDraft,
|
||||
savingLimit,
|
||||
creating,
|
||||
username,
|
||||
password,
|
||||
userLimitReached,
|
||||
@@ -91,9 +93,13 @@ export function AdminUsersForm({
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
disabled={userLimitReached}
|
||||
/>
|
||||
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={userLimitReached}>
|
||||
<Plus size={16} />
|
||||
添加用户
|
||||
<button
|
||||
type="submit"
|
||||
className="neon-button inline-flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
disabled={userLimitReached || creating}
|
||||
>
|
||||
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
|
||||
{creating ? '添加中…' : '添加用户'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ export function AdminUsersPanel() {
|
||||
const [maxUsers, setMaxUsers] = useState(DEFAULT_MAX_USERS)
|
||||
const [maxUsersDraft, setMaxUsersDraft] = useState(String(DEFAULT_MAX_USERS))
|
||||
const [savingLimit, setSavingLimit] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
@@ -69,18 +70,22 @@ export function AdminUsersPanel() {
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await adminAPI.createUser({ username, password })
|
||||
toast.success('用户已添加,默认仅允许浏览与播放媒体')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
userCreateErrorMessage(err) ??
|
||||
'添加用户失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
await refresh().catch(() => undefined)
|
||||
}
|
||||
|
||||
const startEdit = (u: User) => {
|
||||
@@ -171,6 +176,7 @@ export function AdminUsersPanel() {
|
||||
maxUsers={maxUsers}
|
||||
maxUsersDraft={maxUsersDraft}
|
||||
savingLimit={savingLimit}
|
||||
creating={creating}
|
||||
username={username}
|
||||
password={password}
|
||||
userLimitReached={userLimitReached}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function AdultSettingsPanel() {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [dirty, setDirty] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
@@ -79,6 +80,13 @@ export function AdultSettingsPanel() {
|
||||
setValues(idx)
|
||||
setLibraries(libs as Library[])
|
||||
setDirty(new Set())
|
||||
setLoadError('')
|
||||
} catch (err: unknown) {
|
||||
// 加载失败时保留错误态,避免把表单默认值误当成已保存的配置
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载成人设置失败'
|
||||
setLoadError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -196,6 +204,19 @@ export function AdultSettingsPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="glass-panel flex flex-col items-center gap-3 py-10 text-center">
|
||||
<XCircle className="text-rose-500" size={24} />
|
||||
<p className="text-sm text-ink-100">成人设置加载失败:{loadError}</p>
|
||||
<p className="text-xs text-sand-500">当前展示的并非已保存配置,请重新加载后再修改</p>
|
||||
<button type="button" onClick={() => refresh()} className="neon-button !px-4 !py-1.5 !text-xs">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="space-y-6">
|
||||
{/* 1. 全局访问与隔离卡片 */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Media } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
@@ -62,6 +63,8 @@ export function PlayerPage() {
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
const [loadError, setLoadError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
const [resumePosition, setResumePosition] = useState(0)
|
||||
@@ -176,6 +179,7 @@ export function PlayerPage() {
|
||||
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
|
||||
useEffect(() => {
|
||||
setMedia(null)
|
||||
setLoadError('')
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearch(null)
|
||||
@@ -184,29 +188,48 @@ export function PlayerPage() {
|
||||
setDanmakuSearching(true)
|
||||
}, [id])
|
||||
|
||||
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
|
||||
const modeParam = params.get('mode') as PlayerMode | null
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
mediaAPI.get(id).then((m) => {
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const forced = params.get('mode') as PlayerMode | null
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
|
||||
setMode(directOnly || isDirect ? 'direct' : (forced ?? auto))
|
||||
setPlayerError('')
|
||||
})
|
||||
let cancelled = false
|
||||
mediaAPI
|
||||
.get(id)
|
||||
.then((m) => {
|
||||
if (cancelled) return
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
|
||||
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
|
||||
setPlayerError('')
|
||||
setLoadError('')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
||||
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
||||
})
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.then((tracks) => {
|
||||
if (cancelled) return
|
||||
const list = tracks ?? []
|
||||
setSubs(list)
|
||||
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
||||
// 越界时回退到第一条;无字幕则关闭。
|
||||
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
||||
})
|
||||
.catch(() => setSubs([]))
|
||||
}, [id, params, directOnly])
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSubs([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
useEffect(() => {
|
||||
@@ -544,6 +567,7 @@ export function PlayerPage() {
|
||||
/>
|
||||
<PlayerVideoStage
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
playerError={playerError}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { PlayerControls } from '../components/PlayerControls'
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||
loadError?: string
|
||||
playerError: string
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
@@ -43,6 +45,7 @@ type PlayerVideoStageProps = {
|
||||
|
||||
export function PlayerVideoStage({
|
||||
media,
|
||||
loadError,
|
||||
playerError,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
@@ -317,6 +320,8 @@ export function PlayerVideoStage({
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
</>
|
||||
) : loadError ? (
|
||||
<p className="max-w-[92%] text-center text-sm text-rose-400">{loadError}</p>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -56,13 +56,20 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||
// 轮询/翻页/切筛选并发时用递增序号丢弃过期响应
|
||||
const refreshSeqRef = useRef(0)
|
||||
const [pollFailures, setPollFailures] = useState(0)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setIsRefreshing(true)
|
||||
const seq = ++refreshSeqRef.current
|
||||
try {
|
||||
const status = filter === 'all' ? undefined : filter
|
||||
const data = await scraperAPI.queue(status, page, PAGE_SIZE)
|
||||
// 序号不符说明已有更新的请求发出(翻页/切筛选/轮询并发),丢弃旧响应
|
||||
if (seq !== refreshSeqRef.current) return
|
||||
setPollFailures(0)
|
||||
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
|
||||
if (page > tp) {
|
||||
setPage(tp)
|
||||
@@ -71,7 +78,8 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
setTotalPages(tp)
|
||||
setSnapshot(data)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
// 保留旧数据;连续失败 ≥3 次时页头徽标切换为「连接失败,重试中」
|
||||
if (seq === refreshSeqRef.current) setPollFailures((n) => n + 1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (showLoading) setIsRefreshing(false)
|
||||
@@ -232,12 +240,18 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">刮削队列</h1>
|
||||
{autoRefresh && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
)}
|
||||
{autoRefresh &&
|
||||
(pollFailures >= 3 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-300/40 bg-rose-500/10 px-2 py-0.5 text-[11px] font-semibold text-rose-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-rose-500" />
|
||||
连接失败,重试中
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
媒体元数据在线识别与海报/剧照下载进度(TMDb / 豆瓣 / Bangumi / TheTVDB)
|
||||
|
||||
@@ -899,18 +899,23 @@ function StrmDirBrowserDialog({
|
||||
const [crumbs, setCrumbs] = useState<string[]>([])
|
||||
const [entries, setEntries] = useState<StrmRemoteEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 递增序号守卫:快速连续进入目录时丢弃过期目录响应
|
||||
const loadSeqRef = useRef(0)
|
||||
|
||||
const load = async (target: string) => {
|
||||
const seq = ++loadSeqRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await strmAPI.listRemoteDir(accountId, target)
|
||||
if (seq !== loadSeqRef.current) return
|
||||
setEntries(list)
|
||||
setDir(target)
|
||||
setCrumbs(target ? target.split('/').filter(Boolean) : [])
|
||||
} catch (err) {
|
||||
if (seq !== loadSeqRef.current) return
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (seq === loadSeqRef.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -53,6 +53,9 @@ export function StrmQueuePanel({
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<StrmTask | null>(null)
|
||||
// 轮询/翻页/切筛选并发时用递增序号丢弃过期响应
|
||||
const refreshSeqRef = useRef(0)
|
||||
const [pollFailures, setPollFailures] = useState(0)
|
||||
|
||||
const isDownload = kind === 'download'
|
||||
const Icon = isDownload ? Download : Upload
|
||||
@@ -60,11 +63,15 @@ export function StrmQueuePanel({
|
||||
const refresh = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setIsRefreshing(true)
|
||||
const seq = ++refreshSeqRef.current
|
||||
try {
|
||||
const status = filter === 'all' ? undefined : filter
|
||||
const data = isDownload
|
||||
? await strmAPI.downloads(status, page, PAGE_SIZE)
|
||||
: await strmAPI.uploads(status, page, PAGE_SIZE)
|
||||
// 序号不符说明已有更新的请求发出(翻页/切筛选/轮询并发),丢弃旧响应
|
||||
if (seq !== refreshSeqRef.current) return
|
||||
setPollFailures(0)
|
||||
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
|
||||
if (page > tp) {
|
||||
setPage(tp)
|
||||
@@ -73,7 +80,8 @@ export function StrmQueuePanel({
|
||||
setTotalPages(tp)
|
||||
setSnapshot(data)
|
||||
} catch {
|
||||
/* keep existing data */
|
||||
// 保留旧数据;连续失败 ≥3 次时页头徽标切换为「连接失败,重试中」
|
||||
if (seq === refreshSeqRef.current) setPollFailures((n) => n + 1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (showLoading) setIsRefreshing(false)
|
||||
@@ -227,12 +235,18 @@ export function StrmQueuePanel({
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">
|
||||
{isDownload ? '下载队列' : '上传队列'}
|
||||
</h1>
|
||||
{autoRefresh && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
)}
|
||||
{autoRefresh &&
|
||||
(pollFailures >= 3 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-300/40 bg-rose-500/10 px-2 py-0.5 text-[11px] font-semibold text-rose-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-rose-500" />
|
||||
连接失败,重试中
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
{isDownload
|
||||
|
||||
@@ -29,6 +29,10 @@ export function WatchHistoryPage() {
|
||||
historyAPI
|
||||
.list(200)
|
||||
.then(setItems)
|
||||
.catch((err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载观看历史失败'
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
@@ -41,6 +45,9 @@ export function WatchHistoryPage() {
|
||||
await historyAPI.remove(id)
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
toast.success('已移除观看历史')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '移除观看历史失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
@@ -54,6 +61,9 @@ export function WatchHistoryPage() {
|
||||
await historyAPI.clear(undefined, status)
|
||||
setItems((prev) => prev.filter((item) => status === 'completed' ? !item.completed : item.completed))
|
||||
toast.success(`已清除${label}记录`)
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? `清除${label}记录失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
|
||||
@@ -158,16 +158,30 @@ export function Strm115AuthPanel({
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
// confirmed / expired 只处理一次,防止重叠的轮询回调重复触发
|
||||
let settled = false
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (settled) return
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
if (settled) return
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
settled = true
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
setAuthStatus('授权成功,正在验证凭据…')
|
||||
try {
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
} catch (err) {
|
||||
// 授权已确认、令牌已保存;凭据验证失败不阻塞授权完成,
|
||||
// 避免轮询已停且 onAuthed 未回调导致弹窗卡死。
|
||||
toast.error(`凭据验证失败:${apiErrorMessage(err)},授权已保存,可稍后在账号列表重试`)
|
||||
onAuthed(account)
|
||||
}
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
settled = true
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
|
||||
@@ -41,9 +41,13 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
const [type, setType] = useState('movie')
|
||||
const [coverURL, setCoverURL] = useState('')
|
||||
const [createPerSubfolder, setCreatePerSubfolder] = useState(false)
|
||||
// 提交中标记:防止重复点击创建出多个媒体库
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
if (createPerSubfolder) {
|
||||
const parentPath = roots[0]?.path?.trim()
|
||||
@@ -66,10 +70,12 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
setRoots([emptyRootDraft()])
|
||||
setCoverURL('')
|
||||
setCreatePerSubfolder(false)
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '创建失败'))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
await refresh().catch(() => undefined)
|
||||
}
|
||||
|
||||
const updateRoot = (index: number, patch: Partial<RootDraft>) => {
|
||||
@@ -82,6 +88,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
coverURL,
|
||||
roots,
|
||||
createPerSubfolder,
|
||||
creating,
|
||||
setName,
|
||||
setType,
|
||||
setCoverURL,
|
||||
|
||||
@@ -72,7 +72,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, () => cancelled)
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, () => cancelled)
|
||||
if (!cancelled) setItems(collected.items)
|
||||
} catch {
|
||||
if (!cancelled) toast.error('媒体库加载失败')
|
||||
@@ -165,12 +165,16 @@ async function loadAllSeriesCards(
|
||||
libraryID: string,
|
||||
isRemoteEmby: boolean | undefined,
|
||||
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
|
||||
isCancelled: () => boolean,
|
||||
) {
|
||||
const pageSize = isRemoteEmby ? 100 : 500
|
||||
let page = 1
|
||||
let collected: SeriesCard[] = []
|
||||
for (;;) {
|
||||
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
|
||||
if (isCancelled()) return { items: collected }
|
||||
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
|
||||
if (isCancelled()) return { items: collected }
|
||||
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||
const pageItems = data.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
@@ -186,12 +190,16 @@ async function loadAllMedia(
|
||||
libraryID: string,
|
||||
isRemoteEmby: boolean | undefined,
|
||||
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
|
||||
isCancelled: () => boolean,
|
||||
) {
|
||||
const pageSize = isRemoteEmby ? 100 : 2000
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
for (;;) {
|
||||
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
|
||||
if (isCancelled()) return { items: collected }
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
|
||||
if (isCancelled()) return { items: collected }
|
||||
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||
const pageItems = data.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
|
||||
@@ -207,9 +207,13 @@ async function toggleMediaFavourite(
|
||||
setFavourite: Dispatch<SetStateAction<boolean>>,
|
||||
): Promise<void> {
|
||||
if (!media) return
|
||||
const state = await playbackAPI.toggleFavourite(media.id)
|
||||
setFavourite(state)
|
||||
toast.success(state ? '已加入我的收藏' : '已取消收藏')
|
||||
try {
|
||||
const state = await playbackAPI.toggleFavourite(media.id)
|
||||
setFavourite(state)
|
||||
toast.success(state ? '已加入我的收藏' : '已取消收藏')
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '收藏操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function rescrapeMedia(
|
||||
@@ -218,13 +222,18 @@ async function rescrapeMedia(
|
||||
refresh: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!media) return
|
||||
await api.post(`/media/${media.id}/scrape`, {
|
||||
episode_images: scrapeEpisodeArtwork,
|
||||
refresh_matched: true,
|
||||
include_matched: true,
|
||||
})
|
||||
try {
|
||||
await api.post(`/media/${media.id}/scrape`, {
|
||||
episode_images: scrapeEpisodeArtwork,
|
||||
refresh_matched: true,
|
||||
include_matched: true,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '触发重新刮削失败'))
|
||||
return
|
||||
}
|
||||
toast.success('已触发重新刮削')
|
||||
await refresh()
|
||||
await refresh().catch(() => undefined)
|
||||
}
|
||||
|
||||
async function reprobeMedia(media: Media | null, refresh: () => Promise<void>): Promise<void> {
|
||||
@@ -258,7 +267,12 @@ async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFun
|
||||
checkboxLabel: '同时删除本地文件(含同名 NFO)',
|
||||
})
|
||||
if (!result.confirmed) return
|
||||
await mediaAPI.delete(media.id, { deleteFiles: result.checked })
|
||||
try {
|
||||
await mediaAPI.delete(media.id, { deleteFiles: result.checked })
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '删除媒体失败'))
|
||||
return
|
||||
}
|
||||
toast.success(result.checked ? '已删除媒体及本地文件' : '已从媒体库删除')
|
||||
goBackFromMediaDetail(media, navigate, true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user