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