mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-03 06:26:51 +08:00
feat(music): 支持多源识别与刮削交互
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user