diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index ce574275..479d708b 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -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 = 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)" > +
+ +
{ 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; diff --git a/src/components/cards/MediaInfoCard.vue b/src/components/cards/MediaInfoCard.vue index 6b1361a8..5427fca5 100644 --- a/src/components/cards/MediaInfoCard.vue +++ b/src/components/cards/MediaInfoCard.vue @@ -4,10 +4,13 @@ import type { Context } from '@/api/types' import { isNullOrEmptyObject } from '@/@core/utils' // 输入参数 -defineProps({ +const props = defineProps({ context: Object as PropType, }) +// 音乐元数据使用 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) {
@@ -48,7 +51,7 @@ function openTmdbPage(type: string, tmdbId: number) {
- {{ context?.media_info?.title || context?.meta_info?.name }} + {{ context?.media_info?.title || recognizedName }} {{ context?.meta_info?.season_episode }} @@ -117,7 +120,7 @@ function openTmdbPage(type: string, tmdbId: number) {
- 识别失败,无法识别到有效信息! + 识别失败,无法识别到有效信息! diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 2a454fee..e4028ed1 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -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({ diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index cc3e282f..287d8a0b 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -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: '电视剧' }) diff --git a/src/components/cards/__tests__/MediaInfoCard.spec.ts b/src/components/cards/__tests__/MediaInfoCard.spec.ts new file mode 100644 index 00000000..18761b28 --- /dev/null +++ b/src/components/cards/__tests__/MediaInfoCard.spec.ts @@ -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() + }) +}) diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index e37d10d2..03f7abde 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -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 diff --git a/src/components/dialog/ScrapeDialog.vue b/src/components/dialog/ScrapeDialog.vue index da111391..7f8b87af 100644 --- a/src/components/dialog/ScrapeDialog.vue +++ b/src/components/dialog/ScrapeDialog.vue @@ -1,9 +1,9 @@ @@ -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 diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index becc6da3..fca92224 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -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', diff --git a/src/components/dialog/__tests__/ScrapeDialog.spec.ts b/src/components/dialog/__tests__/ScrapeDialog.spec.ts index 385914fd..f0a46e2b 100644 --- a/src/components/dialog/__tests__/ScrapeDialog.spec.ts +++ b/src/components/dialog/__tests__/ScrapeDialog.spec.ts @@ -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: '音乐', + }) + }) }) diff --git a/src/components/dialog/__tests__/SearchBarDialog.spec.ts b/src/components/dialog/__tests__/SearchBarDialog.spec.ts index 4991a54d..4441dbc0 100644 --- a/src/components/dialog/__tests__/SearchBarDialog.spec.ts +++ b/src/components/dialog/__tests__/SearchBarDialog.spec.ts @@ -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 () => { diff --git a/src/components/filebrowser/FileList.vue b/src/components/filebrowser/FileList.vue index 8ea4accd..f576512f 100644 --- a/src/components/filebrowser/FileList.vue +++ b/src/components/filebrowser/FileList.vue @@ -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) { diff --git a/src/components/filebrowser/__tests__/FileList.spec.ts b/src/components/filebrowser/__tests__/FileList.spec.ts index 73d43936..4ca5effb 100644 --- a/src/components/filebrowser/__tests__/FileList.spec.ts +++ b/src/components/filebrowser/__tests__/FileList.spec.ts @@ -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([ diff --git a/src/components/misc/MediaIdSelector.vue b/src/components/misc/MediaIdSelector.vue index a73e890c..2cd7f9fb 100644 --- a/src/components/misc/MediaIdSelector.vue +++ b/src/components/misc/MediaIdSelector.vue @@ -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: `${item.type} ${item.overview}`, + overview: + item.type === '音乐' + ? `${item.type} ${[item.artist, item.album].filter(Boolean).join(' · ')}` + : `${item.type} ${item.overview || ''}`, }) } } catch (e) { diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 3c04e3ae..acff0294 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index fd77647f..04718bb0 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -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: '剧集组', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index b0093ac9..9d9a7315 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -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: '劇集組', diff --git a/src/pages/__tests__/music-detail.spec.ts b/src/pages/__tests__/music-detail.spec.ts new file mode 100644 index 00000000..db414d33 --- /dev/null +++ b/src/pages/__tests__/music-detail.spec.ts @@ -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')) + }) +}) diff --git a/src/pages/__tests__/music.spec.ts b/src/pages/__tests__/music.spec.ts index db629c6f..0610cc09 100644 --- a/src/pages/__tests__/music.spec.ts +++ b/src/pages/__tests__/music.spec.ts @@ -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: '晴天', + }) + }) }) diff --git a/src/pages/music-detail.vue b/src/pages/music-detail.vue new file mode 100644 index 00000000..0215568b --- /dev/null +++ b/src/pages/music-detail.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/pages/music.vue b/src/pages/music.vue index 70c0aa3b..c6bb9545 100644 --- a/src/pages/music.vue +++ b/src/pages/music.vue @@ -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([]) const subscribingIds = ref(new Set()) +const imageErrorIds = ref(new Set()) 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(() => { - +
@@ -159,15 +157,16 @@ onMounted(() => {
- + {{ t('music.searchResources') }} {{ t('music.subscribe') }} diff --git a/src/router/index.ts b/src/router/index.ts index 569a2790..3b21fa6f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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'), diff --git a/src/utils/mediaId.ts b/src/utils/mediaId.ts new file mode 100644 index 00000000..10a192af --- /dev/null +++ b/src/utils/mediaId.ts @@ -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 + +/** 按媒体数据源校验原生 ID,MusicBrainz 使用 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) +} diff --git a/src/utils/music.ts b/src/utils/music.ts new file mode 100644 index 00000000..25919cc0 --- /dev/null +++ b/src/utils/music.ts @@ -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', + }, + } +} diff --git a/src/views/discover/MusicView.vue b/src/views/discover/MusicView.vue index 421bcac3..018bf1ef 100644 --- a/src/views/discover/MusicView.vue +++ b/src/views/discover/MusicView.vue @@ -1,7 +1,81 @@ + + diff --git a/src/views/discover/__tests__/MusicView.spec.ts b/src/views/discover/__tests__/MusicView.spec.ts new file mode 100644 index 00000000..deace768 --- /dev/null +++ b/src/views/discover/__tests__/MusicView.spec.ts @@ -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: '
{{ JSON.stringify(params) }}
', +}) + +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') + }) +})