mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-19 14:05:14 +08:00
perf(dashboard): restore media cards from snapshots (#573)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
src/components/cards/__tests__/MediaServerImageCards.spec.ts
Normal file
39
src/components/cards/__tests__/MediaServerImageCards.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
53
src/composables/__tests__/useDashboardSnapshot.spec.ts
Normal file
53
src/composables/__tests__/useDashboardSnapshot.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
71
src/composables/useDashboardSnapshot.ts
Normal file
71
src/composables/useDashboardSnapshot.ts
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
325
src/views/dashboard/__tests__/MediaServerCards.spec.ts
Normal file
325
src/views/dashboard/__tests__/MediaServerCards.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user