feat: 优化音乐卡片与搜索站点筛选

This commit is contained in:
jxxghp
2026-08-09 18:12:29 +08:00
parent d1d46353dd
commit 6db19ee6f7
16 changed files with 602 additions and 158 deletions
+3 -2
View File
@@ -122,10 +122,11 @@ function openSearchSiteDialog() {
) )
} }
// 查询所有站点 // 查询与当前媒体类型兼容的站点
async function querySites() { async function querySites() {
try { try {
const data: Site[] = await api.get('site/') const mediaType = props.media?.type === '电视剧' ? 'tv' : props.media?.type === '音乐' ? 'music' : 'movie'
const data: Site[] = await api.get(`site/media/${mediaType}`)
// 过滤站点,只有启用的站点才显示 // 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active) allSites.value = data.filter(item => item.is_active)
+260 -46
View File
@@ -4,6 +4,7 @@ import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache' import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { import {
buildMusicAlbumRoute, buildMusicAlbumRoute,
buildMusicArtistRoute, buildMusicArtistRoute,
@@ -34,23 +35,44 @@ const isSubscribed = ref(false)
// 可点击跳转的艺术家 // 可点击跳转的艺术家
const artistLinks = computed(() => getMusicArtistLinks(props.music)) const artistLinks = computed(() => getMusicArtistLinks(props.music))
// 卡片上展示的元数据标签,只保留 MusicBrainz 实际返回的字段 // 音乐实体标签和图标
const metaChips = computed(() => { const entityMeta = computed(() => {
const chips: string[] = [] const entities = {
album: { icon: 'mdi-album', label: t('music.entityAlbum') },
artist: { icon: 'mdi-account-music', label: t('music.entityArtist') },
recording: { icon: 'mdi-music-note', label: t('music.entityRecording') },
}
return entities[props.music?.music_type || 'recording']
})
// 卡片上展示的元数据,只保留 MusicBrainz 实际返回的字段
const metaItems = computed(() => {
const items: { hideOnNarrow?: boolean; icon: string; label: string }[] = []
const category = props.music?.category || props.music?.album_type const category = props.music?.category || props.music?.album_type
if (category) chips.push(category) if (category) items.push({ hideOnNarrow: true, icon: 'mdi-label-outline', label: category })
const releaseDate = props.music?.release_date || props.music?.year?.toString() const releaseDate = props.music?.release_date || props.music?.year?.toString()
if (releaseDate) chips.push(releaseDate) if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
const duration = formatMusicDuration(props.music?.duration) const duration = formatMusicDuration(props.music?.duration)
if (duration) chips.push(duration) if (duration) items.push({ icon: 'mdi-clock-outline', label: duration })
if (props.music?.track_number) chips.push(t('music.trackNumber', { number: props.music.track_number })) if (props.music?.track_number)
items.push({
hideOnNarrow: true,
icon: 'mdi-counter',
label: t('music.trackNumber', { number: props.music.track_number }),
})
if (props.music?.listen_count) if (props.music?.listen_count)
chips.push(t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() })) items.push({
return chips hideOnNarrow: true,
icon: 'mdi-chart-line',
label: t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }),
})
return items
}) })
const coverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '') const coverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
/** 生成订阅状态缓存键。 */
function getSubscribeStatusKey() { function getSubscribeStatusKey() {
return `${getMediaSubscribeId(props.music)}::all` return `${getMediaSubscribeId(props.music)}::all`
} }
@@ -62,6 +84,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey, getSubscribeStatusKey,
}) })
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
props.music ? buildMusicResourceRoute(props.music, sites) : undefined,
)
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */ /** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() { async function checkSubscribeStatus() {
if (!canSubscribe.value || !props.music?.media_id) return if (!canSubscribe.value || !props.music?.media_id) return
@@ -92,12 +118,12 @@ function goArtist(artistId?: string, name?: string) {
router.push(buildMusicArtistRoute(artistId, name, props.music?.source)) router.push(buildMusicArtistRoute(artistId, name, props.music?.source))
} }
/** 使用音乐元数据身份进入站点资源精确搜索页。 */ watch(
function goResource() { () => coverUrl.value,
if (!props.music) return () => {
const target = buildMusicResourceRoute(props.music) imageLoadError.value = false
if (target) router.push(target) },
} )
watch(() => props.music?.media_id, checkSubscribeStatus) watch(() => props.music?.media_id, checkSubscribeStatus)
@@ -105,31 +131,39 @@ onMounted(checkSubscribeStatus)
</script> </script>
<template> <template>
<VCard class="music-card h-100 cursor-pointer" @click="goDetail"> <VHover>
<div class="d-flex pa-4 ga-4"> <template #default="hover">
<VImg <div v-bind="hover.props" class="music-card-hover-area h-100">
v-if="coverUrl && !imageLoadError" <VCard
:src="coverUrl" class="music-card app-hover-lift-card h-100 cursor-pointer"
width="104" :class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
height="104" @click="goDetail"
cover >
rounded="lg" <div class="music-card-content">
class="flex-grow-0 music-card-cover" <div class="music-card-cover-shell">
@error="imageLoadError = true" <VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
/> <template #placeholder>
<VSheet v-else width="104" height="104" rounded="lg" class="d-flex align-center justify-center flex-grow-0"> <VSkeletonLoader class="h-100" />
<VIcon icon="mdi-album" size="48" color="medium-emphasis" /> </template>
</VSheet> </VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<div class="music-card-body flex-grow-1"> <VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
<div class="d-flex align-start ga-2"> {{ entityMeta.label }}
<div class="music-card-title text-h6">{{ props.music?.title }}</div> </VChip>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="flex-grow-0 mt-1"> </div>
<div class="music-card-body">
<div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="music-card-version">
{{ props.music.version }} {{ props.music.version }}
</VChip> </VChip>
</div> </div>
<div class="text-body-2 text-medium-emphasis music-card-artists"> <div class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-account-music" size="16" />
<div class="music-card-artists">
<template v-if="artistLinks.length"> <template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`"> <template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span> <span v-if="index > 0"> / </span>
@@ -147,9 +181,12 @@ onMounted(checkSubscribeStatus)
</template> </template>
<span v-else>{{ t('common.unknown') }}</span> <span v-else>{{ t('common.unknown') }}</span>
</div> </div>
</div>
<div v-if="props.music?.album" class="text-caption text-medium-emphasis mt-1 music-card-album"> <div v-if="props.music?.album" class="music-card-supporting text-medium-emphasis">
{{ t('music.album') }} <VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
<a <a
v-if="props.music.album_id" v-if="props.music.album_id"
class="music-card-link" class="music-card-link"
@@ -161,43 +198,141 @@ onMounted(checkSubscribeStatus)
> >
<span v-else>{{ props.music.album }}</span> <span v-else>{{ props.music.album }}</span>
</div> </div>
<div class="d-flex flex-wrap ga-2 mt-3">
<VChip v-for="chip in metaChips" :key="chip" size="small" variant="tonal">{{ chip }}</VChip>
</div>
</div> </div>
<div class="music-card-actions d-flex flex-column align-center ga-1"> <div class="music-card-footer">
<div class="music-card-meta">
<VChip
v-for="item in metaItems"
:key="`${item.icon}-${item.label}`"
:prepend-icon="item.icon"
:class="{ 'music-card-meta-item--narrow-optional': item.hideOnNarrow }"
size="x-small"
variant="tonal"
>
{{ item.label }}
</VChip>
</div>
<div class="music-card-actions">
<IconBtn <IconBtn
v-if="canSubscribe" v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'" :icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'" :color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')" :aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
:title="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
size="small"
variant="tonal"
@click.stop="subscribeActions.handleSubscribe()" @click.stop="subscribeActions.handleSubscribe()"
/> />
<IconBtn <IconBtn
v-if="canSearch" v-if="canSearch"
icon="mdi-magnify" icon="mdi-magnify"
color="medium-emphasis" color="primary"
:aria-label="t('music.searchResources')" :aria-label="t('music.searchResources')"
@click.stop="goResource" :title="t('music.searchResources')"
size="small"
variant="tonal"
@click.stop="openMusicSiteSearch"
/> />
</div> </div>
</div> </div>
</div>
</div>
</VCard> </VCard>
</div>
</template>
</VHover>
</template> </template>
<style scoped> <style scoped>
.music-card-hover-area {
inline-size: 100%;
}
.music-card {
min-block-size: 144px;
overflow: hidden;
}
.music-card-content {
display: grid;
block-size: 100%;
gap: 1rem;
grid-template-columns: 112px minmax(0, 1fr);
padding: 1rem;
}
.music-card-cover-shell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
align-self: start;
justify-content: center;
aspect-ratio: 1;
background: rgba(var(--v-theme-on-surface), 0.06);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
inline-size: 112px;
}
.music-card-cover {
block-size: 100%;
inline-size: 100%;
transition: transform 0.3s ease;
}
.music-card.app-hover-lift-card--hovering .music-card-cover {
transform: scale(1.035);
}
.music-card-entity {
position: absolute;
z-index: 1;
background: rgba(var(--v-theme-surface), 0.88) !important;
color: rgb(var(--v-theme-on-surface)) !important;
inset-block-end: 0.5rem;
inset-inline-start: 0.5rem;
max-inline-size: calc(100% - 1rem);
}
.music-card-body { .music-card-body {
display: flex;
flex-direction: column;
min-inline-size: 0; min-inline-size: 0;
} }
.music-card-heading {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.music-card-title { .music-card-title {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
color: rgb(var(--v-theme-on-surface));
font-size: 1rem;
font-weight: 600;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
letter-spacing: 0;
line-height: 1.4;
}
.music-card-version {
flex: 0 0 auto;
margin-block-start: 0.125rem;
}
.music-card-supporting {
display: flex;
align-items: center;
font-size: 0.8125rem;
gap: 0.375rem;
line-height: 1.35; line-height: 1.35;
margin-block-start: 0.35rem;
} }
.music-card-artists, .music-card-artists,
@@ -208,16 +343,95 @@ onMounted(checkSubscribeStatus)
} }
.music-card-link { .music-card-link {
color: rgb(var(--v-theme-primary)); color: inherit;
cursor: pointer; cursor: pointer;
font-weight: 500;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease;
} }
.music-card-link:hover { .music-card-link:hover {
text-decoration: underline; color: rgb(var(--v-theme-primary));
}
.music-card-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
margin-block-start: auto;
padding-block-start: 0.65rem;
}
.music-card-meta,
.music-card-actions {
display: flex;
align-items: center;
}
.music-card-meta {
flex: 1 1 auto;
flex-wrap: wrap;
gap: 0.3rem;
min-inline-size: 0;
} }
.music-card-actions { .music-card-actions {
flex: 0 0 auto; flex: 0 0 auto;
gap: 0.25rem;
}
@media (width <= 600px) {
.music-card {
min-block-size: 120px;
}
.music-card-content {
gap: 0.75rem;
grid-template-columns: 88px minmax(0, 1fr);
padding: 0.75rem;
}
.music-card-cover-shell {
inline-size: 88px;
}
.music-card-body {
position: relative;
}
.music-card-heading {
align-items: center;
min-block-size: 40px;
padding-inline-end: 5.5rem;
}
.music-card-entity {
inset-block-end: 0.375rem;
inset-inline-start: 0.375rem;
max-inline-size: calc(100% - 0.75rem);
}
.music-card-footer {
display: block;
padding-block-start: 0.5rem;
}
.music-card-actions {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
.music-card-actions :deep(.v-btn) {
block-size: 40px;
inline-size: 40px;
}
}
@media (width <= 360px) {
.music-card-meta-item--narrow-optional {
display: none;
}
} }
</style> </style>
@@ -27,7 +27,8 @@ vi.mock('@/router', () => ({
})) }))
const API_BASE_URL = 'http://localhost/api/v1/' const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = [] let intersectionObservers: IntersectionObserverMock[] = []
@@ -142,9 +143,13 @@ function getStatusObservers() {
} }
/** 安装站点列表及已选站点的搜索请求处理器。 */ /** 安装站点列表及已选站点的搜索请求处理器。 */
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) { function installSearchHandlers(
sites: Record<string, unknown>[],
selected: number[],
mediaType: 'movie' | 'tv' = 'movie',
) {
server.use( server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)), http.get(mediaType === 'tv' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })), http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
) )
} }
@@ -359,7 +364,7 @@ describe('MediaCard', () => {
}) })
it('routes directly to resource search when no active sites are available', async () => { it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5]) installSearchHandlers([], [3, 5], 'tv')
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' }) const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
const { container } = await renderCard(media) const { container } = await renderCard(media)
@@ -385,7 +390,7 @@ describe('MediaCard', () => {
it('falls back to global search when site settings cannot provide active selections', async () => { it('falls back to global search when site settings cannot provide active selections', async () => {
server.use( server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })), http.get(movieSiteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })), http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
) )
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 }) const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
@@ -444,7 +449,7 @@ describe('MediaCard', () => {
it('opens active sites with an empty selection when the saved setting fails', async () => { it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use( server.use(
http.get(siteListUrl, () => http.get(movieSiteListUrl, () =>
HttpResponse.json([ HttpResponse.json([
{ {
domain: 'fallback.example', domain: 'fallback.example',
+83
View File
@@ -0,0 +1,83 @@
import api from '@/api'
import type { Site } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import type { RouteLocationRaw } from 'vue-router'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
type MusicResourceRouteBuilder = (sites: number[]) => RouteLocationRaw | undefined
/**
* 统一处理音乐资源搜索前的站点加载、选择与跳转。
*
* @param buildRoute 根据已选站点构造资源搜索路由
*/
export function useMusicSiteSearch(buildRoute: MusicResourceRouteBuilder) {
const router = useRouter()
const musicSites = ref<Site[]>([])
const selectedSites = ref<number[]>([])
let dialogController: ReturnType<typeof openSharedDialog> | undefined
/** 查询已配置且支持音乐搜索的启用站点。 */
async function queryMusicSites() {
musicSites.value = []
try {
const sites: Site[] = await api.get('site/media/music')
musicSites.value = sites.filter(site => site.is_active)
} catch (error) {
console.error(error)
}
}
/** 查询用户默认选择的索引站点。 */
async function querySelectedSites() {
selectedSites.value = []
try {
const result: { data?: { value?: number[] } } = await api.get('system/setting/public/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.error(error)
}
}
/** 重新加载音乐站点,并同步更新已打开对话框的属性。 */
async function reloadMusicSites() {
await queryMusicSites()
dialogController?.updateProps({
sites: musicSites.value,
selected: selectedSites.value,
})
}
/** 使用用户确认的站点进入音乐资源搜索页。 */
function searchSelectedSites(sites: number[]) {
if (!sites.length) return
selectedSites.value = sites
const target = buildRoute(sites)
if (target) router.push(target)
}
/** 打开只包含音乐站点的站点选择对话框。 */
function openMusicSiteDialog() {
dialogController = openSharedDialog(
SearchSiteDialog,
{
sites: musicSites.value,
selected: selectedSites.value,
},
{
reload: reloadMusicSites,
search: searchSelectedSites,
},
{ closeOn: ['close', 'search'] },
)
}
/** 加载音乐站点和默认选择后打开站点选择对话框。 */
async function openMusicSiteSearch() {
await Promise.all([queryMusicSites(), querySelectedSites()])
openMusicSiteDialog()
}
return { openMusicSiteSearch }
}
+36
View File
@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(), apiGet: vi.fn(),
apiPost: vi.fn(), apiPost: vi.fn(),
apiDelete: vi.fn(), apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
toastSuccess: vi.fn(), toastSuccess: vi.fn(),
})) }))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
default: { default: {
get: (...args: unknown[]) => mocks.apiGet(...args), get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -75,10 +80,16 @@ const album = {
year: 1975, year: 1975,
} }
const musicSite = { id: 13, is_active: true, name: '专辑站点', url: 'https://album-music.example' }
/** 按请求路径分派专辑详情与订阅状态查询。 */ /** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) { function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => { mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album) if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [13] }, success: true })
}
if (path.startsWith('subscribe/media/')) { if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
} }
@@ -101,6 +112,8 @@ describe('music album page', () => {
mocks.apiGet.mockReset() mocks.apiGet.mockReset()
mocks.apiPost.mockReset() mocks.apiPost.mockReset()
mocks.apiDelete.mockReset() mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockAlbumRequests() mockAlbumRequests()
}) })
@@ -161,4 +174,27 @@ describe('music album page', () => {
expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument() expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument()
}) })
it('selects a music-capable site before searching album resources', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(router.currentRoute.value.path).toBe('/music/album')
dialogEvents.search([13])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:release-group-1',
sites: '13',
type: '音乐',
})
})
}) })
+27 -5
View File
@@ -1,11 +1,16 @@
import MusicArtistPage from '@/pages/music-artist.vue' import MusicArtistPage from '@/pages/music-artist.vue'
import { screen, waitFor } from '@testing-library/vue' import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(), apiGet: vi.fn(),
openSharedDialog: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
})) }))
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
@@ -38,6 +43,8 @@ const artist = {
type: '音乐', type: '音乐',
} }
const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'https://artist-music.example' }
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */ /** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() { function renderArtistPage() {
return renderWithProviders(MusicArtistPage, { return renderWithProviders(MusicArtistPage, {
@@ -50,8 +57,14 @@ function renderArtistPage() {
describe('music artist page', () => { describe('music artist page', () => {
beforeEach(() => { beforeEach(() => {
mocks.apiGet.mockReset() mocks.apiGet.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mocks.apiGet.mockImplementation((path: string) => { mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/artist/artist-1') return Promise.resolve(artist) if (path === 'music/artist/artist-1') return Promise.resolve(artist)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [14] }, success: true })
}
return Promise.resolve([]) return Promise.resolve([])
}) })
}) })
@@ -88,13 +101,22 @@ describe('music artist page', () => {
expect(screen.getByText('official homepage')).toBeInTheDocument() expect(screen.getByText('official homepage')).toBeInTheDocument()
}) })
it('routes the resource search action to the site resource page', async () => { it('selects a music-capable site before searching artist resources', async () => {
const { router } = await renderArtistPage() const { router } = await renderArtistPage()
const searchButton = await screen.findByRole('button', { name: '搜索资源' }) await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
searchButton.click()
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(router.currentRoute.value.path).toBe('/music/artist')
dialogEvents.search([14])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', type: '音乐' }) expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', sites: '14', type: '音乐' })
}) })
}) })
+25 -1
View File
@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(), apiGet: vi.fn(),
apiPost: vi.fn(), apiPost: vi.fn(),
apiDelete: vi.fn(), apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
toastSuccess: vi.fn(), toastSuccess: vi.fn(),
})) }))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
default: { default: {
get: (...args: unknown[]) => mocks.apiGet(...args), get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -72,6 +77,8 @@ const album = {
type: '音乐', type: '音乐',
} }
const musicSite = { id: 12, is_active: true, name: '音乐详情站点', url: 'https://detail-music.example' }
/** 按请求路径分派单曲详情、专辑详情和订阅状态查询。 */ /** 按请求路径分派单曲详情、专辑详情和订阅状态查询。 */
function mockDetailRequests(subscribed = false) { function mockDetailRequests(subscribed = false) {
mocks.apiPost.mockImplementation((path: string) => { mocks.apiPost.mockImplementation((path: string) => {
@@ -80,6 +87,10 @@ function mockDetailRequests(subscribed = false) {
}) })
mocks.apiGet.mockImplementation((path: string) => { mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album) if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [12] }, success: true })
}
if (path.startsWith('subscribe/media/')) { if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
} }
@@ -101,6 +112,8 @@ describe('music detail page', () => {
mocks.apiGet.mockReset() mocks.apiGet.mockReset()
mocks.apiPost.mockReset() mocks.apiPost.mockReset()
mocks.apiDelete.mockReset() mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockDetailRequests() mockDetailRequests()
}) })
@@ -183,14 +196,25 @@ describe('music detail page', () => {
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' }) expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
}) })
it('routes the resource search action to the site resource page', async () => { it('selects a music-capable site before routing the resource search', async () => {
const { router } = await renderMusicDetailPage() const { router } = await renderMusicDetailPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(router.currentRoute.value.path).toBe('/music/detail')
dialogEvents.search([12])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1', keyword: 'musicbrainz:recording-1',
sites: '12',
type: '音乐', type: '音乐',
}) })
}) })
+41 -1
View File
@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(), apiGet: vi.fn(),
apiPost: vi.fn(), apiPost: vi.fn(),
apiDelete: vi.fn(), apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
toastSuccess: vi.fn(), toastSuccess: vi.fn(),
})) }))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
default: { default: {
get: (...args: unknown[]) => mocks.apiGet(...args), get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -44,10 +49,16 @@ const musicResult = {
year: 2003, year: 2003,
} }
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' }
/** 按请求路径分派音乐搜索与订阅状态查询。 */ /** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean) { function mockSearchAndSubscribeState(subscribed: boolean) {
mocks.apiGet.mockImplementation((path: string) => { mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([musicResult]) if (path === 'media/search') return Promise.resolve([musicResult])
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [11, 99] }, success: true })
}
if (path.startsWith('subscribe/media/')) { if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
} }
@@ -69,6 +80,8 @@ describe('music page', () => {
mocks.apiGet.mockReset() mocks.apiGet.mockReset()
mocks.apiPost.mockReset() mocks.apiPost.mockReset()
mocks.apiDelete.mockReset() mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockSearchAndSubscribeState(false) mockSearchAndSubscribeState(false)
mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true }) mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true })
}) })
@@ -89,6 +102,7 @@ describe('music page', () => {
await renderMusicPage() await renderMusicPage()
expect(await screen.findByText('晴天')).toBeInTheDocument() expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
expect(screen.getByText('周杰伦')).toBeInTheDocument() expect(screen.getByText('周杰伦')).toBeInTheDocument()
expect(screen.getByText('叶惠美')).toBeInTheDocument() expect(screen.getByText('叶惠美')).toBeInTheDocument()
expect(screen.getByText('2003-07-31')).toBeInTheDocument() expect(screen.getByText('2003-07-31')).toBeInTheDocument()
@@ -96,6 +110,18 @@ describe('music page', () => {
expect(screen.getByText('Album')).toBeInTheDocument() expect(screen.getByText('Album')).toBeInTheDocument()
}) })
it('uses the shared themed lift interaction for result cards', async () => {
const { container } = await renderMusicPage()
const hoverArea = await waitFor(() => container.querySelector('.music-card-hover-area'))
const card = container.querySelector('.music-card')!
await fireEvent.mouseEnter(hoverArea!)
await waitFor(() => expect(card).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.mouseLeave(hoverArea!)
await waitFor(() => expect(card).not.toHaveClass('app-hover-lift-card--hovering'))
})
it('offers a subscribe action when the music is not subscribed yet', async () => { it('offers a subscribe action when the music is not subscribed yet', async () => {
await renderMusicPage() await renderMusicPage()
@@ -155,14 +181,28 @@ describe('music page', () => {
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' }) expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
}) })
it('routes the resource search action to the site resource page', async () => { it('selects a music-capable site before routing the resource search', async () => {
const { router } = await renderMusicPage() const { router } = await renderMusicPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(mocks.apiGet).toHaveBeenCalledWith('site/media/music')
expect(router.currentRoute.value.path).toBe('/music')
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ selected: number[]; sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(dialogProps.selected).toEqual([11, 99])
dialogEvents.search([11])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1', keyword: 'musicbrainz:recording-1',
sites: '11',
type: '音乐', type: '音乐',
}) })
}) })
+16 -2
View File
@@ -43,8 +43,8 @@ watch(query, searchMusic, { immediate: true })
<VPageContentTitle :title="query || t('music.title')" /> <VPageContentTitle :title="query || t('music.title')" />
<LoadingBanner v-if="loading" class="mt-12" /> <LoadingBanner v-if="loading" class="mt-12" />
<VRow v-else-if="results.length"> <VRow v-else-if="results.length" class="music-results">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4"> <VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4" class="music-result-col">
<MusicCard :music="item" /> <MusicCard :music="item" />
</VCol> </VCol>
</VRow> </VRow>
@@ -58,4 +58,18 @@ watch(query, searchMusic, { immediate: true })
max-width: 1440px; max-width: 1440px;
margin-inline: auto; margin-inline: auto;
} }
@media (width <= 600px) {
.music-search-page {
padding-inline: 0.5rem;
}
.music-results {
margin-block: -0.375rem;
}
.music-result-col {
padding-block: 0.375rem;
}
}
</style> </style>
+6 -3
View File
@@ -66,15 +66,18 @@ describe('music utils', () => {
it('builds the site resource route from the metadata identity', () => { it('builds the site resource route from the metadata identity', () => {
expect( expect(
buildMusicResourceRoute({ buildMusicResourceRoute(
{
source: 'musicbrainz', source: 'musicbrainz',
media_id: 'recording-1', media_id: 'recording-1',
title: '晴天', title: '晴天',
year: '2003', year: '2003',
} as never), } as never,
[11, 12],
),
).toMatchObject({ ).toMatchObject({
path: '/resource', path: '/resource',
query: { keyword: 'musicbrainz:recording-1', type: '音乐' }, query: { keyword: 'musicbrainz:recording-1', sites: '11,12', type: '音乐' },
}) })
}) })
+5 -1
View File
@@ -59,7 +59,10 @@ export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw
} }
/** 构造音乐元数据身份对应的站点资源搜索路由。 */ /** 构造音乐元数据身份对应的站点资源搜索路由。 */
export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): RouteLocationRaw | undefined { export function buildMusicResourceRoute(
item: MediaInfo | MusicAlbumInfo,
sites: number[] = [],
): RouteLocationRaw | undefined {
const source = getMusicSource(item as MusicRouteTarget) const source = getMusicSource(item as MusicRouteTarget)
if (!source || !item.media_id) return undefined if (!source || !item.media_id) return undefined
return { return {
@@ -71,6 +74,7 @@ export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): Route
year: item.year, year: item.year,
area: 'title', area: 'title',
result_type: 'torrent', result_type: 'torrent',
...(sites.length ? { sites: sites.join(',') } : {}),
}, },
} }
} }
+3 -2
View File
@@ -176,10 +176,11 @@ function openSearchSiteDialog() {
) )
} }
// 查询所有站点 // 查询与当前媒体类型兼容的站点
async function querySites() { async function querySites() {
try { try {
const data: Site[] = await api.get('site/') const mediaType = mediaDetail.value.type === '电视剧' ? 'tv' : 'movie'
const data: Site[] = await api.get(`site/media/${mediaType}`)
// 过滤站点,只有启用的站点才显示 // 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active) allSites.value = data.filter(item => item.is_active)
+6 -8
View File
@@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue' import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music' import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
@@ -70,6 +71,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey, getSubscribeStatusKey,
}) })
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
album.value ? buildMusicResourceRoute(album.value, sites) : undefined,
)
/** 加载专辑详情、曲目列表和发行版本。 */ /** 加载专辑详情、曲目列表和发行版本。 */
async function loadAlbumDetail() { async function loadAlbumDetail() {
if (!props.source || !props.mediaid) return if (!props.source || !props.mediaid) return
@@ -95,13 +100,6 @@ async function checkSubscribeStatus() {
} }
} }
/** 进入站点资源精确搜索。 */
function goResource() {
if (!album.value) return
const target = buildMusicResourceRoute(album.value)
if (target) router.push(target)
}
/** 打开艺术家详情页。 */ /** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) { function goArtist(artistId?: string, name?: string) {
if (!artistId) return if (!artistId) return
@@ -144,7 +142,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
</template> </template>
<template #actions> <template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource"> <VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
{{ t('music.searchResources') }} {{ t('music.searchResources') }}
</VBtn> </VBtn>
<VBtn <VBtn
+17 -17
View File
@@ -7,9 +7,9 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const props = defineProps({ const props = defineProps({
// MusicBrainz Artist ID // MusicBrainz Artist ID
@@ -28,6 +28,21 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false) const isRefreshed = ref(false)
const artist = ref<MusicArtistInfo>() const artist = ref<MusicArtistInfo>()
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
if (!artist.value?.name) return undefined
return {
path: '/resource',
query: {
keyword: artist.value.name,
type: '音乐',
title: artist.value.name,
area: 'title',
result_type: 'torrent',
sites: sites.join(','),
},
}
})
// 艺术家作品按 MusicBrainz 的 Release Group 主类型分区展示 // 艺术家作品按 MusicBrainz 的 Release Group 主类型分区展示
const albumSections = computed(() => [ const albumSections = computed(() => [
{ type: 'album', title: t('music.albums') }, { type: 'album', title: t('music.albums') },
@@ -58,21 +73,6 @@ async function loadArtistDetail() {
} }
} }
/** 按艺术家名称进入站点资源搜索。 */
function goResource() {
if (!artist.value?.name) return
router.push({
path: '/resource',
query: {
keyword: artist.value.name,
type: '音乐',
title: artist.value.name,
area: 'title',
result_type: 'torrent',
},
})
}
/** 返回指定专辑类型的浏览列表路由。 */ /** 返回指定专辑类型的浏览列表路由。 */
function getAlbumsBrowseRoute(albumType: string, title: string) { function getAlbumsBrowseRoute(albumType: string, title: string) {
return `/browse/music/artist/${props.mediaid}/albums?title=${encodeURIComponent(title)}&album_type=${albumType}` return `/browse/music/artist/${props.mediaid}/albums?title=${encodeURIComponent(title)}&album_type=${albumType}`
@@ -96,7 +96,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
</template> </template>
<template #actions> <template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource"> <VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
{{ t('music.searchResources') }} {{ t('music.searchResources') }}
</VBtn> </VBtn>
</template> </template>
+6 -8
View File
@@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue' import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { import {
@@ -71,6 +72,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey, getSubscribeStatusKey,
}) })
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
music.value ? buildMusicResourceRoute(music.value, sites) : undefined,
)
/** 加载单曲详情,并按所属专辑补全曲目列表。 */ /** 加载单曲详情,并按所属专辑补全曲目列表。 */
async function loadMusicDetail() { async function loadMusicDetail() {
if (!props.source || !props.mediaid) return if (!props.source || !props.mediaid) return
@@ -116,13 +121,6 @@ async function checkSubscribeStatus() {
} }
} }
/** 进入站点资源精确搜索。 */
function goResource() {
if (!music.value) return
const target = buildMusicResourceRoute(music.value)
if (target) router.push(target)
}
/** 打开所属专辑详情页。 */ /** 打开所属专辑详情页。 */
function goAlbum() { function goAlbum() {
if (!music.value?.album_id) return if (!music.value?.album_id) return
@@ -166,7 +164,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</template> </template>
<template #actions> <template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource"> <VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
{{ t('music.searchResources') }} {{ t('music.searchResources') }}
</VBtn> </VBtn>
<VBtn <VBtn
@@ -65,7 +65,8 @@ vi.mock('@/utils/appDeepLink', () => ({
})) }))
const API_BASE_URL = 'http://localhost/api/v1/' const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
const PersonCardSlideViewStub = defineComponent({ const PersonCardSlideViewStub = defineComponent({
@@ -134,9 +135,9 @@ interface RenderDetailOptions {
type?: string type?: string
} }
function installSiteHandlers(sites: Site[] = [], selected: number[] = []) { function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type = '电影') {
server.use( server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)), http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })), http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
) )
} }
@@ -169,7 +170,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
) )
} }
options.setupHandlers?.() options.setupHandlers?.()
installSiteHandlers(options.sites, options.selectedSites) installSiteHandlers(options.sites, options.selectedSites, type)
const result = await renderWithProviders(MediaDetailView, { const result = await renderWithProviders(MediaDetailView, {
initialState: { initialState: {
@@ -508,7 +509,7 @@ describe('MediaDetailView detail and actions', () => {
it('continues to resource search when site settings fail to load', async () => { it('continues to resource search when site settings fail to load', async () => {
await renderDetail() await renderDetail()
server.use( server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: '站点失败' }, { status: 500 })), http.get(movieSiteListUrl, () => HttpResponse.json({ message: '站点失败' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ message: '设置失败' }, { status: 500 })), http.get(selectedSitesUrl, () => HttpResponse.json({ message: '设置失败' }, { status: 500 })),
) )
@@ -522,7 +523,7 @@ describe('MediaDetailView detail and actions', () => {
const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' }) const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' })
await renderDetail() await renderDetail()
server.use( server.use(
http.get(siteListUrl, () => HttpResponse.json([site])), http.get(movieSiteListUrl, () => HttpResponse.json([site])),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })), http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })),
) )