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
+2 -1
View File
@@ -1729,7 +1729,7 @@ export default {
recognizing: 'Recognizing...', recognizing: 'Recognizing...',
recognizeAgain: 'Recognize Again', recognizeAgain: 'Recognize Again',
title: 'Title', 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', subtitle: 'Subtitle',
subtitleHint: 'Optional torrent description, alias, or release details to improve recognition accuracy', subtitleHint: 'Optional torrent description, alias, or release details to improve recognition accuracy',
source: 'Recognition Source', source: 'Recognition Source',
@@ -2779,6 +2779,7 @@ export default {
douban: 'Douban', douban: 'Douban',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList', anilist: 'AniList',
musicbrainz: 'MusicBrainz',
}, },
}, },
}, },
+2 -1
View File
@@ -1719,7 +1719,7 @@ export default {
recognizing: '识别中...', recognizing: '识别中...',
recognizeAgain: '重新识别', recognizeAgain: '重新识别',
title: '标题', title: '标题',
titleHint: '输入种子名、发布标题或文件名', titleHint: '输入种子名、发布标题或文件名,音频文件名将按音乐识别',
subtitle: '副标题', subtitle: '副标题',
subtitleHint: '可选,补充种子描述、别名或发布信息以提高识别准确度', subtitleHint: '可选,补充种子描述、别名或发布信息以提高识别准确度',
source: '识别数据源', source: '识别数据源',
@@ -2728,6 +2728,7 @@ export default {
douban: '豆瓣', douban: '豆瓣',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList', anilist: 'AniList',
musicbrainz: 'MusicBrainz',
}, },
}, },
}, },
+2 -1
View File
@@ -1718,7 +1718,7 @@ export default {
recognizing: '識別中...', recognizing: '識別中...',
recognizeAgain: '重新識別', recognizeAgain: '重新識別',
title: '標題', title: '標題',
titleHint: '輸入種子名、發佈標題或文件名', titleHint: '輸入種子名、發佈標題或文件名,音頻文件名將按音樂識別',
subtitle: '副標題', subtitle: '副標題',
subtitleHint: '可選,補充種子描述、別名或發佈信息以提高識別準確度', subtitleHint: '可選,補充種子描述、別名或發佈信息以提高識別準確度',
source: '識別數據源', source: '識別數據源',
@@ -2727,6 +2727,7 @@ export default {
douban: '豆瓣', douban: '豆瓣',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList', anilist: 'AniList',
musicbrainz: 'MusicBrainz',
}, },
}, },
}, },
+49 -8
View File
@@ -27,6 +27,7 @@ interface MediaIdentity {
interface MediaSourceDisplay { interface MediaSourceDisplay {
icon?: string icon?: string
iconColor?: string
image?: string image?: string
key: string key: string
label: string label: string
@@ -43,6 +44,7 @@ const MEDIA_SOURCE_LABELS: Record<string, string> = {
anilist: 'AniList', anilist: 'AniList',
bangumi: 'Bangumi', bangumi: 'Bangumi',
douban: 'Douban', douban: 'Douban',
musicbrainz: 'MusicBrainz',
themoviedb: 'TheMovieDb', themoviedb: 'TheMovieDb',
} }
@@ -52,6 +54,12 @@ const MEDIA_SOURCE_LOGOS: Record<string, string> = {
themoviedb: getLogoUrl('tmdb'), 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_KEY = 'MP_NAME_TEST_TITLE_HISTORY'
const NAME_TEST_TITLE_HISTORY_LIMIT = 5 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.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' },
]) ])
// 退TheMovieDb // 退TheMovieDb
@@ -88,6 +97,9 @@ const nameTestForm = reactive<NameTestForm>({
source: getDefaultMediaSource(), source: getDefaultMediaSource(),
}) })
// MusicBrainz
const showCustomWords = computed(() => nameTestForm.source !== 'musicbrainz')
/** 从本地存储读取最近使用的识别标题。 */ /** 从本地存储读取最近使用的识别标题。 */
function loadTitleHistory() { function loadTitleHistory() {
try { try {
@@ -140,17 +152,36 @@ const savingCustomWords = ref(false)
const metaInfo = computed(() => nameTestResult.value?.meta_info) const metaInfo = computed(() => nameTestResult.value?.meta_info)
const mediaInfo = computed(() => nameTestResult.value?.media_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 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]
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') return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
}) })
const mediaClassification = computed(() => { const mediaClassification = computed(() => {
return [mediaInfo.value?.type || metaInfo.value?.type, mediaInfo.value?.category].filter(Boolean).join(' · ') || '-' return [mediaInfo.value?.type || metaInfo.value?.type, mediaInfo.value?.category].filter(Boolean).join(' · ') || '-'
}) })
const resourceChips = computed(() => { 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 [ return [
metaInfo.value?.web_source, metaInfo.value?.web_source,
metaInfo.value?.edition, metaInfo.value?.edition,
@@ -186,6 +217,9 @@ function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string)
return `https://bgm.tv/subject/${encodedId}` return `https://bgm.tv/subject/${encodedId}`
case 'anilist': case 'anilist':
return `https://anilist.co/anime/${encodedId}` return `https://anilist.co/anime/${encodedId}`
case 'musicbrainz':
// MusicBrainz UUID使
return media.detail_link || `https://musicbrainz.org/recording/${encodedId}`
default: default:
return undefined return undefined
} }
@@ -209,9 +243,11 @@ function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value)) const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value))
const recognizedMediaSource = computed<MediaSourceDisplay>(() => { const recognizedMediaSource = computed<MediaSourceDisplay>(() => {
const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source
const iconInfo = MEDIA_SOURCE_ICONS[sourceKey]
return { return {
icon: sourceKey === 'anilist' ? 'mdi-alpha-a-circle' : undefined, icon: iconInfo?.icon,
iconColor: iconInfo?.color,
image: MEDIA_SOURCE_LOGOS[sourceKey], image: MEDIA_SOURCE_LOGOS[sourceKey],
key: sourceKey, key: sourceKey,
label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey, label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey,
@@ -227,8 +263,12 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
{ {
icon: 'mdi-puzzle-check-outline', icon: 'mdi-puzzle-check-outline',
title: t('nameTest.steps.meta.title'), title: t('nameTest.steps.meta.title'),
value: value: isMusicResult.value
[metaInfo.value?.name, metaInfo.value?.resource_term, metaInfo.value?.release_group] ? //
[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) .filter(Boolean)
.join(' · ') || '-', .join(' · ') || '-',
}, },
@@ -293,7 +333,8 @@ async function nameTest() {
params: { params: {
title: nameTestForm.title, title: nameTestForm.title,
subtitle: nameTestForm.subtitle, subtitle: nameTestForm.subtitle,
custom_words: nameTestForm.customWords?.trim() || undefined, //
custom_words: showCustomWords.value ? nameTestForm.customWords?.trim() || undefined : undefined,
source: nameTestForm.source, source: nameTestForm.source,
}, },
}) })
@@ -383,7 +424,7 @@ async function saveCustomWords() {
prepend-inner-icon="mdi-subtitles" prepend-inner-icon="mdi-subtitles"
/> />
</VCol> </VCol>
<VCol cols="12" class="shortcut-form-col"> <VCol v-if="showCustomWords" cols="12" class="shortcut-form-col">
<VTextarea <VTextarea
v-model="nameTestForm.customWords" v-model="nameTestForm.customWords"
:label="t('nameTest.customWords')" :label="t('nameTest.customWords')"
@@ -513,7 +554,7 @@ async function saveCustomWords() {
<VIcon <VIcon
v-else-if="step.source.icon" v-else-if="step.source.icon"
class="media-source-logo" class="media-source-logo"
color="#02a9ff" :color="step.source.iconColor || '#02a9ff'"
:icon="step.source.icon" :icon="step.source.icon"
/> />
<span>{{ step.source.label }}</span> <span>{{ step.source.label }}</span>
@@ -36,6 +36,7 @@ vi.mock('vue-toastification', () => ({
interface RecognizedMedia { interface RecognizedMedia {
category?: string category?: string
detail_link?: string
media_id: string media_id: string
source: string source: string
title: string title: string
@@ -99,6 +100,18 @@ describe('NameTestView media identity', () => {
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' }, { media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
'https://anilist.co/anime/154587', '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) => { ])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
await renderRecognizedMedia(media) await renderRecognizedMedia(media)
@@ -129,6 +142,77 @@ describe('NameTestView media identity', () => {
expect(classificationStep).toHaveTextContent('媒体分类电视剧 · 动漫') 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 () => { it('closes the recognition dialog before navigating to the media detail', async () => {
const eventOrder: string[] = [] const eventOrder: string[] = []
const onClose = vi.fn(() => eventOrder.push('close')) const onClose = vi.fn(() => eventOrder.push('close'))