mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-14 01:55:57 +08:00
fix(v3): finish music discovery and detail flows
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: '电视剧' })
|
||||
|
||||
28
src/components/cards/__tests__/MediaInfoCard.spec.ts
Normal file
28
src/components/cards/__tests__/MediaInfoCard.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '剧集组',
|
||||
|
||||
@@ -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: '劇集組',
|
||||
|
||||
75
src/pages/__tests__/music-detail.spec.ts
Normal file
75
src/pages/__tests__/music-detail.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
@@ -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
148
src/pages/music-detail.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
11
src/utils/mediaId.ts
Normal 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
|
||||
|
||||
/** 按媒体数据源校验原生 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)
|
||||
}
|
||||
58
src/utils/music.ts
Normal file
58
src/utils/music.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
33
src/views/discover/__tests__/MusicView.spec.ts
Normal file
33
src/views/discover/__tests__/MusicView.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user