mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
perf(dashboard): restore media cards from snapshots (#573)
This commit is contained in:
@@ -105,6 +105,7 @@ async function goPlay() {
|
|||||||
>
|
>
|
||||||
<VImg
|
<VImg
|
||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
|
crossorigin="anonymous"
|
||||||
class="playing-card__image"
|
class="playing-card__image"
|
||||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||||
cover
|
cover
|
||||||
@@ -216,8 +217,7 @@ async function goPlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playing-card__bottom-scrim {
|
.playing-card__bottom-scrim {
|
||||||
background:
|
background: linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playing-card__percent,
|
.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)}`
|
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||||
const use_cookies = props.media?.use_cookies
|
const use_cookies = props.media?.use_cookies
|
||||||
if (use_cookies) {
|
if (use_cookies) {
|
||||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
})
|
})
|
||||||
@@ -63,43 +63,44 @@ async function goPlay(isHovering: boolean | null = false) {
|
|||||||
'ring-1': isImageLoaded,
|
'ring-1': isImageLoaded,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VImg
|
<VImg
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
:src="getImgUrl"
|
:src="getImgUrl"
|
||||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
crossorigin="anonymous"
|
||||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||||
cover
|
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||||
@load="isImageLoaded = true"
|
cover
|
||||||
@error="imageLoadError = true"
|
@load="isImageLoaded = true"
|
||||||
>
|
@error="imageLoadError = true"
|
||||||
<template #placeholder>
|
>
|
||||||
<div class="w-full h-full">
|
<template #placeholder>
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
<div class="w-full h-full">
|
||||||
</div>
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
</template>
|
</div>
|
||||||
</VImg>
|
</template>
|
||||||
<!-- 类型角标 -->
|
</VImg>
|
||||||
<VChip
|
<!-- 类型角标 -->
|
||||||
v-show="isImageLoaded"
|
<VChip
|
||||||
variant="elevated"
|
v-show="isImageLoaded"
|
||||||
size="small"
|
variant="elevated"
|
||||||
:class="getChipColor(props.media?.type || '')"
|
size="small"
|
||||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
: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>
|
{{ props.media?.type }}
|
||||||
<!-- 详情 -->
|
</VChip>
|
||||||
<VCardText
|
<!-- 详情 -->
|
||||||
v-show="hover.isHovering || imageLoadError"
|
<VCardText
|
||||||
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"
|
v-show="hover.isHovering || imageLoadError"
|
||||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
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"
|
||||||
@click.stop="goPlay(hover.isHovering)"
|
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 ...">
|
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
|
||||||
{{ props.media?.title }}
|
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||||
</h1>
|
{{ props.media?.title }}
|
||||||
</VCardText>
|
</h1>
|
||||||
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 PosterCard from '@/components/cards/PosterCard.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
||||||
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
@@ -14,8 +15,13 @@ const display = useDisplay()
|
|||||||
const LATEST_CARD_MIN_WIDTH = 144
|
const LATEST_CARD_MIN_WIDTH = 144
|
||||||
const MEDIA_GRID_HORIZONTAL_PADDING = 40
|
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[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
@@ -44,8 +50,10 @@ async function loadMediaServerSetting() {
|
|||||||
try {
|
try {
|
||||||
const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')
|
const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')
|
||||||
mediaServers.value = response.data?.value ?? []
|
mediaServers.value = response.data?.value ?? []
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(t('dashboard.errors.loadMediaServer'), error)
|
console.log(t('dashboard.errors.loadMediaServer'), error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,13 +64,15 @@ async function loadMediaServerSetting() {
|
|||||||
*/
|
*/
|
||||||
async function loadLatest(server: string, count: number) {
|
async function loadLatest(server: string, count: number) {
|
||||||
try {
|
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 ?? []
|
return response ?? []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(t('dashboard.errors.loadLatest', { server }), e)
|
console.log(t('dashboard.errors.loadLatest', { server }), e)
|
||||||
|
|
||||||
return []
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +85,7 @@ async function loadData() {
|
|||||||
|
|
||||||
const loadId = ++latestLoadId
|
const loadId = ++latestLoadId
|
||||||
|
|
||||||
await loadMediaServerSetting()
|
if (!(await loadMediaServerSetting())) return
|
||||||
if (loadId !== latestLoadId) return
|
if (loadId !== latestLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
@@ -85,24 +95,30 @@ async function loadData() {
|
|||||||
|
|
||||||
if (loadId !== latestLoadId) return
|
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) {
|
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 => {
|
watch(latestItemCount, count => {
|
||||||
if (count <= 0) return
|
if (count <= 0) return
|
||||||
|
|
||||||
loadData()
|
void loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
const previousItemCount = latestItemCount.value
|
||||||
refreshCapacity()
|
refreshCapacity()
|
||||||
loadData()
|
|
||||||
|
// 容量变化时 watcher 会加载;容量不变时仍需执行一次 SWR 刷新。
|
||||||
|
if (latestItemCount.value === previousItemCount) void loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,27 @@ import api from '@/api'
|
|||||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||||
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
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[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
|
let libraryLoadId = 0
|
||||||
|
let canRefreshOnActivated = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询媒体服务器设置。
|
* 查询媒体服务器设置。
|
||||||
@@ -21,8 +32,10 @@ async function loadMediaServerSetting() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
||||||
mediaServers.value = result.data?.value ?? []
|
mediaServers.value = result.data?.value ?? []
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,15 +48,10 @@ async function loadLibrary(server: string) {
|
|||||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', {
|
const result: MediaServerLibrary[] = await api.get('mediaserver/library', {
|
||||||
params: { server: server, hidden: true },
|
params: { server: server, hidden: true },
|
||||||
})
|
})
|
||||||
if (result && result.length > 0) {
|
return (result ?? []).map(library => ({ ...library, dashboardServer: server }))
|
||||||
// 不存在时添加
|
|
||||||
for (const item of result) {
|
|
||||||
const index = libraryList.value.findIndex(i => i.id === item.id)
|
|
||||||
if (index === -1) libraryList.value.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,19 +59,41 @@ async function loadLibrary(server: string) {
|
|||||||
* 加载已启用媒体服务器的媒体库数据。
|
* 加载已启用媒体服务器的媒体库数据。
|
||||||
*/
|
*/
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
await loadMediaServerSetting()
|
const loadId = ++libraryLoadId
|
||||||
|
if (!(await loadMediaServerSetting())) return
|
||||||
|
if (loadId !== libraryLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
for (const server of enabledServers) {
|
const serverLibraries = await Promise.all(enabledServers.map(server => loadLibrary(server.name)))
|
||||||
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(() => {
|
onMounted(() => {
|
||||||
loadData()
|
void loadData()
|
||||||
|
|
||||||
|
// KeepAlive 首次激活紧随 mounted,首轮由 mounted 唯一负责加载。
|
||||||
|
void nextTick(() => {
|
||||||
|
canRefreshOnActivated = true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
loadData()
|
if (!canRefreshOnActivated) return
|
||||||
|
|
||||||
|
void loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,7 +106,7 @@ onActivated(() => {
|
|||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
class="dashboard-media-grid"
|
class="dashboard-media-grid"
|
||||||
:items="libraryList"
|
: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"
|
:min-item-width="240"
|
||||||
:estimated-item-height="160"
|
:estimated-item-height="160"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
|||||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
||||||
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
@@ -14,8 +15,16 @@ const display = useDisplay()
|
|||||||
const PLAYING_CARD_MIN_WIDTH = 240
|
const PLAYING_CARD_MIN_WIDTH = 240
|
||||||
const MEDIA_GRID_HORIZONTAL_PADDING = 40
|
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[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
@@ -46,8 +55,10 @@ async function loadMediaServerSetting() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
||||||
mediaServers.value = result.data?.value ?? []
|
mediaServers.value = result.data?.value ?? []
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +69,15 @@ async function loadMediaServerSetting() {
|
|||||||
*/
|
*/
|
||||||
async function loadPlayingList(server: string, count: number) {
|
async function loadPlayingList(server: string, count: number) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
|
||||||
return []
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,35 +90,43 @@ async function loadData() {
|
|||||||
|
|
||||||
const loadId = ++playingLoadId
|
const loadId = ++playingLoadId
|
||||||
|
|
||||||
await loadMediaServerSetting()
|
if (!(await loadMediaServerSetting())) return
|
||||||
if (loadId !== playingLoadId) return
|
if (loadId !== playingLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
|
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
|
||||||
|
|
||||||
if (loadId !== playingLoadId) return
|
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) => {
|
serverItems
|
||||||
const key = String(item.id || item.link || `${item.server_type || 'server'}-${item.title}-${index}`)
|
.flatMap(items => items ?? [])
|
||||||
if (!itemMap.has(key)) {
|
.forEach((item, index) => {
|
||||||
itemMap.set(key, item)
|
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 => {
|
watch(playingItemCount, count => {
|
||||||
if (count <= 0) return
|
if (count <= 0) return
|
||||||
|
|
||||||
loadData()
|
void loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
const previousItemCount = playingItemCount.value
|
||||||
refreshCapacity()
|
refreshCapacity()
|
||||||
loadData()
|
|
||||||
|
// 容量变化时 watcher 会加载;容量不变时仍需执行一次 SWR 刷新。
|
||||||
|
if (playingItemCount.value === previousItemCount) void loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,7 +145,7 @@ onActivated(() => {
|
|||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
class="dashboard-media-grid"
|
class="dashboard-media-grid"
|
||||||
:items="displayedPlayingList"
|
: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"
|
:min-item-width="PLAYING_CARD_MIN_WIDTH"
|
||||||
:estimated-item-height="174"
|
:estimated-item-height="174"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|||||||
@@ -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