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

View File

@@ -122,10 +122,11 @@ function openSearchSiteDialog() {
)
}
// 查询所有站点
// 查询与当前媒体类型兼容的站点
async function querySites() {
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)

View File

@@ -4,6 +4,7 @@ import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
@@ -34,23 +35,44 @@ const isSubscribed = ref(false)
// 可点击跳转的艺术家
const artistLinks = computed(() => getMusicArtistLinks(props.music))
// 卡片上展示的元数据标签,只保留 MusicBrainz 实际返回的字段
const metaChips = computed(() => {
const chips: string[] = []
// 音乐实体标签和图标
const entityMeta = computed(() => {
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
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()
if (releaseDate) chips.push(releaseDate)
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
const duration = formatMusicDuration(props.music?.duration)
if (duration) chips.push(duration)
if (props.music?.track_number) chips.push(t('music.trackNumber', { number: props.music.track_number }))
if (duration) items.push({ icon: 'mdi-clock-outline', label: duration })
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)
chips.push(t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }))
return chips
items.push({
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 showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
/** 生成订阅状态缓存键。 */
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(props.music)}::all`
}
@@ -62,6 +84,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey,
})
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
props.music ? buildMusicResourceRoute(props.music, sites) : undefined,
)
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
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))
}
/** 使用音乐元数据身份进入站点资源精确搜索页。 */
function goResource() {
if (!props.music) return
const target = buildMusicResourceRoute(props.music)
if (target) router.push(target)
}
watch(
() => coverUrl.value,
() => {
imageLoadError.value = false
},
)
watch(() => props.music?.media_id, checkSubscribeStatus)
@@ -105,99 +131,208 @@ onMounted(checkSubscribeStatus)
</script>
<template>
<VCard class="music-card h-100 cursor-pointer" @click="goDetail">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="coverUrl && !imageLoadError"
:src="coverUrl"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0 music-card-cover"
@error="imageLoadError = true"
/>
<VSheet v-else width="104" height="104" rounded="lg" class="d-flex align-center justify-center flex-grow-0">
<VIcon icon="mdi-album" size="48" color="medium-emphasis" />
</VSheet>
<VHover>
<template #default="hover">
<div v-bind="hover.props" class="music-card-hover-area h-100">
<VCard
class="music-card app-hover-lift-card h-100 cursor-pointer"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click="goDetail"
>
<div class="music-card-content">
<div class="music-card-cover-shell">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100" />
</template>
</VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<div class="music-card-body flex-grow-1">
<div class="d-flex align-start ga-2">
<div class="music-card-title text-h6">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="flex-grow-0 mt-1">
{{ props.music.version }}
</VChip>
</div>
<VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis music-card-artists">
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</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 }}
</VChip>
</div>
<div v-if="props.music?.album" class="text-caption text-medium-emphasis mt-1 music-card-album">
{{ t('music.album') }}
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
<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-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</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 v-if="props.music?.album" class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
</div>
<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
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
: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()"
/>
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="primary"
:aria-label="t('music.searchResources')"
:title="t('music.searchResources')"
size="small"
variant="tonal"
@click.stop="openMusicSiteSearch"
/>
</div>
</div>
</div>
</div>
</VCard>
</div>
<div class="music-card-actions d-flex flex-column align-center ga-1">
<IconBtn
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="medium-emphasis"
:aria-label="t('music.searchResources')"
@click.stop="goResource"
/>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<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 {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.music-card-heading {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.music-card-title {
overflow: hidden;
display: -webkit-box;
color: rgb(var(--v-theme-on-surface));
font-size: 1rem;
font-weight: 600;
-webkit-box-orient: vertical;
-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;
margin-block-start: 0.35rem;
}
.music-card-artists,
@@ -208,16 +343,95 @@ onMounted(checkSubscribeStatus)
}
.music-card-link {
color: rgb(var(--v-theme-primary));
color: inherit;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
}
.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 {
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>

View File

@@ -27,7 +27,8 @@ vi.mock('@/router', () => ({
}))
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
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(
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 })),
)
}
@@ -359,7 +364,7 @@ describe('MediaCard', () => {
})
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 { container } = await renderCard(media)
@@ -385,7 +390,7 @@ describe('MediaCard', () => {
it('falls back to global search when site settings cannot provide active selections', async () => {
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 })),
)
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 () => {
server.use(
http.get(siteListUrl, () =>
http.get(movieSiteListUrl, () =>
HttpResponse.json([
{
domain: 'fallback.example',

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 }
}

View File

@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -75,10 +80,16 @@ const album = {
year: 1975,
}
const musicSite = { id: 13, is_active: true, name: '专辑站点', url: 'https://album-music.example' }
/** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => {
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/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
@@ -101,6 +112,8 @@ describe('music album page', () => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockAlbumRequests()
})
@@ -161,4 +174,27 @@ describe('music album page', () => {
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: '音乐',
})
})
})

View File

@@ -1,11 +1,16 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
openSharedDialog: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
@@ -38,6 +43,8 @@ const artist = {
type: '音乐',
}
const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'https://artist-music.example' }
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() {
return renderWithProviders(MusicArtistPage, {
@@ -50,8 +57,14 @@ function renderArtistPage() {
describe('music artist page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mocks.apiGet.mockImplementation((path: string) => {
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([])
})
})
@@ -88,13 +101,22 @@ describe('music artist page', () => {
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 searchButton = await screen.findByRole('button', { name: '搜索资源' })
searchButton.click()
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/artist')
dialogEvents.search([14])
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: '音乐' })
})
})

View File

@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -72,6 +77,8 @@ const album = {
type: '音乐',
}
const musicSite = { id: 12, is_active: true, name: '音乐详情站点', url: 'https://detail-music.example' }
/** 按请求路径分派单曲详情、专辑详情和订阅状态查询。 */
function mockDetailRequests(subscribed = false) {
mocks.apiPost.mockImplementation((path: string) => {
@@ -80,6 +87,10 @@ function mockDetailRequests(subscribed = false) {
})
mocks.apiGet.mockImplementation((path: string) => {
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/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
@@ -101,6 +112,8 @@ describe('music detail page', () => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockDetailRequests()
})
@@ -183,14 +196,25 @@ describe('music detail page', () => {
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()
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'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
sites: '12',
type: '音乐',
})
})

View File

@@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -44,10 +49,16 @@ const musicResult = {
year: 2003,
}
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' }
/** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean) {
mocks.apiGet.mockImplementation((path: string) => {
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/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
@@ -69,6 +80,8 @@ describe('music page', () => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockSearchAndSubscribeState(false)
mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true })
})
@@ -89,6 +102,7 @@ describe('music page', () => {
await renderMusicPage()
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
expect(screen.getByText('周杰伦')).toBeInTheDocument()
expect(screen.getByText('叶惠美')).toBeInTheDocument()
expect(screen.getByText('2003-07-31')).toBeInTheDocument()
@@ -96,6 +110,18 @@ describe('music page', () => {
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 () => {
await renderMusicPage()
@@ -155,14 +181,28 @@ describe('music page', () => {
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()
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'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
sites: '11',
type: '音乐',
})
})

View File

@@ -43,8 +43,8 @@ watch(query, searchMusic, { immediate: true })
<VPageContentTitle :title="query || t('music.title')" />
<LoadingBanner v-if="loading" class="mt-12" />
<VRow v-else-if="results.length">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4">
<VRow v-else-if="results.length" class="music-results">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4" class="music-result-col">
<MusicCard :music="item" />
</VCol>
</VRow>
@@ -58,4 +58,18 @@ watch(query, searchMusic, { immediate: true })
max-width: 1440px;
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>

View File

@@ -66,15 +66,18 @@ describe('music utils', () => {
it('builds the site resource route from the metadata identity', () => {
expect(
buildMusicResourceRoute({
source: 'musicbrainz',
media_id: 'recording-1',
title: '晴天',
year: '2003',
} as never),
buildMusicResourceRoute(
{
source: 'musicbrainz',
media_id: 'recording-1',
title: '晴天',
year: '2003',
} as never,
[11, 12],
),
).toMatchObject({
path: '/resource',
query: { keyword: 'musicbrainz:recording-1', type: '音乐' },
query: { keyword: 'musicbrainz:recording-1', sites: '11,12', type: '音乐' },
})
})

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)
if (!source || !item.media_id) return undefined
return {
@@ -71,6 +74,7 @@ export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): Route
year: item.year,
area: 'title',
result_type: 'torrent',
...(sites.length ? { sites: sites.join(',') } : {}),
},
}
}

View File

@@ -176,10 +176,11 @@ function openSearchSiteDialog() {
)
}
// 查询所有站点
// 查询与当前媒体类型兼容的站点
async function querySites() {
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)

View File

@@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
@@ -70,6 +71,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey,
})
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
album.value ? buildMusicResourceRoute(album.value, sites) : undefined,
)
/** 加载专辑详情、曲目列表和发行版本。 */
async function loadAlbumDetail() {
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) {
if (!artistId) return
@@ -144,7 +142,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
</template>
<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') }}
</VBtn>
<VBtn

View File

@@ -7,9 +7,9 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
// MusicBrainz Artist ID
@@ -28,6 +28,21 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false)
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 主类型分区展示
const albumSections = computed(() => [
{ 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) {
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 #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') }}
</VBtn>
</template>

View File

@@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import {
@@ -71,6 +72,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey,
})
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
music.value ? buildMusicResourceRoute(music.value, sites) : undefined,
)
/** 加载单曲详情,并按所属专辑补全曲目列表。 */
async function loadMusicDetail() {
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() {
if (!music.value?.album_id) return
@@ -166,7 +164,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</template>
<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') }}
</VBtn>
<VBtn

View File

@@ -65,7 +65,8 @@ vi.mock('@/utils/appDeepLink', () => ({
}))
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 PersonCardSlideViewStub = defineComponent({
@@ -134,9 +135,9 @@ interface RenderDetailOptions {
type?: string
}
function installSiteHandlers(sites: Site[] = [], selected: number[] = []) {
function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type = '电影') {
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 })),
)
}
@@ -169,7 +170,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
)
}
options.setupHandlers?.()
installSiteHandlers(options.sites, options.selectedSites)
installSiteHandlers(options.sites, options.selectedSites, type)
const result = await renderWithProviders(MediaDetailView, {
initialState: {
@@ -508,7 +509,7 @@ describe('MediaDetailView detail and actions', () => {
it('continues to resource search when site settings fail to load', async () => {
await renderDetail()
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 })),
)
@@ -522,7 +523,7 @@ describe('MediaDetailView detail and actions', () => {
const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' })
await renderDetail()
server.use(
http.get(siteListUrl, () => HttpResponse.json([site])),
http.get(movieSiteListUrl, () => HttpResponse.json([site])),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })),
)