优化,排查项目问题

This commit is contained in:
truewhile
2026-09-05 12:34:17 +08:00
parent 7fa05391e1
commit 1407b9b5c4
85 changed files with 1940 additions and 638 deletions
+7 -2
View File
@@ -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)
-123
View File
@@ -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,
}
}
+12 -5
View File
@@ -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)
}
}
+1
View File
@@ -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}
+6 -3
View File
@@ -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>
+10 -4
View File
@@ -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>
)
+7 -1
View File
@@ -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}
+21
View File
@@ -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. 全局访问与隔离卡片 */}
+35 -11
View File
@@ -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}
+5
View File
@@ -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>
)}
+22 -8
View File
@@ -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)
+6 -1
View File
@@ -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)
}
}
+22 -8
View File
@@ -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
+10
View File
@@ -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('授权已过期,请重新发起')
+8 -1
View File
@@ -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,
+10 -2
View File
@@ -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)
+24 -10
View File
@@ -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)
}