fix(v3): finish music discovery and detail flows

This commit is contained in:
jxxghp
2026-08-07 23:46:54 +08:00
parent d48ee55bab
commit 02e3314853
25 changed files with 699 additions and 55 deletions

View File

@@ -27,6 +27,7 @@ import {
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
} from '@/utils/mediaStatusCache'
import { buildMusicDetailRoute } from '@/utils/music'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
@@ -271,12 +272,7 @@ function goMediaDetail(isHovering = false) {
resetMediaCardDetailState()
if (props.media?.type === '音乐') {
router.push({
path: '/music',
query: {
query: [props.media?.artist, props.media?.title].filter(Boolean).join(' - '),
},
})
router.push(buildMusicDetailRoute(props.media))
} else if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
@@ -403,6 +399,7 @@ function setupIntersectionObserver() {
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (props.media?.type === '音乐' && (!props.media?.poster_path || imageLoadError.value)) return ''
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
@@ -478,7 +475,14 @@ onBeforeUnmount(() => {
}"
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<div
v-if="props.media?.type === '音乐' && !getImgUrl"
class="music-card-placeholder d-flex align-center justify-center"
>
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
</div>
<VImg
v-else
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
@@ -569,6 +573,13 @@ onBeforeUnmount(() => {
inline-size: 100%;
}
.music-card-placeholder {
aspect-ratio: 2 / 3;
background: rgb(var(--v-theme-surface-variant));
block-size: 100%;
inline-size: 100%;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;

View File

@@ -4,10 +4,13 @@ import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
defineProps({
const props = defineProps({
context: Object as PropType<Context>,
})
// 音乐元数据使用 title影视元数据使用 name识别结果卡片统一兼容两种字段。
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url) return ''
@@ -27,7 +30,7 @@ function openTmdbPage(type: string, tmdbId: number) {
<div v-show="context">
<VCol>
<div
v-if="context?.meta_info?.name"
v-if="recognizedName"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div v-if="context?.media_info?.poster_path" class="ma-auto">
@@ -48,7 +51,7 @@ function openTmdbPage(type: string, tmdbId: number) {
<div class="flex-grow">
<VCardItem class="pb-1">
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.media_info?.title || recognizedName }}
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
{{ context?.meta_info?.season_episode }}
</span>
@@ -117,7 +120,7 @@ function openTmdbPage(type: string, tmdbId: number) {
</VCardItem>
</div>
</div>
<VAlert v-if="!context?.meta_info?.name" icon="mdi-alert-circle-outline"> 识别失败无法识别到有效信息 </VAlert>
<VAlert v-if="!recognizedName" icon="mdi-alert-circle-outline"> 识别失败无法识别到有效信息 </VAlert>
</VCol>
<VExpansionPanels v-show="!isNullOrEmptyObject(context?.meta_info.apply_words)">
<VExpansionPanel>

View File

@@ -11,6 +11,7 @@ import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute } from '@/utils/music'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -284,10 +285,7 @@ function getMediaId() {
// 查看媒体详情
async function viewMediaDetail() {
if (props.media?.type === '音乐') {
router.push({
path: '/music',
query: { query: props.media?.name },
})
router.push(buildMusicDetailRoute(props.media))
return
}
router.push({

View File

@@ -304,7 +304,7 @@ describe('MediaCard', () => {
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
})
it('opens music search and skips media-library existence checks', async () => {
it('opens music detail and skips media-library existence checks', async () => {
const media = createMediaInfo({
artist: '周杰伦',
media_id: 'recording-1',
@@ -332,12 +332,32 @@ describe('MediaCard', () => {
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/music',
query: { query: '周杰伦 - 晴天' },
path: '/music/detail',
query: {
source: 'musicbrainz',
mediaid: 'recording-1',
title: '晴天',
},
}),
)
})
it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => {
const media = createMediaInfo({
media_id: 'recording-2',
source: 'musicbrainz',
poster_path: undefined,
title: '无封面歌曲',
tmdb_id: undefined,
type: '音乐',
})
const { container } = await renderCard(media)
expect(container.querySelector('.music-card-placeholder .v-icon')).not.toBeNull()
expect(container.querySelector('img[src*="no-image"]')).toBeNull()
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })

View File

@@ -0,0 +1,28 @@
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { describe, expect, it } from 'vitest'
describe('MediaInfoCard', () => {
it('renders music recognition results whose meta identity is stored in title', async () => {
await renderWithProviders(MediaInfoCard, {
props: {
context: {
meta_info: {
artists: ['周杰伦'],
title: '晴天',
type: '音乐',
},
media_info: {
artist: '周杰伦',
title: '晴天',
type: '音乐',
},
},
},
})
expect(screen.getByText('晴天')).toBeInTheDocument()
expect(screen.queryByText('识别失败,无法识别到有效信息!')).not.toBeInTheDocument()
})
})

View File

@@ -23,6 +23,7 @@ import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { isValidMediaSourceId } from '@/utils/mediaId'
// 国际化
const { t } = useI18n()
@@ -48,6 +49,7 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
{ title: 'MusicBrainz', value: 'musicbrainz' },
])
// 获取后台设置中的默认识别数据源未知值兼容回退到TheMovieDb。
@@ -153,6 +155,9 @@ function resolveTransferMediaType(type?: string) {
const tvTypes = ['电视剧', 'tv', 'series']
if (tvTypes.includes(normalizedType)) return '电视剧'
const musicTypes = ['音乐', 'music']
if (musicTypes.includes(normalizedType)) return '音乐'
return undefined
}
@@ -464,6 +469,16 @@ watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, sourc
episodeGroups.value = []
})
// 音乐目前只使用 MusicBrainz 原生身份,选择音乐类型时自动切换识别来源。
watch(
() => transferForm.type_name,
typeName => {
if (typeName === '音乐' && transferForm.media_source !== 'musicbrainz') {
transferForm.media_source = 'musicbrainz'
}
},
)
// 切换数据源时清空上一来源的原生ID避免把同一数字误传给新来源。
watch(
() => transferForm.media_source,
@@ -472,9 +487,17 @@ watch(
transferForm.media_id = null
mediaSelectorDialog.value = false
}
if (source === 'musicbrainz' && transferForm.type_name !== '音乐') {
transferForm.type_name = '音乐'
}
},
)
/** 按当前来源校验手动整理使用的原生媒体 ID。 */
function validateMediaId(value?: string | number | null) {
return isValidMediaSourceId(value, mediaSource.value) || t('dialog.reorganize.mediaIdInvalid')
}
watch(
() => transferForm.episode_group,
episodeGroup => {
@@ -1519,7 +1542,7 @@ onUnmounted(() => {
:disabled="transferForm.type_name === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[validateMediaId]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup>
import { numberValidator } from '@/@validators'
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { isValidMediaSourceId } from '@/utils/mediaId'
const { t } = useI18n()
@@ -29,6 +29,7 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
{ title: 'MusicBrainz', value: 'musicbrainz' },
])
const globalSettingsStore = useGlobalSettingsStore()
@@ -62,7 +63,7 @@ const mediaIdLabel = computed(() => {
const canSubmit = computed(() => {
const normalizedMediaId = mediaId.value?.trim()
return !normalizedMediaId || /^\d+$/.test(normalizedMediaId)
return isValidMediaSourceId(normalizedMediaId, mediaSource.value)
})
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
@@ -76,9 +77,15 @@ function resolveMediaType(type?: string) {
const normalizedType = type?.trim().toLowerCase()
if (['电影', 'movie'].includes(normalizedType ?? '')) return '电影'
if (['电视剧', 'tv', 'series'].includes(normalizedType ?? '')) return '电视剧'
if (['音乐', 'music'].includes(normalizedType ?? '')) return '音乐'
return undefined
}
/** 按当前刮削来源校验原生媒体 ID。 */
function validateMediaId(value?: string | null) {
return isValidMediaSourceId(value, mediaSource.value) || t('dialog.reorganize.mediaIdInvalid')
}
// 选择搜索结果后同步媒体类型,减少手动填写出错。
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
@@ -104,6 +111,11 @@ function submitScrape() {
watch(mediaSource, () => {
mediaId.value = null
mediaSelectorDialog.value = false
if (mediaSource.value === 'musicbrainz') mediaType.value = '音乐'
})
watch(mediaType, type => {
if (type === '音乐' && mediaSource.value !== 'musicbrainz') mediaSource.value = 'musicbrainz'
})
</script>
@@ -129,6 +141,7 @@ watch(mediaSource, () => {
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
]"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
@@ -151,7 +164,7 @@ watch(mediaSource, () => {
:disabled="mediaType === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[validateMediaId]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint

View File

@@ -169,7 +169,7 @@ const mediaSearchActions = computed(() => {
type: 'music',
icon: 'mdi-music-note-outline',
title: t('mediaType.music'),
description: t('music.subtitle'),
description: t('music.searchDescription'),
},
{
type: 'collection',

View File

@@ -76,4 +76,22 @@ describe('ScrapeDialog', () => {
expect(screen.getByText('共 2 项')).toBeInTheDocument()
})
it('supports MusicBrainz UUIDs when scraping music', async () => {
const user = userEvent.setup()
const { events } = await renderDialog('themoviedb', [
{ name: '晴天.flac', path: '/music/晴天.flac', storage: 'local', type: 'file' },
])
await user.click(screen.getByLabelText('类型'))
await user.click(await screen.findByRole('option', { name: '音乐' }))
await user.type(screen.getByLabelText('MusicBrainz ID'), '977e6978-139d-425c-bb98-6b0c62d1e45e')
await user.click(screen.getByRole('button', { name: '确认' }))
expect(events.scrape).toHaveBeenCalledWith({
media_source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
type_name: '音乐',
})
})
})

View File

@@ -114,6 +114,8 @@ describe('SearchBarDialog media source selection', () => {
await user.type(input, '晴天')
expect(getSearchItem('音乐').querySelector('[data-icon="mdi-music-note-outline"]')).not.toBeNull()
expect(getSearchItem('音乐')).toHaveTextContent('搜索歌曲、专辑或艺术家')
expect(getSearchItem('音乐')).not.toHaveTextContent('搜索音乐元数据,并进入站点资源搜索、下载和订阅流程')
})
it('searches actors with the selected supported source', async () => {

View File

@@ -704,7 +704,7 @@ async function recognize(path: string) {
// 关闭进度条
closeProgressDialog()
if (!nameTestResult.value) $toast.error(t('file.recognizeFailed', { path }))
if (nameTestResult.value?.meta_info?.name) {
if (nameTestResult.value?.meta_info?.name || nameTestResult.value?.meta_info?.title) {
openSharedDialog(MediaInfoDialog, { context: nameTestResult.value }, {}, { closeOn: ['close'] })
}
} catch (error) {

View File

@@ -214,6 +214,34 @@ describe('FileList list state', () => {
expect(renderedNames[1]).toContain('a-file.mkv')
})
it('opens recognized music details when audio metadata uses title instead of name', async () => {
const audio = createItem({
extension: 'flac',
name: '晴天.flac',
path: '/music/晴天.flac',
type: 'file',
})
mocks.apiGet.mockResolvedValueOnce({
meta_info: { artists: ['周杰伦'], title: '晴天', type: '音乐' },
media_info: { artist: '周杰伦', title: '晴天', type: '音乐' },
})
await renderList(() => Promise.resolve([]), { item: audio })
await fireEvent.click(getSlotIconButton('mdi-text-recognition'))
await waitFor(() =>
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({
context: {
meta_info: { title: '晴天', type: '音乐' },
media_info: { title: '晴天', type: '音乐' },
},
}),
)
expect(mocks.apiGet).toHaveBeenCalledWith('media/recognize_file', {
params: { path: '/music/晴天.flac' },
})
})
it('filters by substring, wildcard and case sensitivity', async () => {
await renderList(() =>
Promise.resolve([

View File

@@ -58,14 +58,19 @@ async function searchMedias() {
// 调用API搜索词条
try {
loading.value = true
const result: MediaInfo[] = await api.get('media/search', {
params: {
title: searchKeyword,
page: 1,
count: 20,
source: props.type,
},
})
const result: MediaInfo[] =
props.type === 'musicbrainz'
? await api.get('music/search', {
params: { query: searchKeyword, count: 20 },
})
: await api.get('media/search', {
params: {
title: searchKeyword,
page: 1,
count: 20,
source: props.type,
},
})
// 清空
items.value = []
@@ -81,10 +86,13 @@ async function searchMedias() {
if (!mediaId) continue
items.value.push({
id: mediaId,
poster: getW500Image(item.poster_path),
poster: getW500Image(item.cover_url || item.poster_path),
type: item.type,
title: item.year ? `${item.title}${item.year}` : item.title || '',
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
overview:
item.type === '音乐'
? `<span class="text-primary">${item.type}</span> ${[item.artist, item.album].filter(Boolean).join(' · ')}`
: `<span class="text-primary">${item.type}</span> ${item.overview || ''}`,
})
}
} catch (e) {

View File

@@ -412,7 +412,9 @@ export default {
},
music: {
title: 'Music Search',
back: 'Back',
subtitle: 'Find music metadata, then search sites, download, and subscribe',
searchDescription: 'Search tracks, albums, or artists',
searchPlaceholder: 'Enter a track, artist, or “artist - track”',
search: 'Search Music',
noResults: 'No matching music found',
@@ -421,7 +423,26 @@ export default {
subscribeSuccess: 'Music subscription added',
album: 'Album',
artist: 'Artist',
albumArtist: 'Album Artist',
releaseDate: 'Release Date',
duration: 'Duration',
category: 'Type',
listenCount: 'Listen Count',
source: 'MusicBrainz',
filter: {
period: 'Period',
sort: 'Sort',
cover: 'Cover',
thisWeek: 'This Week',
thisMonth: 'This Month',
thisYear: 'This Year',
allTime: 'All Time',
mostListened: 'Most Listened',
leastListened: 'Least Listened',
all: 'All',
withCover: 'With Cover',
minListenCount: 'Minimum Listens',
},
},
settingTabs: {
system: {
@@ -3187,6 +3208,7 @@ export default {
anilistId: 'AniList ID',
mediaIdHint: 'Query media ID by name, leave empty for auto recognition',
mediaIdPlaceholder: 'Leave empty for auto recognition',
mediaIdInvalid: 'Invalid media ID format',
mediaSearchInput: 'Search Media',
mediaSearchPlaceholder: 'Enter a media title',
episodeGroup: 'Episode Group',

View File

@@ -404,7 +404,9 @@ export default {
},
music: {
title: '音乐搜索',
back: '返回',
subtitle: '搜索音乐元数据,并进入站点资源搜索、下载和订阅流程',
searchDescription: '搜索歌曲、专辑或艺术家',
searchPlaceholder: '输入歌曲、艺术家或“艺术家 - 歌曲”',
search: '搜索音乐',
noResults: '没有找到匹配的音乐',
@@ -413,7 +415,26 @@ export default {
subscribeSuccess: '音乐订阅添加成功',
album: '专辑',
artist: '艺术家',
albumArtist: '专辑艺术家',
releaseDate: '发行日期',
duration: '时长',
category: '类型',
listenCount: '收听次数',
source: 'MusicBrainz',
filter: {
period: '周期',
sort: '排序',
cover: '封面',
thisWeek: '本周',
thisMonth: '本月',
thisYear: '今年',
allTime: '全部时间',
mostListened: '收听最多',
leastListened: '收听最少',
all: '全部',
withCover: '仅有封面',
minListenCount: '最低收听次数',
},
},
settingTabs: {
system: {
@@ -3132,6 +3153,7 @@ export default {
anilistId: 'AniList编号',
mediaIdHint: '按名称查询媒体编号,留空自动识别',
mediaIdPlaceholder: '留空自动识别',
mediaIdInvalid: '媒体ID格式无效',
mediaSearchInput: '搜索媒体',
mediaSearchPlaceholder: '输入媒体名称',
episodeGroup: '剧集组',

View File

@@ -404,7 +404,9 @@ export default {
},
music: {
title: '音樂搜索',
back: '返回',
subtitle: '搜索音樂元數據,並進入站點資源搜索、下載和訂閱流程',
searchDescription: '搜索歌曲、專輯或藝術家',
searchPlaceholder: '輸入歌曲、藝術家或「藝術家 - 歌曲」',
search: '搜索音樂',
noResults: '沒有找到匹配的音樂',
@@ -413,7 +415,26 @@ export default {
subscribeSuccess: '音樂訂閱添加成功',
album: '專輯',
artist: '藝術家',
albumArtist: '專輯藝術家',
releaseDate: '發行日期',
duration: '時長',
category: '類型',
listenCount: '收聽次數',
source: 'MusicBrainz',
filter: {
period: '週期',
sort: '排序',
cover: '封面',
thisWeek: '本週',
thisMonth: '本月',
thisYear: '今年',
allTime: '全部時間',
mostListened: '收聽最多',
leastListened: '收聽最少',
all: '全部',
withCover: '僅有封面',
minListenCount: '最低收聽次數',
},
},
settingTabs: {
system: {
@@ -3131,6 +3152,7 @@ export default {
anilistId: 'AniList編號',
mediaIdHint: '按名稱查詢媒體編號,留空自動識別',
mediaIdPlaceholder: '留空自動識別',
mediaIdInvalid: '媒體ID格式無效',
mediaSearchInput: '搜索媒體',
mediaSearchPlaceholder: '輸入媒體名稱',
episodeGroup: '劇集組',

View File

@@ -0,0 +1,75 @@
import MusicDetailPage from '@/pages/music-detail.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
post: (...args: unknown[]) => mocks.apiPost(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
describe('music detail page', () => {
beforeEach(() => {
mocks.apiPost.mockReset()
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'music/recognize') {
return Promise.resolve({
album: '叶惠美',
artist: '周杰伦',
artists: ['周杰伦'],
cover_url: 'https://coverartarchive.org/release-group/example/front-500',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
})
}
return Promise.resolve({ data: { id: 1 }, success: true })
})
})
it('loads details and connects resource search and subscription actions', async () => {
const { router } = await renderWithProviders(MusicDetailPage, {
initialRoute:
'/music/detail?source=musicbrainz&mediaid=977e6978-139d-425c-bb98-6b0c62d1e45e&title=晴天',
global: {
stubs: { NoDataFound: true },
},
})
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
})
await fireEvent.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith('subscribe/', {
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
year: '2003',
}),
)
await fireEvent.click(screen.getByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
})
})

View File

@@ -78,4 +78,25 @@ describe('music page', () => {
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('音乐订阅添加成功')
})
it('opens a MusicBrainz result on the dedicated music detail page', async () => {
const { router } = await renderWithProviders(MusicPage, {
initialRoute: '/music?query=晴天',
global: {
stubs: {
NoDataFound: true,
VPageContentTitle: true,
},
},
})
await fireEvent.click(await screen.findByText('晴天'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({
source: 'musicbrainz',
mediaid: 'recording-1',
title: '晴天',
})
})
})

148
src/pages/music-detail.vue Normal file
View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import api from '@/api'
import type { ApiResponse, MediaInfo } from '@/api/types'
import { buildMusicResourceRoute, getMusicKey } from '@/utils/music'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const subscribing = ref(false)
const imageError = ref(false)
const music = ref<MediaInfo>()
const source = computed(() => route.query.source?.toString() || '')
const mediaId = computed(() => route.query.mediaid?.toString() || '')
/** 加载路由指定的音乐详情。 */
async function loadMusicDetail() {
if (!source.value || !mediaId.value) return
loading.value = true
imageError.value = false
try {
music.value = await api.post('music/recognize', {
source: source.value,
media_id: mediaId.value,
})
} catch (error) {
console.error(error)
music.value = undefined
} finally {
loading.value = false
}
}
/** 从详情页进入现有站点资源搜索。 */
function searchResources() {
if (!music.value) return
const target = buildMusicResourceRoute(music.value)
if (target) router.push(target)
}
/** 从详情页创建音乐订阅,后端会以发行封面写入订阅海报。 */
async function subscribeMusic() {
if (!music.value?.source || !music.value.media_id || subscribing.value) return
subscribing.value = true
try {
const result = (await api.post('subscribe/', {
name: music.value.title,
year: music.value.year?.toString(),
type: '音乐',
media_source: music.value.source,
media_id: music.value.media_id,
})) as ApiResponse<{ id?: number }>
if (result.success) toast.success(t('music.subscribeSuccess'))
else toast.error(result.message || t('common.failed'))
} catch (error) {
console.error(error)
toast.error(t('common.failed'))
} finally {
subscribing.value = false
}
}
/** 将秒数格式化为详情页使用的分钟和秒。 */
function formatDuration(seconds?: number) {
if (!seconds) return ''
const minutes = Math.floor(seconds / 60)
const remainder = Math.floor(seconds % 60)
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
watch([source, mediaId], loadMusicDetail, { immediate: true })
</script>
<template>
<div class="music-detail-page">
<VBtn variant="text" prepend-icon="mdi-arrow-left" class="mb-3" @click="router.back()">
{{ t('music.back') }}
</VBtn>
<VSkeletonLoader v-if="loading" type="image, article, actions" />
<NoDataFound v-else-if="!music" :title="t('music.noResults')" />
<VCard v-else :key="getMusicKey(music)" class="overflow-hidden">
<VRow no-gutters>
<VCol cols="12" md="4" lg="3">
<VImg
v-if="(music.cover_url || music.poster_path) && !imageError"
:src="music.cover_url || music.poster_path"
aspect-ratio="1"
cover
@error="imageError = true"
/>
<VSheet v-else aspect-ratio="1" class="music-detail-cover d-flex align-center justify-center">
<VIcon icon="mdi-album" size="96" color="medium-emphasis" />
</VSheet>
</VCol>
<VCol cols="12" md="8" lg="9">
<VCardItem class="pa-6">
<VCardTitle class="text-h4 text-wrap">{{ music.title }}</VCardTitle>
<VCardSubtitle class="text-h6 mt-2">
{{ music.artist || music.artists?.join(' / ') || t('common.unknown') }}
</VCardSubtitle>
</VCardItem>
<VCardText class="px-6 pb-6">
<VList bg-color="transparent" density="compact">
<VListItem v-if="music.album" :title="t('music.album')" :subtitle="music.album" />
<VListItem v-if="music.album_artist" :title="t('music.albumArtist')" :subtitle="music.album_artist" />
<VListItem v-if="music.release_date || music.year" :title="t('music.releaseDate')" :subtitle="music.release_date || music.year?.toString()" />
<VListItem v-if="music.duration" :title="t('music.duration')" :subtitle="formatDuration(music.duration)" />
<VListItem v-if="music.isrc" title="ISRC" :subtitle="music.isrc" />
<VListItem v-if="music.category" :title="t('music.category')" :subtitle="music.category" />
<VListItem v-if="music.listen_count" :title="t('music.listenCount')" :subtitle="music.listen_count.toLocaleString()" />
</VList>
</VCardText>
<VCardActions class="px-6 pb-6 ga-3">
<VBtn color="primary" variant="tonal" prepend-icon="mdi-magnify" @click="searchResources">
{{ t('music.searchResources') }}
</VBtn>
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-rss"
:loading="subscribing"
@click="subscribeMusic"
>
{{ t('music.subscribe') }}
</VBtn>
</VCardActions>
</VCol>
</VRow>
</VCard>
</div>
</template>
<style scoped>
.music-detail-page {
max-width: 1200px;
margin-inline: auto;
}
.music-detail-cover {
min-block-size: 280px;
}
</style>

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { ApiResponse, MediaInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { buildMusicDetailRoute, buildMusicResourceRoute, getMusicKey } from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
@@ -14,16 +15,12 @@ const loading = ref(false)
const searched = ref(false)
const results = ref<MediaInfo[]>([])
const subscribingIds = ref(new Set<string>())
const imageErrorIds = ref(new Set<string>())
if (route.query.query) {
query.value = route.query.query.toString()
}
/** 返回音乐候选在列表中的稳定身份。 */
function getMusicKey(item: MediaInfo) {
return `${item.source || 'music'}:${item.media_id || `${item.artist}-${item.title}-${item.album}`}`
}
/** 调用统一音乐元数据接口搜索候选。 */
async function searchMusic() {
const keyword = query.value.trim()
@@ -43,18 +40,18 @@ async function searchMusic() {
/** 使用音乐元数据身份进入现有站点资源精确搜索页。 */
function searchResources(item: MediaInfo) {
if (!item.source || !item.media_id) return
router.push({
path: '/resource',
query: {
keyword: `${item.source}:${item.media_id}`,
type: '音乐',
title: item.title,
year: item.year,
area: 'title',
result_type: 'torrent',
},
})
const target = buildMusicResourceRoute(item)
if (target) router.push(target)
}
/** 打开选中音乐的标准详情页。 */
function viewMusicDetail(item: MediaInfo) {
router.push(buildMusicDetailRoute(item))
}
/** 记录失效封面,让音乐卡片改用专辑占位图标。 */
function markImageError(item: MediaInfo) {
imageErrorIds.value = new Set(imageErrorIds.value).add(getMusicKey(item))
}
/** 将选中的音乐目标写入现有订阅表和订阅调度流程。 */
@@ -127,16 +124,17 @@ onMounted(() => {
<VRow v-if="results.length">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4">
<VCard class="h-100">
<VCard class="h-100 cursor-pointer" @click="viewMusicDetail(item)">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="item.cover_url || item.poster_path"
v-if="(item.cover_url || item.poster_path) && !imageErrorIds.has(getMusicKey(item))"
:src="item.cover_url || item.poster_path"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0"
@error="markImageError(item)"
/>
<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" />
@@ -159,15 +157,16 @@ onMounted(() => {
</div>
<VCardActions class="px-4 pb-4 pt-0">
<VBtn variant="tonal" prepend-icon="mdi-magnify" @click="searchResources(item)">
<VBtn variant="tonal" color="primary" prepend-icon="mdi-magnify" @click.stop="searchResources(item)">
{{ t('music.searchResources') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-rss"
:loading="subscribingIds.has(getMusicKey(item))"
@click="subscribeMusic(item)"
@click.stop="subscribeMusic(item)"
>
{{ t('music.subscribe') }}
</VBtn>

View File

@@ -85,6 +85,13 @@ const router = createRouter({
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
},
{
path: '/music/detail',
component: () => import('../pages/music-detail.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/subscribe/movie',
component: () => import('../pages/subscribe.vue'),

11
src/utils/mediaId.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { MediaDataSource } from '@/api/types'
const MUSICBRAINZ_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
/** 按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它现有来源使用数字 ID。 */
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
const normalized = value?.toString().trim()
if (!normalized) return true
if (source === 'musicbrainz') return MUSICBRAINZ_ID_PATTERN.test(normalized)
return /^\d+$/.test(normalized)
}

58
src/utils/music.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { MediaInfo } from '@/api/types'
import type { RouteLocationRaw } from 'vue-router'
export interface MusicRouteTarget {
source?: string
media_source?: string
media_id?: string | number
title?: string
name?: string
year?: string | number
}
/** 返回音乐对象可用于路由和订阅的统一来源。 */
export function getMusicSource(item: MusicRouteTarget): string | undefined {
return item.source || item.media_source
}
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
export function getMusicKey(item: MusicRouteTarget): string {
const source = getMusicSource(item) || 'music'
return `${source}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
}
/** 构造音乐详情路由,缺少标准身份时回退到音乐搜索页。 */
export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw {
const source = getMusicSource(item)
if (!source || !item.media_id) {
return {
path: '/music',
query: { query: item.title || item.name },
}
}
return {
path: '/music/detail',
query: {
source,
mediaid: item.media_id.toString(),
title: item.title || item.name,
},
}
}
/** 构造音乐元数据身份对应的站点资源搜索路由。 */
export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | undefined {
const source = getMusicSource(item)
if (!source || !item.media_id) return undefined
return {
path: '/resource',
query: {
keyword: `${source}:${item.media_id}`,
type: '音乐',
title: item.title,
year: item.year,
area: 'title',
result_type: 'torrent',
},
}
}

View File

@@ -1,7 +1,81 @@
<script setup lang="ts">
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const rangeName = ref('this_month')
const sortBy = ref('listen_count.desc')
const coverFilter = ref('all')
const minListenCount = ref(0)
const currentKey = ref(0)
const rangeOptions = computed(() => ({
this_week: t('music.filter.thisWeek'),
this_month: t('music.filter.thisMonth'),
this_year: t('music.filter.thisYear'),
all_time: t('music.filter.allTime'),
}))
const sortOptions = computed(() => ({
'listen_count.desc': t('music.filter.mostListened'),
'listen_count.asc': t('music.filter.leastListened'),
}))
const filterParams = computed(() => ({
count: 30,
range_name: rangeName.value,
sort_by: sortBy.value,
min_listen_count: Math.max(0, minListenCount.value || 0),
with_cover: coverFilter.value === 'with_cover',
}))
watch([rangeName, sortBy, coverFilter, minListenCount], () => {
currentKey.value++
})
</script>
<template>
<MediaCardListView apipath="music/explore" :params="{ count: 30 }" />
<div class="px-3 music-explore-filters">
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.period') }}</VLabel>
<VChipGroup v-model="rangeName" mandatory>
<VChip v-for="(label, value) in rangeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="sortBy" mandatory>
<VChip v-for="(label, value) in sortOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-3">
<VLabel>{{ t('music.filter.cover') }}</VLabel>
<VChipGroup v-model="coverFilter" mandatory>
<VChip value="all" filter tile>{{ t('music.filter.all') }}</VChip>
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
</VChipGroup>
<VTextField
v-model.number="minListenCount"
:label="t('music.filter.minListenCount')"
type="number"
min="0"
density="compact"
variant="outlined"
hide-details
class="music-listen-count-filter"
/>
</div>
</div>
<MediaCardListView :key="currentKey" apipath="music/explore" :params="filterParams" />
</template>
<style scoped>
.music-listen-count-filter {
max-inline-size: 14rem;
}
</style>

View File

@@ -0,0 +1,33 @@
import MusicView from '@/views/discover/MusicView.vue'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
import { describe, expect, it } from 'vitest'
const MediaCardListViewStub = defineComponent({
props: ['apipath', 'params'],
template: '<pre data-testid="music-params">{{ JSON.stringify(params) }}</pre>',
})
describe('MusicView', () => {
it('provides period, sort, cover and listen-count filters to music exploration', async () => {
const user = userEvent.setup()
await renderWithProviders(MusicView, {
global: {
stubs: { MediaCardListView: MediaCardListViewStub },
},
})
await user.click(screen.getByText('本周'))
await user.click(screen.getByText('收听最少'))
await user.click(screen.getByText('仅有封面'))
await user.clear(screen.getByLabelText('最低收听次数'))
await user.type(screen.getByLabelText('最低收听次数'), '100')
expect(screen.getByTestId('music-params')).toHaveTextContent('"range_name":"this_week"')
expect(screen.getByTestId('music-params')).toHaveTextContent('"sort_by":"listen_count.asc"')
expect(screen.getByTestId('music-params')).toHaveTextContent('"with_cover":true')
expect(screen.getByTestId('music-params')).toHaveTextContent('"min_listen_count":100')
})
})