perf(dashboard): restore media cards from snapshots (#573)

This commit is contained in:
InfinityPacer
2026-07-22 17:55:54 +08:00
committed by GitHub
parent 4405a69d59
commit 103472404f
9 changed files with 637 additions and 81 deletions

View File

@@ -105,6 +105,7 @@ async function goPlay() {
>
<VImg
:src="imageUrl"
crossorigin="anonymous"
class="playing-card__image"
:class="{ 'playing-card__image--loaded': imageLoaded }"
cover
@@ -216,8 +217,7 @@ async function goPlay() {
}
.playing-card__bottom-scrim {
background:
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
background: linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
}
.playing-card__percent,

View File

@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return url
})
@@ -63,43 +63,44 @@ async function goPlay(isHovering: boolean | null = false) {
'ring-1': isImageLoaded,
}"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
:class="{ 'poster-card-image--loaded': isImageLoaded }"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
@click.stop="goPlay(hover.isHovering)"
>
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
crossorigin="anonymous"
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
:class="{ 'poster-card-image--loaded': isImageLoaded }"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
@click.stop="goPlay(hover.isHovering)"
>
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,39 @@
import type { MediaServerPlayItem } from '@/api/types'
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
import PosterCard from '@/components/cards/PosterCard.vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/appDeepLink', () => ({
openMediaServerItem: vi.fn(),
}))
const media: MediaServerPlayItem = {
id: 'media-1',
image: 'https://media.example.com/poster.jpg',
title: 'Test media',
}
const VImgStub = defineComponent({
inheritAttrs: false,
props: {
crossorigin: String,
src: String,
},
setup: props => () => h('img', { crossorigin: props.crossorigin, src: props.src }),
})
describe.each([
['PosterCard', PosterCard],
['PlayingBackdropCard', PlayingBackdropCard],
])('%s image request mode', (_name, component) => {
it('loads the media server image anonymously', async () => {
const { container } = await renderWithProviders(component, {
global: { stubs: { VImg: VImgStub } },
props: { media },
})
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
})
})

View File

@@ -0,0 +1,53 @@
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { useUserStore } from '@/stores'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('dashboard snapshot', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T00:00:00Z'))
localStorage.clear()
setActivePinia(createPinia())
useUserStore().userID = 7
})
afterEach(() => {
vi.useRealTimers()
})
it('expires a snapshot after 24 hours', () => {
const snapshot = useDashboardSnapshot<string>('test-card-v1')
snapshot.writeSnapshot('value')
vi.advanceTimersByTime(24 * 60 * 60 * 1000)
expect(snapshot.readSnapshot()?.value).toBe('value')
vi.advanceTimersByTime(1)
expect(snapshot.readSnapshot()).toBeUndefined()
})
it('isolates snapshots by login user', () => {
const user7Snapshot = useDashboardSnapshot<string>('test-card-v1')
user7Snapshot.writeSnapshot('user-7')
useUserStore().userID = 8
const user8Snapshot = useDashboardSnapshot<string>('test-card-v1')
expect(user8Snapshot.readSnapshot()).toBeUndefined()
user8Snapshot.writeSnapshot('user-8')
expect(user7Snapshot.readSnapshot()?.value).toBe('user-7')
expect(user8Snapshot.readSnapshot()?.value).toBe('user-8')
})
it('keeps a late write bound to the user that created the snapshot instance', () => {
const user7Snapshot = useDashboardSnapshot<string>('test-card-v1')
useUserStore().userID = 8
const user8Snapshot = useDashboardSnapshot<string>('test-card-v1')
user7Snapshot.writeSnapshot('late-user-7')
expect(user8Snapshot.readSnapshot()).toBeUndefined()
expect(user7Snapshot.readSnapshot()?.value).toBe('late-user-7')
})
})

View File

@@ -0,0 +1,71 @@
import { useUserStore } from '@/stores'
export interface DashboardSnapshot<T> {
/** 最后一次成功请求的完成时间。 */
savedAt: number
/** 卡片可直接恢复的完整成功结果。 */
value: T
}
const DASHBOARD_SNAPSHOT_PREFIX = 'MP_DASHBOARD_SNAPSHOT_V1'
const DASHBOARD_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000
/**
* 按登录用户保存仪表盘卡片的最后一次成功快照。
*
* @param key 卡片稳定标识,数据结构变更时应同步换版
*/
export function useDashboardSnapshot<T>(key: string) {
const userStore = useUserStore()
// 组件实例绑定创建时的登录用户,迟到响应不得写入之后登录的其他用户快照。
const snapshotUserID = userStore.userID
function getStorageKey() {
if (snapshotUserID < 0) return undefined
return `${DASHBOARD_SNAPSHOT_PREFIX}:${snapshotUserID}:${key}`
}
function clearSnapshot() {
const storageKey = getStorageKey()
if (storageKey) localStorage.removeItem(storageKey)
}
function readSnapshot(): DashboardSnapshot<T> | undefined {
const storageKey = getStorageKey()
if (!storageKey) return undefined
const rawSnapshot = localStorage.getItem(storageKey)
if (!rawSnapshot) return undefined
try {
const snapshot = JSON.parse(rawSnapshot) as Partial<DashboardSnapshot<T>>
const age = Date.now() - Number(snapshot.savedAt)
if (!Number.isFinite(age) || age < 0 || age > DASHBOARD_SNAPSHOT_TTL_MS || !('value' in snapshot)) {
clearSnapshot()
return undefined
}
return snapshot as DashboardSnapshot<T>
} catch {
clearSnapshot()
return undefined
}
}
function writeSnapshot(value: T) {
const snapshot: DashboardSnapshot<T> = { savedAt: Date.now(), value }
const storageKey = getStorageKey()
if (!storageKey) return snapshot
try {
localStorage.setItem(storageKey, JSON.stringify(snapshot))
} catch {
// 快照是可选的首屏优化,存储不可用时仍以实时请求为准。
}
return snapshot
}
return { clearSnapshot, readSnapshot, writeSnapshot }
}

View File

@@ -4,6 +4,7 @@ import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -14,8 +15,13 @@ const display = useDisplay()
const LATEST_CARD_MIN_WIDTH = 144
const MEDIA_GRID_HORIZONTAL_PADDING = 40
const { readSnapshot, writeSnapshot } = useDashboardSnapshot<{
[key: string]: MediaServerPlayItem[]
}>('media-latest-v1')
const currentSnapshot = readSnapshot()
// 最近入库列表
const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})
const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>(currentSnapshot?.value ?? {})
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
@@ -44,8 +50,10 @@ async function loadMediaServerSetting() {
try {
const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')
mediaServers.value = response.data?.value ?? []
return true
} catch (error) {
console.log(t('dashboard.errors.loadMediaServer'), error)
return false
}
}
@@ -56,13 +64,15 @@ async function loadMediaServerSetting() {
*/
async function loadLatest(server: string, count: number) {
try {
const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { count, server } })
const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', {
params: { count, server },
})
return response ?? []
} catch (e) {
console.log(t('dashboard.errors.loadLatest', { server }), e)
return []
return undefined
}
}
@@ -75,7 +85,7 @@ async function loadData() {
const loadId = ++latestLoadId
await loadMediaServerSetting()
if (!(await loadMediaServerSetting())) return
if (loadId !== latestLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
@@ -85,24 +95,30 @@ async function loadData() {
if (loadId !== latestLoadId) return
latestList.value = entries.reduce<{ [key: string]: MediaServerPlayItem[] }>((result, [name, data]) => {
const nextLatestList: { [key: string]: MediaServerPlayItem[] } = {}
for (const [name, data] of entries) {
if (data === undefined) return
if (data.length > 0) {
result[name] = data.slice(0, count)
nextLatestList[name] = data.slice(0, count)
}
}
return result
}, {})
latestList.value = nextLatestList
writeSnapshot(nextLatestList)
}
watch(latestItemCount, count => {
if (count <= 0) return
loadData()
void loadData()
})
onActivated(() => {
const previousItemCount = latestItemCount.value
refreshCapacity()
loadData()
// 容量变化时 watcher 会加载;容量不变时仍需执行一次 SWR 刷新。
if (latestItemCount.value === previousItemCount) void loadData()
})
</script>

View File

@@ -3,16 +3,27 @@ import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
interface DashboardMediaServerLibrary extends MediaServerLibrary {
/** 媒体库所属的配置服务器名称,用于跨服务器稳定去重。 */
dashboardServer: string
}
const { readSnapshot, writeSnapshot } = useDashboardSnapshot<DashboardMediaServerLibrary[]>('media-library-v1')
const currentSnapshot = readSnapshot()
// 媒体库列表
const libraryList = ref<MediaServerLibrary[]>([])
const libraryList = ref<DashboardMediaServerLibrary[]>(currentSnapshot?.value ?? [])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
let libraryLoadId = 0
let canRefreshOnActivated = false
/**
* 查询媒体服务器设置。
@@ -21,8 +32,10 @@ async function loadMediaServerSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
mediaServers.value = result.data?.value ?? []
return true
} catch (error) {
console.log(error)
return false
}
}
@@ -35,15 +48,10 @@ async function loadLibrary(server: string) {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', {
params: { server: server, hidden: true },
})
if (result && result.length > 0) {
// 不存在时添加
for (const item of result) {
const index = libraryList.value.findIndex(i => i.id === item.id)
if (index === -1) libraryList.value.push(item)
}
}
return (result ?? []).map(library => ({ ...library, dashboardServer: server }))
} catch (e) {
console.log(e)
return undefined
}
}
@@ -51,19 +59,41 @@ async function loadLibrary(server: string) {
* 加载已启用媒体服务器的媒体库数据。
*/
async function loadData() {
await loadMediaServerSetting()
const loadId = ++libraryLoadId
if (!(await loadMediaServerSetting())) return
if (loadId !== libraryLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadLibrary(server.name)
}
const serverLibraries = await Promise.all(enabledServers.map(server => loadLibrary(server.name)))
if (loadId !== libraryLoadId || serverLibraries.some(libraries => libraries === undefined)) return
const libraryMap = new Map<string, DashboardMediaServerLibrary>()
serverLibraries
.flatMap(libraries => libraries ?? [])
.forEach(library => {
const key = `${library.dashboardServer}:${library.id ?? library.link ?? library.name}`
if (!libraryMap.has(key)) libraryMap.set(key, library)
})
const nextLibraryList = Array.from(libraryMap.values())
libraryList.value = nextLibraryList
writeSnapshot(nextLibraryList)
}
onMounted(() => {
loadData()
void loadData()
// KeepAlive 首次激活紧随 mounted首轮由 mounted 唯一负责加载。
void nextTick(() => {
canRefreshOnActivated = true
})
})
onActivated(() => {
loadData()
if (!canRefreshOnActivated) return
void loadData()
})
</script>
@@ -76,7 +106,7 @@ onActivated(() => {
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="libraryList"
:get-item-key="item => item.id || item.name"
:get-item-key="item => `${item.dashboardServer}:${item.id ?? item.link ?? item.name}`"
:min-item-width="240"
:estimated-item-height="160"
tabindex="0"

View File

@@ -4,6 +4,7 @@ import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -14,8 +15,16 @@ const display = useDisplay()
const PLAYING_CARD_MIN_WIDTH = 240
const MEDIA_GRID_HORIZONTAL_PADDING = 40
interface DashboardPlayingItem extends MediaServerPlayItem {
/** 媒体项所属的配置服务器名称,用于跨服务器稳定去重。 */
dashboardServer: string
}
const { readSnapshot, writeSnapshot } = useDashboardSnapshot<DashboardPlayingItem[]>('media-playing-v1')
const currentSnapshot = readSnapshot()
// 继续播放列表
const playingList = ref<MediaServerPlayItem[]>([])
const playingList = ref<DashboardPlayingItem[]>(currentSnapshot?.value ?? [])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
@@ -46,8 +55,10 @@ async function loadMediaServerSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
mediaServers.value = result.data?.value ?? []
return true
} catch (error) {
console.log(error)
return false
}
}
@@ -58,13 +69,15 @@ async function loadMediaServerSetting() {
*/
async function loadPlayingList(server: string, count: number) {
try {
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { count, server } })
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', {
params: { count, server },
})
return result ?? []
return (result ?? []).map(item => ({ ...item, dashboardServer: server }))
} catch (e) {
console.log(e)
return []
return undefined
}
}
@@ -77,35 +90,43 @@ async function loadData() {
const loadId = ++playingLoadId
await loadMediaServerSetting()
if (!(await loadMediaServerSetting())) return
if (loadId !== playingLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
if (loadId !== playingLoadId) return
if (serverItems.some(items => items === undefined)) return
const itemMap = new Map<string, MediaServerPlayItem>()
const itemMap = new Map<string, DashboardPlayingItem>()
serverItems.flat().forEach((item, index) => {
const key = String(item.id || item.link || `${item.server_type || 'server'}-${item.title}-${index}`)
if (!itemMap.has(key)) {
itemMap.set(key, item)
}
})
serverItems
.flatMap(items => items ?? [])
.forEach((item, index) => {
const key = `${item.dashboardServer}:${item.id || item.link || item.title || index}`
if (!itemMap.has(key)) {
itemMap.set(key, item)
}
})
playingList.value = Array.from(itemMap.values()).slice(0, count)
const nextPlayingList = Array.from(itemMap.values()).slice(0, count)
playingList.value = nextPlayingList
writeSnapshot(nextPlayingList)
}
watch(playingItemCount, count => {
if (count <= 0) return
loadData()
void loadData()
})
onActivated(() => {
const previousItemCount = playingItemCount.value
refreshCapacity()
loadData()
// 容量变化时 watcher 会加载;容量不变时仍需执行一次 SWR 刷新。
if (playingItemCount.value === previousItemCount) void loadData()
})
</script>
@@ -124,7 +145,7 @@ onActivated(() => {
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="displayedPlayingList"
:get-item-key="item => item.id || item.link || item.title"
:get-item-key="item => `${item.dashboardServer}:${item.id || item.link || item.title}`"
:min-item-width="PLAYING_CARD_MIN_WIDTH"
:estimated-item-height="174"
tabindex="0"

View File

@@ -0,0 +1,325 @@
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { defineComponent, ref, type Component } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
mediaGridItemCount: undefined as unknown as { value: number },
refreshCapacity: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
vi.mock('@/composables/useDashboardMediaGridCapacity', async () => {
const { ref } = await import('vue')
const itemCount = ref(4)
mocks.mediaGridItemCount = itemCount
return {
useDashboardMediaGridCapacity: () => ({
containerRef: ref(null),
itemCount,
refreshCapacity: (...args: unknown[]) => mocks.refreshCapacity(...args),
}),
}
})
vi.mock('@/components/misc/ProgressiveCardGrid.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
props: { items: { type: Array, default: () => [] } },
template: '<div><slot v-for="item in items" :key="item.id || item.name" :item="item" /></div>',
}),
}
})
vi.mock('@/components/cards/PlayingBackdropCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
props: { media: { type: Object, required: true } },
template: '<span>{{ media.title }}</span>',
}),
}
})
vi.mock('@/components/cards/PosterCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
props: { media: { type: Object, required: true } },
template: '<span>{{ media.title }}</span>',
}),
}
})
vi.mock('@/components/cards/LibraryCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
props: { media: { type: Object, required: true } },
template: '<span>{{ media.name }}</span>',
}),
}
})
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
function keepAliveHarness(component: Component) {
return defineComponent({
components: { TestedCard: component },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用卡片</button>
<button type="button" @click="active = true">启用卡片</button>
<KeepAlive><TestedCard v-if="active" /></KeepAlive>
`,
})
}
async function reactivateCard() {
await fireEvent.click(screen.getByRole('button', { name: '停用卡片' }))
await fireEvent.click(screen.getByRole('button', { name: '启用卡片' }))
}
describe('dashboard media server cards', () => {
beforeEach(() => {
localStorage.clear()
mocks.apiGet.mockReset()
mocks.mediaGridItemCount.value = 4
mocks.refreshCapacity.mockReset()
})
it('loads media libraries on an ordinary initial mount', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === 'mediaserver/library') return [{ id: 'movies', name: '电影库' }]
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(MediaServerLibrary)
expect(await screen.findByText('电影库')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/library', {
params: { hidden: true, server: 'home' },
})
})
it('restores the last successful library snapshot before F5 revalidation completes', async () => {
const pendingSettings = deferred<{ data: { value: Array<{ enabled: boolean; name: string }> } }>()
let reload = false
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') {
return reload ? pendingSettings.promise : { data: { value: [{ enabled: true, name: 'home' }] } }
}
if (url === 'mediaserver/library') return [{ id: 'movies', name: '已缓存媒体库' }]
throw new Error(`Unexpected GET ${url}`)
})
const renderOptions = { initialState: { user: { userID: 7 } } }
const firstRender = await renderWithProviders(MediaServerLibrary, renderOptions)
await screen.findByText('已缓存媒体库')
firstRender.unmount()
reload = true
await renderWithProviders(MediaServerLibrary, renderOptions)
expect(screen.getByText('已缓存媒体库')).toBeInTheDocument()
})
it('does not duplicate the settings request when reactivation changes media capacity', async () => {
let settingReads = 0
let playingReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') {
settingReads += 1
return { data: { value: [{ enabled: true, name: 'home' }] } }
}
if (url === 'mediaserver/playing') {
playingReads += 1
return [{ id: `playing-${playingReads}`, title: `继续观看 ${playingReads}` }]
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerPlaying))
await screen.findByText('继续观看 1')
mocks.mediaGridItemCount.value = 0
mocks.refreshCapacity.mockImplementationOnce(() => {
mocks.mediaGridItemCount.value = 6
})
await reactivateCard()
await screen.findByText('继续观看 2')
expect(settingReads).toBe(2)
expect(playingReads).toBe(2)
})
it('keeps continue-watching content when a warm refresh fails', async () => {
const refresh = deferred<Array<{ id: string; title: string }>>()
let playingReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === 'mediaserver/playing') {
playingReads += 1
return playingReads === 1 ? [{ id: 'old', title: '旧继续观看' }] : refresh.promise
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerPlaying))
await screen.findByText('旧继续观看')
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/playing', {
params: { count: 4, server: 'home' },
})
await reactivateCard()
expect(screen.getByText('旧继续观看')).toBeInTheDocument()
refresh.reject(new Error('remote unavailable'))
await waitFor(() => expect(playingReads).toBe(2))
await new Promise(resolve => setTimeout(resolve, 0))
expect(screen.getByText('旧继续观看')).toBeInTheDocument()
})
it('keeps recent-library content when a warm refresh fails', async () => {
const refresh = deferred<Array<{ id: string; title: string }>>()
let latestReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === 'mediaserver/latest') {
latestReads += 1
return latestReads === 1 ? [{ id: 'old', title: '旧最近入库' }] : refresh.promise
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerLatest))
await screen.findByText('旧最近入库')
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/latest', {
params: { count: 4, server: 'home' },
})
await reactivateCard()
refresh.reject(new Error('remote unavailable'))
await waitFor(() => expect(latestReads).toBe(2))
await new Promise(resolve => setTimeout(resolve, 0))
expect(screen.getByText('旧最近入库')).toBeInTheDocument()
})
it('replaces the media-library snapshot atomically after a warm refresh', async () => {
const refresh = deferred<Array<{ id: string; name: string }>>()
let libraryReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === 'mediaserver/library') {
libraryReads += 1
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerLibrary))
await screen.findByText('旧媒体库')
expect(libraryReads).toBe(1)
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/library', {
params: { hidden: true, server: 'home' },
})
await reactivateCard()
expect(screen.getByText('旧媒体库')).toBeInTheDocument()
await waitFor(() => expect(libraryReads).toBe(2))
refresh.resolve([{ id: 'new', name: '新媒体库' }])
await screen.findByText('新媒体库')
expect(screen.queryByText('旧媒体库')).not.toBeInTheDocument()
})
it('keeps same-id libraries from different media servers', async () => {
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
if (url === 'system/setting/MediaServers') {
return {
data: {
value: [
{ enabled: true, name: 'home-a' },
{ enabled: true, name: 'home-b' },
],
},
}
}
if (url === 'mediaserver/library') {
const server = options?.params?.server
return [
{
id: 'movies',
name: server === 'home-a' ? '媒体库 A' : '媒体库 B',
server: 'emby',
},
]
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerLibrary))
expect(await screen.findByText('媒体库 A')).toBeInTheDocument()
expect(screen.getByText('媒体库 B')).toBeInTheDocument()
})
it('keeps same-id continue-watching items from different media servers', async () => {
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
if (url === 'system/setting/MediaServers') {
return {
data: {
value: [
{ enabled: true, name: 'home-a' },
{ enabled: true, name: 'home-b' },
],
},
}
}
if (url === 'mediaserver/playing') {
const server = options?.params?.server
return [
{
id: 'shared-item',
title: server === 'home-a' ? '继续观看 A' : '继续观看 B',
},
]
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerPlaying))
expect(await screen.findByText('继续观看 A')).toBeInTheDocument()
expect(screen.getByText('继续观看 B')).toBeInTheDocument()
})
})