mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-16 19:24:31 +08:00
feat(music): 支持多源识别与刮削交互
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {})
|
||||
export type MediaDataSource =
|
||||
'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | 'theaudiodb' | 'doubanmusic' | (string & {})
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
@@ -370,7 +371,7 @@ export interface DownloadHistory {
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz
|
||||
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz、theaudiodb、doubanmusic
|
||||
source?: string
|
||||
// 类型 电影、电视剧、音乐、合集
|
||||
type?: string
|
||||
|
||||
@@ -113,6 +113,12 @@ const sourceIconDict: { [key: string]: any } = {
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
}
|
||||
|
||||
const musicSourceIconDict: Record<string, { color: string; icon: string }> = {
|
||||
musicbrainz: { color: '#eb743b', icon: 'mdi-music-circle' },
|
||||
theaudiodb: { color: '#35a7a0', icon: 'mdi-music-box-multiple' },
|
||||
doubanmusic: { color: '#00b51d', icon: 'mdi-music-circle' },
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
const mediaCardRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -665,9 +671,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VIcon
|
||||
v-else-if="props.media?.source === 'musicbrainz'"
|
||||
color="#eb743b"
|
||||
icon="mdi-music-circle"
|
||||
v-else-if="musicSourceIconDict[props.media?.source]"
|
||||
:color="musicSourceIconDict[props.media.source].color"
|
||||
:icon="musicSourceIconDict[props.media.source].icon"
|
||||
size="24"
|
||||
/>
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
|
||||
@@ -50,7 +50,7 @@ const entityMeta = computed(() => {
|
||||
return entities[props.music?.music_type || 'recording']
|
||||
})
|
||||
|
||||
// 卡片上展示的元数据,只保留 MusicBrainz 实际返回的字段
|
||||
// 卡片只展示标准音乐模型中已映射的稳定字段
|
||||
const metaItems = computed(() => {
|
||||
const items: { hideOnNarrow?: boolean; icon: string; label: string }[] = []
|
||||
const category = props.music?.category || props.music?.album_type
|
||||
|
||||
@@ -32,7 +32,15 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 可选的媒体数据源
|
||||
const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = ['themoviedb', 'douban', 'bangumi', 'anilist', 'musicbrainz']
|
||||
const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
|
||||
'themoviedb',
|
||||
'douban',
|
||||
'bangumi',
|
||||
'anilist',
|
||||
'musicbrainz',
|
||||
'theaudiodb',
|
||||
'doubanmusic',
|
||||
]
|
||||
|
||||
// 当前识别类型:优先使用媒体自身的数据源,否则使用全局识别来源
|
||||
const mediaSource = computed<MediaDataSource>(() => {
|
||||
@@ -71,11 +79,11 @@ const showAdvancedOptions = ref(false)
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(undefined)
|
||||
|
||||
// 音乐媒体自带 MusicBrainz ID,打开对话框时预填到高级选项中辅助识别
|
||||
// 音乐媒体自带来源原生 ID,打开对话框时预填到高级选项中辅助识别。
|
||||
watch(
|
||||
() => props.media,
|
||||
media => {
|
||||
if (media && media.source === 'musicbrainz' && media.media_id) {
|
||||
if (media?.source && SUPPORTED_MEDIA_SOURCES.includes(media.source) && media.media_id) {
|
||||
mediaId.value = media.media_id
|
||||
}
|
||||
},
|
||||
@@ -90,6 +98,8 @@ const mediaIdLabel = computed(() => {
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
musicbrainz: 'MusicBrainz ID',
|
||||
theaudiodb: 'TheAudioDB ID',
|
||||
doubanmusic: t('dialog.reorganize.doubanId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
@@ -58,6 +58,8 @@ const mediaIdLabel = computed(() => {
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
musicbrainz: 'MusicBrainz ID',
|
||||
theaudiodb: 'TheAudioDB ID',
|
||||
doubanmusic: t('dialog.reorganize.doubanId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@ 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: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
])
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
@@ -40,6 +43,9 @@ const mediaIdLabel = computed(() => {
|
||||
douban: t('setting.cache.reidentifyDialog.doubanId'),
|
||||
bangumi: t('setting.cache.reidentifyDialog.bangumiId'),
|
||||
anilist: t('setting.cache.reidentifyDialog.anilistId'),
|
||||
musicbrainz: 'MusicBrainz ID',
|
||||
theaudiodb: 'TheAudioDB ID',
|
||||
doubanmusic: t('setting.cache.reidentifyDialog.doubanId'),
|
||||
}
|
||||
return labels[mediaSource.value] || t('setting.cache.reidentifyDialog.mediaId')
|
||||
})
|
||||
|
||||
@@ -23,7 +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'
|
||||
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -49,7 +49,9 @@ 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' },
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
])
|
||||
|
||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||
@@ -353,6 +355,8 @@ const mediaIdLabel = computed(() => {
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
musicbrainz: 'MusicBrainz ID',
|
||||
theaudiodb: 'TheAudioDB ID',
|
||||
doubanmusic: t('dialog.reorganize.doubanId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
@@ -469,11 +473,11 @@ watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, sourc
|
||||
episodeGroups.value = []
|
||||
})
|
||||
|
||||
// 音乐目前只使用 MusicBrainz 原生身份,选择音乐类型时自动切换识别来源。
|
||||
// 音乐默认使用 MusicBrainz;已显式选择其它音乐源时保留用户选择。
|
||||
watch(
|
||||
() => transferForm.type_name,
|
||||
typeName => {
|
||||
if (typeName === '音乐' && transferForm.media_source !== 'musicbrainz') {
|
||||
if (typeName === '音乐' && !isMusicMediaSource(transferForm.media_source)) {
|
||||
transferForm.media_source = 'musicbrainz'
|
||||
}
|
||||
},
|
||||
@@ -487,7 +491,7 @@ watch(
|
||||
transferForm.media_id = null
|
||||
mediaSelectorDialog.value = false
|
||||
}
|
||||
if (source === 'musicbrainz' && transferForm.type_name !== '音乐') {
|
||||
if (isMusicMediaSource(source) && transferForm.type_name !== '音乐') {
|
||||
transferForm.type_name = '音乐'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -29,7 +29,9 @@ 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' },
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
])
|
||||
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -57,6 +59,8 @@ const mediaIdLabel = computed(() => {
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
musicbrainz: 'MusicBrainz ID',
|
||||
theaudiodb: 'TheAudioDB ID',
|
||||
doubanmusic: t('dialog.reorganize.doubanId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
@@ -111,11 +115,11 @@ function submitScrape() {
|
||||
watch(mediaSource, () => {
|
||||
mediaId.value = null
|
||||
mediaSelectorDialog.value = false
|
||||
if (mediaSource.value === 'musicbrainz') mediaType.value = '音乐'
|
||||
if (isMusicMediaSource(mediaSource.value)) mediaType.value = '音乐'
|
||||
})
|
||||
|
||||
watch(mediaType, type => {
|
||||
if (type === '音乐' && mediaSource.value !== 'musicbrainz') mediaSource.value = 'musicbrainz'
|
||||
if (type === '音乐' && !isMusicMediaSource(mediaSource.value)) mediaSource.value = 'musicbrainz'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ const searchOverlayProps = computed(() =>
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz'
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | 'theaudiodb' | 'doubanmusic'
|
||||
type MediaSearchType = 'media' | 'music' | 'collection' | 'person'
|
||||
|
||||
interface MediaSearchSourceOption {
|
||||
@@ -197,10 +197,20 @@ const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSou
|
||||
name: 'MusicBrainz',
|
||||
value: 'musicbrainz' as const,
|
||||
}
|
||||
const theaudiodb = {
|
||||
label: 'TheAudioDB',
|
||||
name: 'TheAudioDB',
|
||||
value: 'theaudiodb' as const,
|
||||
}
|
||||
const doubanmusic = {
|
||||
label: t('setting.cache.recognitionSource.doubanmusic'),
|
||||
name: t('setting.cache.recognitionSource.doubanmusic'),
|
||||
value: 'doubanmusic' as const,
|
||||
}
|
||||
|
||||
return {
|
||||
media: [themoviedb, douban, bangumi, anilist],
|
||||
music: [musicbrainz],
|
||||
music: [musicbrainz, theaudiodb, doubanmusic],
|
||||
collection: [themoviedb],
|
||||
person: [themoviedb, douban],
|
||||
}
|
||||
@@ -453,9 +463,13 @@ function searchMedia(searchType: MediaSearchType) {
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
if (searchType === 'music') {
|
||||
const sources = selectedMediaSearchSources.music
|
||||
router.push({
|
||||
path: '/music',
|
||||
query: { query: searchWord.value },
|
||||
query: {
|
||||
query: searchWord.value,
|
||||
...(sources.length > 0 ? { source: sources.join(',') } : {}),
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
return
|
||||
|
||||
@@ -94,4 +94,24 @@ describe('ScrapeDialog', () => {
|
||||
type_name: '音乐',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicitly selected TheAudioDB source for music scraping', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('themoviedb', [
|
||||
{ name: 'Yellow.flac', path: '/music/Yellow.flac', storage: 'local', type: 'file' },
|
||||
])
|
||||
|
||||
await user.click(screen.getByLabelText('类型'))
|
||||
await user.click(await screen.findByRole('option', { name: '音乐' }))
|
||||
await user.click(screen.getByLabelText('数据源'))
|
||||
await user.click(await screen.findByRole('option', { name: 'TheAudioDB' }))
|
||||
await user.type(screen.getByLabelText('TheAudioDB ID'), '32793500')
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
expect(events.scrape).toHaveBeenCalledWith({
|
||||
media_source: 'theaudiodb',
|
||||
media_id: '32793500',
|
||||
type_name: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,14 +120,26 @@ describe('SearchBarDialog media source selection', () => {
|
||||
|
||||
await user.type(input, '芙莉莲')
|
||||
const mediaItem = getSearchItem('电影、电视剧')
|
||||
const musicItem = getSearchItem('音乐')
|
||||
const collectionItem = getSearchItem('系列合集')
|
||||
const personItem = getSearchItem('演员')
|
||||
|
||||
const mediaGroup = within(mediaItem).getByRole('group', { name: '电影、电视剧搜索数据源' })
|
||||
const musicGroup = within(musicItem).getByRole('group', { name: '音乐搜索数据源' })
|
||||
const collectionGroup = within(collectionItem).getByRole('group', { name: '系列合集搜索数据源' })
|
||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||
|
||||
expect(within(mediaGroup).getAllByRole('button')).toHaveLength(4)
|
||||
expect(within(musicGroup).getAllByRole('button')).toHaveLength(3)
|
||||
expect(within(musicGroup).getByRole('button', { name: '使用 MusicBrainz 搜索' })).toHaveClass(
|
||||
'media-source-button--active',
|
||||
)
|
||||
expect(within(musicGroup).getByRole('button', { name: '使用 TheAudioDB 搜索' })).not.toHaveClass(
|
||||
'media-source-button--active',
|
||||
)
|
||||
expect(within(musicGroup).getByRole('button', { name: '使用 豆瓣音乐 搜索' })).not.toHaveClass(
|
||||
'media-source-button--active',
|
||||
)
|
||||
expect(within(collectionGroup).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(personGroup).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' })).toHaveClass(
|
||||
@@ -159,6 +171,27 @@ describe('SearchBarDialog media source selection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('searches music with an explicitly selected alternate source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, 'Coldplay')
|
||||
const musicItem = getSearchItem('音乐')
|
||||
const musicGroup = within(musicItem).getByRole('group', { name: '音乐搜索数据源' })
|
||||
await user.click(within(musicGroup).getByRole('button', { name: '使用 TheAudioDB 搜索' }))
|
||||
await user.click(within(musicGroup).getByRole('button', { name: '使用 MusicBrainz 搜索' }))
|
||||
await user.click(musicItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.path).toBe('/music')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
query: 'Coldplay',
|
||||
source: 'theaudiodb',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the bundled music icon in the music search action', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderSearchBar()
|
||||
@@ -193,4 +226,4 @@ describe('SearchBarDialog media source selection', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaDataSource, MediaInfo } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isMusicMediaSource } from '@/utils/mediaId'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -61,7 +62,7 @@ async function searchMedias() {
|
||||
const result: MediaInfo[] = await api.get('media/search', {
|
||||
params: {
|
||||
title: searchKeyword,
|
||||
type: props.type === 'musicbrainz' ? 'music' : 'media',
|
||||
type: isMusicMediaSource(props.type) ? 'music' : 'media',
|
||||
page: 1,
|
||||
count: 20,
|
||||
source: props.type,
|
||||
|
||||
@@ -2795,6 +2795,8 @@ export default {
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
doubanmusic: 'Douban Music',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2744,6 +2744,8 @@ export default {
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
doubanmusic: '豆瓣音乐',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2743,6 +2743,8 @@ export default {
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
doubanmusic: '豆瓣音樂',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,6 +125,20 @@ describe('music page', () => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards an alternate music source from the route', async () => {
|
||||
await renderWithProviders(MusicPage, {
|
||||
initialRoute: '/music?query=Coldplay&source=theaudiodb',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { NoDataFound: true, VPageContentTitle: true } },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
|
||||
params: { type: 'music', count: 30, title: 'Coldplay', source: 'theaudiodb' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows album, artist, release date and duration on the result card', async () => {
|
||||
await renderMusicPage()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import MusicAlbumView from '@/views/discover/MusicAlbumView.vue'
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
|
||||
// MusicBrainz Release Group ID
|
||||
// 音乐数据源原生专辑 ID
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
|
||||
@@ -4,7 +4,7 @@ import MusicArtistView from '@/views/discover/MusicArtistView.vue'
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
|
||||
// MusicBrainz Artist ID
|
||||
// 音乐数据源原生艺术家 ID
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
|
||||
@@ -4,7 +4,7 @@ import MusicDetailView from '@/views/discover/MusicDetailView.vue'
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
|
||||
// MusicBrainz 单曲 ID
|
||||
// 音乐数据源原生单曲 ID
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
|
||||
@@ -14,6 +14,7 @@ const results = ref<MediaInfo[]>([])
|
||||
|
||||
// 搜索入口统一在全局搜索,本页只消费路由关键词
|
||||
const query = computed(() => route.query.query?.toString().trim() || '')
|
||||
const source = computed(() => route.query.source?.toString().trim() || '')
|
||||
|
||||
/** 调用统一音乐元数据接口搜索候选。 */
|
||||
async function searchMusic() {
|
||||
@@ -26,7 +27,13 @@ async function searchMusic() {
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
try {
|
||||
results.value = (await api.get('media/search', { params: { title: query.value, type: 'music', count: 30 } })) || []
|
||||
const params: Record<string, string | number> = {
|
||||
title: query.value,
|
||||
type: 'music',
|
||||
count: 30,
|
||||
}
|
||||
if (source.value) params.source = source.value
|
||||
results.value = (await api.get('media/search', { params })) || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
results.value = []
|
||||
@@ -35,7 +42,7 @@ async function searchMusic() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, searchMusic, { immediate: true })
|
||||
watch([query, source], searchMusic, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
15
src/utils/__tests__/mediaId.spec.ts
Normal file
15
src/utils/__tests__/mediaId.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('media source identity utils', () => {
|
||||
it.each(['musicbrainz', 'theaudiodb', 'doubanmusic'] as const)('recognizes %s as a music source', source => {
|
||||
expect(isMusicMediaSource(source)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses UUIDs for MusicBrainz and numeric IDs for alternate music sources', () => {
|
||||
expect(isValidMediaSourceId('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
|
||||
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
|
||||
expect(isValidMediaSourceId('1401853', 'doubanmusic')).toBe(true)
|
||||
expect(isValidMediaSourceId('not-a-number', 'theaudiodb')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { MediaDataSource } from '@/api/types'
|
||||
|
||||
const MUSICBRAINZ_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
export const MUSIC_MEDIA_SOURCES = ['musicbrainz', 'theaudiodb', 'doubanmusic'] as const
|
||||
|
||||
/** 按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它现有来源使用数字 ID。 */
|
||||
/** 判断当前请求来源是否为内置音乐元数据源。 */
|
||||
export function isMusicMediaSource(source?: MediaDataSource): boolean {
|
||||
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
|
||||
}
|
||||
|
||||
/** 按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它内置来源使用数字 ID。 */
|
||||
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
|
||||
const normalized = value?.toString().trim()
|
||||
if (!normalized) return true
|
||||
|
||||
@@ -62,6 +62,16 @@ export function getMusicSource(item: MusicRouteTarget): string | undefined {
|
||||
return item.source || item.media_source
|
||||
}
|
||||
|
||||
/** 返回内置音乐元数据源的用户可见名称。 */
|
||||
export function getMusicSourceLabel(source?: string, translate?: (key: string) => string): string {
|
||||
const labels: Record<string, string> = {
|
||||
musicbrainz: 'MusicBrainz',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
doubanmusic: translate?.('setting.cache.recognitionSource.doubanmusic') || '豆瓣音乐',
|
||||
}
|
||||
return (source && labels[source]) || source || 'MusicBrainz'
|
||||
}
|
||||
|
||||
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
|
||||
export function getMusicKey(item: MusicRouteTarget): string {
|
||||
const source = getMusicSource(item) || 'music'
|
||||
|
||||
@@ -10,13 +10,19 @@ import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSu
|
||||
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
|
||||
import {
|
||||
buildMusicArtistRoute,
|
||||
buildMusicResourceRoute,
|
||||
formatMusicDuration,
|
||||
getMusicArtistLinks,
|
||||
getMusicSourceLabel,
|
||||
} from '@/utils/music'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
// MusicBrainz Release Group ID
|
||||
// 音乐数据源原生专辑 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
@@ -39,6 +45,7 @@ const artistLinks = computed(() => getMusicArtistLinks(album.value))
|
||||
|
||||
// 关联浏览统一以首个艺术家为入口
|
||||
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
|
||||
// 专辑订阅复用影视订阅链,年份需要按订阅表的字符串格式传递
|
||||
const albumMedia = computed<MediaInfo | undefined>(() => {
|
||||
@@ -181,7 +188,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
<span class="music-fact-value">{{ album.rating_votes }}</span>
|
||||
</div>
|
||||
<div class="music-fact music-fact--last">
|
||||
<span>MusicBrainz ID</span>
|
||||
<span>{{ sourceLabel }} ID</span>
|
||||
<span class="music-fact-value music-fact-id">{{ album.media_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -195,7 +202,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
<div v-if="album.detail_link" class="mt-4">
|
||||
<a :href="album.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">MusicBrainz</span>
|
||||
<span class="ms-1">{{ sourceLabel }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<template v-if="album.tracks?.length">
|
||||
@@ -223,13 +230,13 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/albums`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:title="t('music.artistAlbums')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<div v-if="primaryArtistId && props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
|
||||
</div>
|
||||
</MusicDetailLayout>
|
||||
|
||||
@@ -8,11 +8,12 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
||||
import { getMusicSourceLabel } from '@/utils/music'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
// MusicBrainz Artist ID
|
||||
// 音乐数据源原生艺术家 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
@@ -27,6 +28,7 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
|
||||
const isRefreshed = ref(false)
|
||||
const artist = ref<MusicArtistInfo>()
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
|
||||
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
|
||||
if (!artist.value?.name) return undefined
|
||||
@@ -75,7 +77,7 @@ async function loadArtistDetail() {
|
||||
|
||||
/** 返回指定专辑类型的浏览列表路由。 */
|
||||
function getAlbumsBrowseRoute(albumType: string, title: string) {
|
||||
return `/browse/music/artist/${props.mediaid}/albums?title=${encodeURIComponent(title)}&album_type=${albumType}`
|
||||
return `/browse/music/artist/${props.mediaid}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(title)}&album_type=${albumType}`
|
||||
}
|
||||
|
||||
watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true })
|
||||
@@ -123,7 +125,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
|
||||
<span class="music-fact-value">{{ artist.aliases.slice(0, 6).join('、') }}</span>
|
||||
</div>
|
||||
<div class="music-fact music-fact--last">
|
||||
<span>MusicBrainz ID</span>
|
||||
<span>{{ sourceLabel }} ID</span>
|
||||
<span class="music-fact-value music-fact-id">{{ artist.media_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,7 +145,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
|
||||
class="music-external-link"
|
||||
>
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">MusicBrainz</span>
|
||||
<span class="ms-1">{{ sourceLabel }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-for="(link, name) in artist.external_links || {}"
|
||||
@@ -161,13 +163,13 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
|
||||
|
||||
<div v-for="section in albumSections" :key="section.type" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${props.mediaid}/albums?album_type=${section.type}`"
|
||||
:apipath="`music/artist/${props.mediaid}/albums?source=${encodeURIComponent(props.source)}&album_type=${section.type}`"
|
||||
:linkurl="getAlbumsBrowseRoute(section.type, section.title)"
|
||||
:title="section.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="music-section">
|
||||
<div v-if="props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${props.mediaid}/related`" :title="t('music.relatedArtists')" />
|
||||
</div>
|
||||
</MusicDetailLayout>
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
buildMusicResourceRoute,
|
||||
formatMusicDuration,
|
||||
getMusicArtistLinks,
|
||||
getMusicSourceLabel,
|
||||
} from '@/utils/music'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
// MusicBrainz 单曲 ID
|
||||
// 音乐数据源原生单曲 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
@@ -47,7 +48,7 @@ const artistLinks = computed(() => getMusicArtistLinks(music.value))
|
||||
// 关联浏览统一以首个艺术家为入口,MusicBrainz 的主署名艺术家排在最前
|
||||
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
|
||||
|
||||
// 头部属性行,只展示 MusicBrainz 实际返回的字段
|
||||
// 头部属性行只展示各音乐源已映射到标准模型的字段
|
||||
const attributes = computed(() => {
|
||||
const values: string[] = []
|
||||
if (music.value?.category) values.push(music.value.category)
|
||||
@@ -60,6 +61,7 @@ const attributes = computed(() => {
|
||||
|
||||
// 专辑内除当前单曲外仍然展示完整曲目,方便对照曲序
|
||||
const albumTracks = computed(() => album.value?.tracks ?? [])
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
|
||||
function getSubscribeStatusKey() {
|
||||
return `${getMediaSubscribeId(music.value)}::all`
|
||||
@@ -213,7 +215,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
<span class="music-fact-value">{{ music.listen_count.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="music-fact music-fact--last">
|
||||
<span>MusicBrainz ID</span>
|
||||
<span>{{ sourceLabel }} ID</span>
|
||||
<span class="music-fact-value music-fact-id">{{ music.media_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -227,7 +229,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
<div v-if="music.detail_link" class="mt-4">
|
||||
<a :href="music.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">MusicBrainz</span>
|
||||
<span class="ms-1">{{ sourceLabel }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -237,14 +239,14 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<div v-if="primaryArtistId && props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
|
||||
</div>
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/albums`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:title="t('music.artistAlbums')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,12 +39,15 @@ const mediaCategories = ref<{ [key: string]: any }>({})
|
||||
const $toast = useToast()
|
||||
|
||||
// 数据源
|
||||
const sourceItems = [
|
||||
{ 'title': 'TheMovieDb', 'value': 'themoviedb' },
|
||||
{ 'title': '豆瓣', 'value': 'douban' },
|
||||
{ 'title': 'Bangumi', 'value': 'bangumi' },
|
||||
{ 'title': 'AniList', 'value': 'anilist' },
|
||||
]
|
||||
const sourceItems = computed(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ 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: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
])
|
||||
|
||||
// 存储选项(排除已添加的)
|
||||
const storageOptions = computed(() => {
|
||||
|
||||
@@ -52,6 +52,18 @@ const mediaSourcesDict = [
|
||||
title: 'AniList',
|
||||
value: 'anilist',
|
||||
},
|
||||
{
|
||||
title: 'MusicBrainz',
|
||||
value: 'musicbrainz',
|
||||
},
|
||||
{
|
||||
title: 'TheAudioDB',
|
||||
value: 'theaudiodb',
|
||||
},
|
||||
{
|
||||
title: '豆瓣音乐',
|
||||
value: 'doubanmusic',
|
||||
},
|
||||
]
|
||||
|
||||
// 当前选中的媒体信息数据源
|
||||
@@ -189,7 +201,13 @@ async function loadSystemSettings() {
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([querySites(), queryFilterRuleGroups(), querySelectedSites(), loadSearchSetting(), loadSystemSettings()])
|
||||
await Promise.all([
|
||||
querySites(),
|
||||
queryFilterRuleGroups(),
|
||||
querySelectedSites(),
|
||||
loadSearchSetting(),
|
||||
loadSystemSettings(),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -100,6 +101,20 @@ describe('mounted local disk empty directory cleanup setting', () => {
|
||||
expect(await screen.findByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('lists every supported music metadata source for scraping', async () => {
|
||||
mockSettings(null)
|
||||
await renderDirectorySettings()
|
||||
|
||||
const organizeCard = (await screen.findByText('整理 & 刮削')).closest('.v-card')
|
||||
expect(organizeCard).not.toBeNull()
|
||||
const user = userEvent.setup()
|
||||
await user.click(within(organizeCard as HTMLElement).getByLabelText('刮削数据源'))
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'MusicBrainz' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'TheAudioDB' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '豆瓣音乐' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves the disabled value with the organization settings', async () => {
|
||||
mockSettings(true)
|
||||
await renderDirectorySettings()
|
||||
|
||||
@@ -9,6 +9,7 @@ import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isMusicMediaSource } from '@/utils/mediaId'
|
||||
|
||||
interface PipelineStep {
|
||||
icon: string
|
||||
@@ -44,7 +45,9 @@ const MEDIA_SOURCE_LABELS: Record<string, string> = {
|
||||
anilist: 'AniList',
|
||||
bangumi: 'Bangumi',
|
||||
douban: 'Douban',
|
||||
doubanmusic: '豆瓣音乐',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
themoviedb: 'TheMovieDb',
|
||||
}
|
||||
|
||||
@@ -58,6 +61,8 @@ const MEDIA_SOURCE_LOGOS: Record<string, string> = {
|
||||
const MEDIA_SOURCE_ICONS: Record<string, { icon: string; color: string }> = {
|
||||
anilist: { icon: 'mdi-alpha-a-circle', color: '#02a9ff' },
|
||||
musicbrainz: { icon: 'mdi-album', color: '#eb743b' },
|
||||
theaudiodb: { icon: 'mdi-music-box-multiple', color: '#35a7a0' },
|
||||
doubanmusic: { icon: 'mdi-music-circle', color: '#00b51d' },
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -72,6 +77,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
|
||||
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
])
|
||||
|
||||
// 获取后台默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||
@@ -94,8 +101,8 @@ const nameTestForm = reactive<NameTestForm>({
|
||||
source: getDefaultMediaSource(),
|
||||
})
|
||||
|
||||
// MusicBrainz 仅支持音乐识别,音乐不应用自定义识别词,隐藏输入区避免误导
|
||||
const showCustomWords = computed(() => nameTestForm.source !== 'musicbrainz')
|
||||
// 音乐识别不应用影视自定义识别词,隐藏输入区避免误导。
|
||||
const showCustomWords = computed(() => !isMusicMediaSource(nameTestForm.source))
|
||||
|
||||
// 识别按钮状态
|
||||
const nameTestLoading = ref(false)
|
||||
@@ -116,9 +123,7 @@ const metaInfo = computed(() => nameTestResult.value?.meta_info)
|
||||
const mediaInfo = computed(() => nameTestResult.value?.media_info)
|
||||
// 音乐识别的元信息没有 name 字段,依靠 title 判断识别是否成功
|
||||
const isMusicResult = computed(() => mediaInfo.value?.type === '音乐' || metaInfo.value?.type === '音乐')
|
||||
const isRecognized = computed(() =>
|
||||
Boolean(metaInfo.value?.name || (isMusicResult.value && metaInfo.value?.title)),
|
||||
)
|
||||
const isRecognized = computed(() => Boolean(metaInfo.value?.name || (isMusicResult.value && metaInfo.value?.title)))
|
||||
const resultTitle = computed(() => mediaInfo.value?.title || metaInfo.value?.name || t('nameTest.unrecognized'))
|
||||
const resultSubtitle = computed(() => {
|
||||
const parts = [mediaInfo.value?.year || metaInfo.value?.year]
|
||||
@@ -182,6 +187,10 @@ function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string)
|
||||
case 'musicbrainz':
|
||||
// MusicBrainz 各实体共用 UUID,优先使用识别结果自带的详情页地址
|
||||
return media.detail_link || `https://musicbrainz.org/recording/${encodedId}`
|
||||
case 'theaudiodb':
|
||||
return media.detail_link || `https://www.theaudiodb.com/track/${encodedId}`
|
||||
case 'doubanmusic':
|
||||
return media.detail_link || `https://music.douban.com/subject/${encodedId}`
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -112,6 +112,28 @@ describe('NameTestView media identity', () => {
|
||||
},
|
||||
'https://musicbrainz.org/recording/8f97b17d-1234-4abc-9def-1234567890ab',
|
||||
],
|
||||
[
|
||||
'TheAudioDB',
|
||||
{
|
||||
media_id: '32793500',
|
||||
source: 'theaudiodb',
|
||||
title: 'Yellow',
|
||||
type: '音乐',
|
||||
year: '2000',
|
||||
},
|
||||
'https://www.theaudiodb.com/track/32793500',
|
||||
],
|
||||
[
|
||||
'豆瓣音乐',
|
||||
{
|
||||
media_id: '1401853',
|
||||
source: 'doubanmusic',
|
||||
title: '范特西',
|
||||
type: '音乐',
|
||||
year: '2001',
|
||||
},
|
||||
'https://music.douban.com/subject/1401853',
|
||||
],
|
||||
])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
|
||||
await renderRecognizedMedia(media)
|
||||
|
||||
@@ -207,6 +229,10 @@ describe('NameTestView media identity', () => {
|
||||
// 音乐识别不应用识别词,输入区应隐藏
|
||||
expect(screen.queryByLabelText('识别词')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByLabelText('识别数据源'))
|
||||
await user.click(await screen.findByRole('option', { name: 'TheAudioDB' }))
|
||||
expect(screen.queryByLabelText('识别词')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByLabelText('识别数据源'))
|
||||
await user.click(await screen.findByRole('option', { name: 'TheMovieDb' }))
|
||||
// 切回影视数据源后输入区恢复
|
||||
|
||||
Reference in New Issue
Block a user