feat(music): support MusicBrainz in recognition test

- add MusicBrainz to recognition source options with music result display
- hide custom words input when MusicBrainz is selected (music ignores words)
- link native MusicBrainz recording/release-group detail page
This commit is contained in:
jxxghp
2026-08-10 23:30:31 +08:00
parent b8e454114a
commit 59a296d429
5 changed files with 141 additions and 13 deletions

View File

@@ -1729,7 +1729,7 @@ export default {
recognizing: 'Recognizing...',
recognizeAgain: 'Recognize Again',
title: 'Title',
titleHint: 'Enter a torrent name, release title, or file name',
titleHint: 'Enter a torrent name, release title, or file name; audio file names are recognized as music',
subtitle: 'Subtitle',
subtitleHint: 'Optional torrent description, alias, or release details to improve recognition accuracy',
source: 'Recognition Source',
@@ -2779,6 +2779,7 @@ export default {
douban: 'Douban',
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
},
},
},

View File

@@ -1719,7 +1719,7 @@ export default {
recognizing: '识别中...',
recognizeAgain: '重新识别',
title: '标题',
titleHint: '输入种子名、发布标题或文件名',
titleHint: '输入种子名、发布标题或文件名,音频文件名将按音乐识别',
subtitle: '副标题',
subtitleHint: '可选,补充种子描述、别名或发布信息以提高识别准确度',
source: '识别数据源',
@@ -2728,6 +2728,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
},
},
},

View File

@@ -1718,7 +1718,7 @@ export default {
recognizing: '識別中...',
recognizeAgain: '重新識別',
title: '標題',
titleHint: '輸入種子名、發佈標題或文件名',
titleHint: '輸入種子名、發佈標題或文件名,音頻文件名將按音樂識別',
subtitle: '副標題',
subtitleHint: '可選,補充種子描述、別名或發佈信息以提高識別準確度',
source: '識別數據源',
@@ -2727,6 +2727,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
},
},
},

View File

@@ -27,6 +27,7 @@ interface MediaIdentity {
interface MediaSourceDisplay {
icon?: string
iconColor?: string
image?: string
key: string
label: string
@@ -43,6 +44,7 @@ const MEDIA_SOURCE_LABELS: Record<string, string> = {
anilist: 'AniList',
bangumi: 'Bangumi',
douban: 'Douban',
musicbrainz: 'MusicBrainz',
themoviedb: 'TheMovieDb',
}
@@ -52,6 +54,12 @@ const MEDIA_SOURCE_LOGOS: Record<string, string> = {
themoviedb: getLogoUrl('tmdb'),
}
// 无专属 logo 的数据源使用品牌色图标展示
const MEDIA_SOURCE_ICONS: Record<string, { icon: string; color: string }> = {
anilist: { icon: 'mdi-alpha-a-circle', color: '#02a9ff' },
musicbrainz: { icon: 'mdi-album', color: '#eb743b' },
}
const NAME_TEST_TITLE_HISTORY_KEY = 'MP_NAME_TEST_TITLE_HISTORY'
const NAME_TEST_TITLE_HISTORY_LIMIT = 5
@@ -66,6 +74,7 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
])
// 获取后台默认识别数据源未知值兼容回退到TheMovieDb。
@@ -88,6 +97,9 @@ const nameTestForm = reactive<NameTestForm>({
source: getDefaultMediaSource(),
})
// MusicBrainz 仅支持音乐识别,音乐不应用自定义识别词,隐藏输入区避免误导
const showCustomWords = computed(() => nameTestForm.source !== 'musicbrainz')
/** 从本地存储读取最近使用的识别标题。 */
function loadTitleHistory() {
try {
@@ -140,17 +152,36 @@ const savingCustomWords = ref(false)
const metaInfo = computed(() => nameTestResult.value?.meta_info)
const mediaInfo = computed(() => nameTestResult.value?.media_info)
const isRecognized = computed(() => Boolean(metaInfo.value?.name))
// 音乐识别的元信息没有 name 字段,依靠 title 判断识别是否成功
const isMusicResult = computed(() => mediaInfo.value?.type === '音乐' || metaInfo.value?.type === '音乐')
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]
if (metaInfo.value?.season_episode) parts.push(metaInfo.value.season_episode)
if (isMusicResult.value) {
// 音乐结果没有季集信息,展示艺术家和专辑辅助确认
const artistText = mediaInfo.value?.artist || metaInfo.value?.artist
if (artistText) parts.push(artistText)
if (mediaInfo.value?.album) parts.push(mediaInfo.value.album)
} else if (metaInfo.value?.season_episode) {
parts.push(metaInfo.value.season_episode)
}
return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
})
const mediaClassification = computed(() => {
return [mediaInfo.value?.type || metaInfo.value?.type, mediaInfo.value?.category].filter(Boolean).join(' · ') || '-'
})
const resourceChips = computed(() => {
if (isMusicResult.value) {
return [
mediaInfo.value?.music_type,
metaInfo.value?.audio_format,
metaInfo.value?.audio_specs,
mediaInfo.value?.category,
].filter(Boolean) as string[]
}
return [
metaInfo.value?.web_source,
metaInfo.value?.edition,
@@ -186,6 +217,9 @@ function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string)
return `https://bgm.tv/subject/${encodedId}`
case 'anilist':
return `https://anilist.co/anime/${encodedId}`
case 'musicbrainz':
// MusicBrainz 各实体共用 UUID优先使用识别结果自带的详情页地址
return media.detail_link || `https://musicbrainz.org/recording/${encodedId}`
default:
return undefined
}
@@ -209,9 +243,11 @@ function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value))
const recognizedMediaSource = computed<MediaSourceDisplay>(() => {
const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source
const iconInfo = MEDIA_SOURCE_ICONS[sourceKey]
return {
icon: sourceKey === 'anilist' ? 'mdi-alpha-a-circle' : undefined,
icon: iconInfo?.icon,
iconColor: iconInfo?.color,
image: MEDIA_SOURCE_LOGOS[sourceKey],
key: sourceKey,
label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey,
@@ -227,10 +263,14 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
{
icon: 'mdi-puzzle-check-outline',
title: t('nameTest.steps.meta.title'),
value:
[metaInfo.value?.name, metaInfo.value?.resource_term, metaInfo.value?.release_group]
.filter(Boolean)
.join(' · ') || '-',
value: isMusicResult.value
? // 音乐元信息展示曲名、艺术家、专辑和音频格式
[metaInfo.value?.title, metaInfo.value?.artist, metaInfo.value?.album, metaInfo.value?.audio_format]
.filter(Boolean)
.join(' · ') || '-'
: [metaInfo.value?.name, metaInfo.value?.resource_term, metaInfo.value?.release_group]
.filter(Boolean)
.join(' · ') || '-',
},
{
icon: 'mdi-shape-outline',
@@ -293,7 +333,8 @@ async function nameTest() {
params: {
title: nameTestForm.title,
subtitle: nameTestForm.subtitle,
custom_words: nameTestForm.customWords?.trim() || undefined,
// 音乐识别不应用识别词,隐藏状态下不随请求携带
custom_words: showCustomWords.value ? nameTestForm.customWords?.trim() || undefined : undefined,
source: nameTestForm.source,
},
})
@@ -383,7 +424,7 @@ async function saveCustomWords() {
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VCol v-if="showCustomWords" cols="12" class="shortcut-form-col">
<VTextarea
v-model="nameTestForm.customWords"
:label="t('nameTest.customWords')"
@@ -513,7 +554,7 @@ async function saveCustomWords() {
<VIcon
v-else-if="step.source.icon"
class="media-source-logo"
color="#02a9ff"
:color="step.source.iconColor || '#02a9ff'"
:icon="step.source.icon"
/>
<span>{{ step.source.label }}</span>

View File

@@ -36,6 +36,7 @@ vi.mock('vue-toastification', () => ({
interface RecognizedMedia {
category?: string
detail_link?: string
media_id: string
source: string
title: string
@@ -99,6 +100,18 @@ describe('NameTestView media identity', () => {
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
'https://anilist.co/anime/154587',
],
[
'MusicBrainz',
{
detail_link: 'https://musicbrainz.org/recording/8f97b17d-1234-4abc-9def-1234567890ab',
media_id: '8f97b17d-1234-4abc-9def-1234567890ab',
source: 'musicbrainz',
title: '测试单曲',
type: '音乐',
year: '2026',
},
'https://musicbrainz.org/recording/8f97b17d-1234-4abc-9def-1234567890ab',
],
])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
await renderRecognizedMedia(media)
@@ -129,6 +142,77 @@ describe('NameTestView media identity', () => {
expect(classificationStep).toHaveTextContent('媒体分类电视剧 · 动漫')
})
it('renders music recognition results from music meta info without name field', async () => {
mocks.apiGet.mockResolvedValueOnce({
media_info: {
album: '叶惠美',
artist: '周杰伦',
category: 'Single',
media_id: '8f97b17d-1234-4abc-9def-1234567890ab',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
},
meta_info: {
apply_words: [],
artist: '周杰伦',
audio_format: 'FLAC',
org_string: '周杰伦 - 晴天.flac',
title: '晴天',
type: '音乐',
},
torrent_info: {},
})
await renderWithProviders(NameTestView, {
initialState: {
globalSettings: {
data: { RECOGNIZE_SOURCE: 'musicbrainz' },
},
},
})
const user = userEvent.setup()
await user.type(screen.getByLabelText('标题'), '周杰伦 - 晴天.flac')
await user.click(screen.getByRole('button', { name: '识别' }))
// 音乐元信息无 name 字段,仍应按识别成功展示曲名和来源
await screen.findByRole('link', { name: '8f97b17d-1234-4abc-9def-1234567890ab' })
expect(screen.getByText('晴天')).toBeInTheDocument()
expect(screen.getByText('2003 · 周杰伦 · 叶惠美')).toBeInTheDocument()
const sourceDisplay = screen.getByTestId('recognition-source')
expect(sourceDisplay).toHaveAttribute('data-source', 'musicbrainz')
expect(sourceDisplay).toHaveAccessibleName('MusicBrainz')
const metaStep = screen.getByText('元信息').closest('.pipeline-step')
expect(metaStep).toHaveTextContent('晴天 · 周杰伦 · FLAC')
})
it('hides the custom words input when MusicBrainz source is selected', async () => {
await renderWithProviders(NameTestView, {
initialState: {
globalSettings: {
data: { RECOGNIZE_SOURCE: 'themoviedb' },
},
},
})
const user = userEvent.setup()
// 默认影视数据源时识别词输入区可见
expect(screen.getByLabelText('识别词')).toBeInTheDocument()
await user.click(screen.getByLabelText('识别数据源'))
await user.click(await screen.findByRole('option', { name: 'MusicBrainz' }))
// 音乐识别不应用识别词,输入区应隐藏
expect(screen.queryByLabelText('识别词')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('识别数据源'))
await user.click(await screen.findByRole('option', { name: 'TheMovieDb' }))
// 切回影视数据源后输入区恢复
expect(screen.getByLabelText('识别词')).toBeInTheDocument()
})
it('closes the recognition dialog before navigating to the media detail', async () => {
const eventOrder: string[] = []
const onClose = vi.fn(() => eventOrder.push('close'))