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

This commit is contained in:
jxxghp
2026-08-11 23:33:14 +08:00
parent df4d697845
commit 26fcda661d
31 changed files with 302 additions and 61 deletions

View File

@@ -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 {
@@ -370,7 +371,7 @@ export interface DownloadHistory {
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi、anilist、musicbrainz
// 来源themoviedb、douban、bangumi、anilist、musicbrainz、theaudiodb、doubanmusic
source?: string
// 类型 电影、电视剧、音乐、合集
type?: string

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" />

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

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]
})

View File

@@ -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]
})

View File

@@ -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')
})

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 = '音乐'
}
},

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>

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

View File

@@ -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: '音乐',
})
})
})

View File

@@ -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', () => {
})
})
})
})
})

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,

View File

@@ -2795,6 +2795,8 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
theaudiodb: 'TheAudioDB',
doubanmusic: 'Douban Music',
},
},
},

View File

@@ -2744,6 +2744,8 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
theaudiodb: 'TheAudioDB',
doubanmusic: '豆瓣音乐',
},
},
},

View File

@@ -2743,6 +2743,8 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
musicbrainz: 'MusicBrainz',
theaudiodb: 'TheAudioDB',
doubanmusic: '豆瓣音樂',
},
},
},

View File

@@ -125,6 +125,20 @@ describe('music page', () => {
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 () => {
await renderMusicPage()

View File

@@ -4,7 +4,7 @@ import MusicAlbumView from '@/views/discover/MusicAlbumView.vue'
// 路由参数
const route = useRoute()
// MusicBrainz Release Group ID
// 音乐数据源原生专辑 ID
const mediaid = computed(() => route.query?.mediaid?.toString())
// 音乐元数据来源

View File

@@ -4,7 +4,7 @@ import MusicArtistView from '@/views/discover/MusicArtistView.vue'
// 路由参数
const route = useRoute()
// MusicBrainz Artist ID
// 音乐数据源原生艺术家 ID
const mediaid = computed(() => route.query?.mediaid?.toString())
// 音乐元数据来源

View File

@@ -4,7 +4,7 @@ import MusicDetailView from '@/views/discover/MusicDetailView.vue'
// 路由参数
const route = useRoute()
// MusicBrainz 单曲 ID
// 音乐数据源原生单曲 ID
const mediaid = computed(() => route.query?.mediaid?.toString())
// 音乐元数据来源

View File

@@ -14,6 +14,7 @@ const results = ref<MediaInfo[]>([])
// 搜索入口统一在全局搜索,本页只消费路由关键词
const query = computed(() => route.query.query?.toString().trim() || '')
const source = computed(() => route.query.source?.toString().trim() || '')
/** 调用统一音乐元数据接口搜索候选。 */
async function searchMusic() {
@@ -26,7 +27,13 @@ async function searchMusic() {
loading.value = true
searched.value = true
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) {
console.error(error)
results.value = []
@@ -35,7 +42,7 @@ async function searchMusic() {
}
}
watch(query, searchMusic, { immediate: true })
watch([query, source], searchMusic, { immediate: true })
</script>
<template>

View File

@@ -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)
})
})

View File

@@ -1,8 +1,14 @@
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
export const MUSIC_MEDIA_SOURCES = ['musicbrainz', 'theaudiodb', 'doubanmusic'] as const
/** 按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它现有来源使用数字 ID。 */
/** 判断当前请求来源是否为内置音乐元数据源。 */
export function isMusicMediaSource(source?: MediaDataSource): boolean {
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
}
/** 按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它内置来源使用数字 ID。 */
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
const normalized = value?.toString().trim()
if (!normalized) return true

View File

@@ -62,6 +62,16 @@ export function getMusicSource(item: MusicRouteTarget): string | undefined {
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 {
const source = getMusicSource(item) || 'music'

View File

@@ -10,13 +10,19 @@ import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSu
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { useUserStore } from '@/stores'
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 router = useRouter()
const props = defineProps({
// MusicBrainz Release Group ID
// 音乐数据源原生专辑 ID
mediaid: String,
// 音乐元数据来源
source: {
@@ -39,6 +45,7 @@ const artistLinks = computed(() => getMusicArtistLinks(album.value))
// 关联浏览统一以首个艺术家为入口
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
// 专辑订阅复用影视订阅链,年份需要按订阅表的字符串格式传递
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>
</div>
<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>
</div>
</template>
@@ -195,7 +202,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
<div v-if="album.detail_link" class="mt-4">
<a :href="album.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
<span class="ms-1">{{ sourceLabel }}</span>
</a>
</div>
<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">
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</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')" />
</div>
</MusicDetailLayout>

View File

@@ -8,11 +8,12 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import { getMusicSourceLabel } from '@/utils/music'
const { t } = useI18n()
const props = defineProps({
// MusicBrainz Artist ID
// 音乐数据源原生艺术家 ID
mediaid: String,
// 音乐元数据来源
source: {
@@ -27,6 +28,7 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false)
const artist = ref<MusicArtistInfo>()
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
if (!artist.value?.name) return undefined
@@ -75,7 +77,7 @@ async function loadArtistDetail() {
/** 返回指定专辑类型的浏览列表路由。 */
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 })
@@ -123,7 +125,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
<span class="music-fact-value">{{ artist.aliases.slice(0, 6).join('、') }}</span>
</div>
<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>
</div>
</template>
@@ -143,7 +145,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
class="music-external-link"
>
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
<span class="ms-1">{{ sourceLabel }}</span>
</a>
<a
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">
<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)"
:title="section.title"
/>
</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')" />
</div>
</MusicDetailLayout>

View File

@@ -16,13 +16,14 @@ import {
buildMusicResourceRoute,
formatMusicDuration,
getMusicArtistLinks,
getMusicSourceLabel,
} from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
// MusicBrainz 单曲 ID
// 音乐数据源原生单曲 ID
mediaid: String,
// 音乐元数据来源
source: {
@@ -47,7 +48,7 @@ const artistLinks = computed(() => getMusicArtistLinks(music.value))
// 关联浏览统一以首个艺术家为入口MusicBrainz 的主署名艺术家排在最前
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
// 头部属性行只展示 MusicBrainz 实际返回的字段
// 头部属性行只展示各音乐源已映射到标准模型的字段
const attributes = computed(() => {
const values: string[] = []
if (music.value?.category) values.push(music.value.category)
@@ -60,6 +61,7 @@ const attributes = computed(() => {
// 专辑内除当前单曲外仍然展示完整曲目,方便对照曲序
const albumTracks = computed(() => album.value?.tracks ?? [])
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
function getSubscribeStatusKey() {
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>
</div>
<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>
</div>
</template>
@@ -227,7 +229,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
<div v-if="music.detail_link" class="mt-4">
<a :href="music.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
<span class="ms-1">{{ sourceLabel }}</span>
</a>
</div>
@@ -237,14 +239,14 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</div>
</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')" />
</div>
<div v-if="primaryArtistId" class="music-section">
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</div>

View File

@@ -39,12 +39,15 @@ const mediaCategories = ref<{ [key: string]: any }>({})
const $toast = useToast()
// 数据源
const sourceItems = [
{ 'title': 'TheMovieDb', 'value': 'themoviedb' },
{ 'title': '豆瓣', 'value': 'douban' },
{ 'title': 'Bangumi', 'value': 'bangumi' },
{ 'title': 'AniList', 'value': 'anilist' },
]
const sourceItems = computed(() => [
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
{ 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 storageOptions = computed(() => {

View File

@@ -52,6 +52,18 @@ const mediaSourcesDict = [
title: 'AniList',
value: 'anilist',
},
{
title: 'MusicBrainz',
value: 'musicbrainz',
},
{
title: 'TheAudioDB',
value: 'theaudiodb',
},
{
title: '豆瓣音乐',
value: 'doubanmusic',
},
]
// 当前选中的媒体信息数据源
@@ -189,7 +201,13 @@ async function loadSystemSettings() {
}
async function loadPageData() {
await Promise.all([querySites(), queryFilterRuleGroups(), querySelectedSites(), loadSearchSetting(), loadSystemSettings()])
await Promise.all([
querySites(),
queryFilterRuleGroups(),
querySelectedSites(),
loadSearchSetting(),
loadSystemSettings(),
])
}
onMounted(() => {

View File

@@ -1,5 +1,6 @@
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
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()
})
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 () => {
mockSettings(true)
await renderDirectorySettings()

View File

@@ -9,6 +9,7 @@ import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { isMusicMediaSource } from '@/utils/mediaId'
interface PipelineStep {
icon: string
@@ -44,7 +45,9 @@ const MEDIA_SOURCE_LABELS: Record<string, string> = {
anilist: 'AniList',
bangumi: 'Bangumi',
douban: 'Douban',
doubanmusic: '豆瓣音乐',
musicbrainz: 'MusicBrainz',
theaudiodb: 'TheAudioDB',
themoviedb: 'TheMovieDb',
}
@@ -58,6 +61,8 @@ const MEDIA_SOURCE_LOGOS: Record<string, string> = {
const MEDIA_SOURCE_ICONS: Record<string, { icon: string; color: string }> = {
anilist: { icon: 'mdi-alpha-a-circle', color: '#02a9ff' },
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: [] }>()
@@ -72,6 +77,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ 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' },
])
// 获取后台默认识别数据源未知值兼容回退到TheMovieDb。
@@ -94,8 +101,8 @@ const nameTestForm = reactive<NameTestForm>({
source: getDefaultMediaSource(),
})
// MusicBrainz 仅支持音乐识别,音乐不应用自定义识别词,隐藏输入区避免误导
const showCustomWords = computed(() => nameTestForm.source !== 'musicbrainz')
// 音乐识别不应用影视自定义识别词,隐藏输入区避免误导
const showCustomWords = computed(() => !isMusicMediaSource(nameTestForm.source))
// 识别按钮状态
const nameTestLoading = ref(false)
@@ -116,9 +123,7 @@ const metaInfo = computed(() => nameTestResult.value?.meta_info)
const mediaInfo = computed(() => nameTestResult.value?.media_info)
// 音乐识别的元信息没有 name 字段,依靠 title 判断识别是否成功
const isMusicResult = computed(() => mediaInfo.value?.type === '音乐' || metaInfo.value?.type === '音乐')
const isRecognized = computed(() =>
Boolean(metaInfo.value?.name || (isMusicResult.value && metaInfo.value?.title)),
)
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]
@@ -182,6 +187,10 @@ function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string)
case 'musicbrainz':
// MusicBrainz 各实体共用 UUID优先使用识别结果自带的详情页地址
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:
return undefined
}

View File

@@ -112,6 +112,28 @@ describe('NameTestView media identity', () => {
},
'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) => {
await renderRecognizedMedia(media)
@@ -207,6 +229,10 @@ describe('NameTestView media identity', () => {
// 音乐识别不应用识别词,输入区应隐藏
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(await screen.findByRole('option', { name: 'TheMovieDb' }))
// 切回影视数据源后输入区恢复