mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-16 04:44:23 +08:00
fix media event access, cache isolation and indexing
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Fragment, useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { libraryCacheScope } from '../utils/libraryCache'
|
||||
import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
|
||||
|
||||
// Route guard: redirects to /login when no token is present.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null)
|
||||
// Drop local page state and in-flight callbacks when visibility changes.
|
||||
useAuthStore((s) => s.user)
|
||||
usePlayProfileStore((s) => s.activeProfileId)
|
||||
usePlayProfileStore((s) => s.activeProfilePinToken)
|
||||
const visibilityKey = libraryCacheScope()
|
||||
const location = useLocation()
|
||||
|
||||
// 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
|
||||
@@ -23,7 +30,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <>{children}</>
|
||||
return <Fragment key={visibilityKey}>{children}</Fragment>
|
||||
}
|
||||
|
||||
// Route guard: only allows users with role === "admin".
|
||||
|
||||
@@ -15,6 +15,7 @@ const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
|
||||
const ref = useRef<WebSocket | null>(null)
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const onEventRef = useRef(onEvent)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,7 +23,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
}, [onEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
if (!token || !isAdmin) return
|
||||
let closed = false
|
||||
let timer: number | undefined
|
||||
let reconnectAttempts = 0
|
||||
@@ -88,5 +89,5 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
ref.current?.close()
|
||||
}
|
||||
}, [token])
|
||||
}, [token, isAdmin])
|
||||
}
|
||||
|
||||
@@ -158,11 +158,9 @@ export function useLibraryData(
|
||||
try {
|
||||
const resolved = await resolveLibrary(libraryID)
|
||||
if (cancelled || seq !== requestSeqRef.current) return
|
||||
if (!lib) {
|
||||
lib = resolved
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
}
|
||||
lib = resolved
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
} catch {
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
setLoading(false)
|
||||
|
||||
@@ -1,89 +1,35 @@
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { createLibraryCache } from './libraryCacheState'
|
||||
|
||||
// 会话级媒体库元数据缓存(不落 localStorage)。
|
||||
// 首页 / 媒体库页 / 媒体库详情页都要先拿库列表或库信息才能渲染,
|
||||
// 同一会话内反复进出这些页面时直接命中缓存,避免每次都等一整轮请求。
|
||||
// TTL 内直接返回缓存;过期后重新请求并刷新。并发调用共享同一个在途请求。
|
||||
|
||||
const LIST_TTL_MS = 30_000
|
||||
const LIBRARY_TTL_MS = 30_000
|
||||
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function fresh(at: number, ttl: number): boolean {
|
||||
return Date.now() - at < ttl
|
||||
export function libraryCacheScope(): string {
|
||||
const { token, user } = useAuthStore.getState()
|
||||
const profile = usePlayProfileStore.getState()
|
||||
return JSON.stringify([
|
||||
Boolean(token),
|
||||
user?.id,
|
||||
user?.role,
|
||||
user?.hide_adult,
|
||||
user?.allowed_library_ids,
|
||||
profile.activeProfileId,
|
||||
profile.activeProfilePinToken,
|
||||
])
|
||||
}
|
||||
|
||||
function rememberList(data: Library[]) {
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
for (const lib of data) {
|
||||
byId.set(lib.id, { at: listAt, data: lib })
|
||||
}
|
||||
}
|
||||
const cache = createLibraryCache({
|
||||
list: () => libraryAPI.list(),
|
||||
get: (id) => libraryAPI.get(id),
|
||||
readScope: libraryCacheScope,
|
||||
})
|
||||
|
||||
// fetchLibraries 拉取库列表:TTL 内命中缓存立即返回(含 peek 先行渲染),
|
||||
// 过期或未缓存时发起请求,并发调用共享在途请求。
|
||||
export function fetchLibraries(): Promise<Library[]> {
|
||||
if (listData && fresh(listAt, LIST_TTL_MS)) return Promise.resolve(listData)
|
||||
if (!listInflight) {
|
||||
listInflight = libraryAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
rememberList(data)
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
listInflight = null
|
||||
})
|
||||
}
|
||||
return listInflight
|
||||
}
|
||||
// Invalidate synchronously, including logout/login between two reads and auth
|
||||
// rehydration from another tab. Refreshing a token alone preserves the cache.
|
||||
useAuthStore.subscribe(() => cache.syncScope())
|
||||
usePlayProfileStore.subscribe(() => cache.syncScope())
|
||||
|
||||
// peekLibraries 同步返回当前缓存的库列表(可能过期),没有则 null。
|
||||
// 页面用它先行渲染上一次的数据,再由 fetchLibraries 在后台补一次刷新。
|
||||
export function peekLibraries(): Library[] | null {
|
||||
return listData
|
||||
}
|
||||
|
||||
// peekLibrary 同步返回单库缓存(库列表缓存也会回填单库索引)。
|
||||
export function peekLibrary(id: string): Library | null {
|
||||
const entry = byId.get(id)
|
||||
return entry?.data ?? null
|
||||
}
|
||||
|
||||
// resolveLibrary 拿单个库信息:命中缓存直接返回;未命中走 /libraries/:id
|
||||
// 并回填缓存。远程 Emby 挂载库的详情接口同样返回 is_remote_emby,缓存可用。
|
||||
export function resolveLibrary(id: string): Promise<Library> {
|
||||
const entry = byId.get(id)
|
||||
if (entry && fresh(entry.at, LIBRARY_TTL_MS)) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const request = libraryAPI
|
||||
.get(id)
|
||||
.then((lib) => {
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// invalidate 清空缓存。管理页对库做过增删改后调用,确保下次拉到新数据。
|
||||
export function invalidateLibraries() {
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
export const fetchLibraries = cache.fetchLibraries
|
||||
export const peekLibraries = cache.peekLibraries
|
||||
export const peekLibrary = cache.peekLibrary
|
||||
export const resolveLibrary = cache.resolveLibrary
|
||||
export const invalidateLibraries = cache.invalidate
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createLibraryCache } from './libraryCacheState.ts'
|
||||
import type { Library } from '../types/library.ts'
|
||||
|
||||
const library = (id: string) => ({ id, name: id } as Library)
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
test('list requests share a flight and populate the detail cache', async () => {
|
||||
const request = deferred<Library[]>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'account-a:profile-a',
|
||||
list: () => { calls++; return request.promise },
|
||||
get: async () => { throw new Error('detail should be cached') },
|
||||
})
|
||||
const first = cache.fetchLibraries()
|
||||
assert.equal(first, cache.fetchLibraries())
|
||||
request.resolve([library('a')])
|
||||
await first
|
||||
assert.equal((await cache.resolveLibrary('a')).id, 'a')
|
||||
assert.equal((await cache.fetchLibraries())[0].id, 'a')
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('account and profile changes discard both list and detail data', async () => {
|
||||
let scope = 'account-a:profile-a'
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => scope,
|
||||
list: async () => [library(scope)],
|
||||
get: async () => library(scope),
|
||||
})
|
||||
await cache.fetchLibraries()
|
||||
scope = 'account-b:profile-a'
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal(cache.peekLibrary('account-a:profile-a'), null)
|
||||
assert.equal((await cache.fetchLibraries())[0].id, scope)
|
||||
scope = 'account-b:profile-b'
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal((await cache.resolveLibrary('shared-id')).id, scope)
|
||||
})
|
||||
|
||||
test('invalidation rejects old list results without clearing the new flight', async () => {
|
||||
const old = deferred<Library[]>()
|
||||
const fresh = deferred<Library[]>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'a',
|
||||
list: () => ++calls === 1 ? old.promise : fresh.promise,
|
||||
get: async (id) => library(id),
|
||||
})
|
||||
const oldRequest = cache.fetchLibraries()
|
||||
const rejected = assert.rejects(oldRequest, { name: 'AbortError' })
|
||||
cache.invalidate()
|
||||
const current = cache.fetchLibraries()
|
||||
old.resolve([library('deleted')])
|
||||
await rejected
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal(cache.fetchLibraries(), current)
|
||||
fresh.resolve([library('current')])
|
||||
await current
|
||||
assert.equal(cache.peekLibraries()?.[0].id, 'current')
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('invalidation rejects old detail results without clearing the new flight', async () => {
|
||||
const old = deferred<Library>()
|
||||
const fresh = deferred<Library>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'a',
|
||||
list: async () => [],
|
||||
get: () => ++calls === 1 ? old.promise : fresh.promise,
|
||||
})
|
||||
const oldRequest = cache.resolveLibrary('same-id')
|
||||
const rejected = assert.rejects(oldRequest, { name: 'AbortError' })
|
||||
cache.invalidate()
|
||||
const current = cache.resolveLibrary('same-id')
|
||||
old.resolve(library('old'))
|
||||
await rejected
|
||||
assert.equal(cache.peekLibrary('same-id'), null)
|
||||
assert.equal(cache.resolveLibrary('same-id'), current)
|
||||
fresh.resolve(library('current'))
|
||||
await current
|
||||
assert.equal(cache.peekLibrary('same-id')?.id, 'current')
|
||||
})
|
||||
|
||||
test('logout followed by login to the same account invalidates old requests', async () => {
|
||||
let scope = 'account-a'
|
||||
const request = deferred<Library[]>()
|
||||
const cache = createLibraryCache({ readScope: () => scope, list: () => request.promise, get: async libraryID => library(libraryID) })
|
||||
const pending = cache.fetchLibraries()
|
||||
const rejected = assert.rejects(pending, { name: 'AbortError' })
|
||||
scope = 'logged-out'
|
||||
cache.syncScope()
|
||||
scope = 'account-a'
|
||||
cache.syncScope()
|
||||
request.resolve([library('old-session')])
|
||||
await rejected
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Library } from '../types/library.ts'
|
||||
|
||||
const TTL_MS = 30_000
|
||||
|
||||
// Kept independent of the API and auth stores so races can be tested with
|
||||
// deferred requests. Only the active visibility scope is retained in memory.
|
||||
export function createLibraryCache({
|
||||
list,
|
||||
get,
|
||||
readScope,
|
||||
}: {
|
||||
list: () => Promise<Library[]>
|
||||
get: (id: string) => Promise<Library>
|
||||
readScope: () => string
|
||||
}) {
|
||||
let scope = readScope()
|
||||
let generation = 0
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function invalidate() {
|
||||
generation += 1
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
|
||||
function syncScope() {
|
||||
const next = readScope()
|
||||
if (scope !== next) {
|
||||
scope = next
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
function checkGeneration(expected: number) {
|
||||
syncScope()
|
||||
if (generation !== expected) {
|
||||
const error = new Error('Library request belongs to an invalidated cache')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLibraries(): Promise<Library[]> {
|
||||
syncScope()
|
||||
if (listData && Date.now() - listAt < TTL_MS) return Promise.resolve(listData)
|
||||
if (listInflight) return listInflight
|
||||
const expected = generation
|
||||
const request = list()
|
||||
.then((rows) => {
|
||||
checkGeneration(expected)
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
byId.clear()
|
||||
for (const lib of data) byId.set(lib.id, { at: listAt, data: lib })
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
if (listInflight === request) listInflight = null
|
||||
})
|
||||
listInflight = request
|
||||
return request
|
||||
}
|
||||
|
||||
function resolveLibrary(id: string): Promise<Library> {
|
||||
syncScope()
|
||||
const entry = byId.get(id)
|
||||
if (entry && Date.now() - entry.at < TTL_MS) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const expected = generation
|
||||
const request = get(id)
|
||||
.then((lib) => {
|
||||
checkGeneration(expected)
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
if (byIdInflight.get(id) === request) byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
fetchLibraries,
|
||||
resolveLibrary,
|
||||
invalidate,
|
||||
syncScope,
|
||||
peekLibraries: () => {
|
||||
syncScope()
|
||||
return listData
|
||||
},
|
||||
peekLibrary: (id: string) => {
|
||||
syncScope()
|
||||
return byId.get(id)?.data ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user