feat(music): 支持多源识别与刮削交互

This commit is contained in:
jxxghp
2026-08-11 23:34:00 +08:00
parent df4d697845
commit 26fcda661d
31 changed files with 302 additions and 61 deletions
+9 -3
View File
@@ -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" />
+1 -1
View File
@@ -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
+13 -3
View File
@@ -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')
})
+9 -5
View File
@@ -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 = '音乐'
}
},
+8 -4
View File
@@ -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>
+17 -3
View File
@@ -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 -1
View File
@@ -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,