feat: 音乐搜索与识别前端联动统一

- 音乐搜索从 music/search 切换为 media/search?type=music
- MediaInfoCard 支持音乐卡片(方形封面、时长、音乐实体类型、详情外链)
- 订阅与资源选择器适配音乐元数据
- 同步中英繁语言包
This commit is contained in:
jxxghp
2026-08-09 10:18:42 +08:00
parent b9194ffb74
commit 009ce6173b
11 changed files with 204 additions and 89 deletions

View File

@@ -336,8 +336,8 @@ export interface MediaInfo {
type?: string
// 媒体标题
title?: string
// 年份
year?: string
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
year?: string | number
// 标题(年)
title_year?: string
// 季号

View File

@@ -11,6 +11,11 @@ const props = defineProps({
// 音乐元数据使用 title影视元数据使用 name识别结果卡片统一兼容两种字段。
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
// 是否为音乐识别结果
const isMusic = computed(
() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐',
)
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url) return ''
@@ -24,6 +29,27 @@ function openTmdbPage(type: string, tmdbId: number) {
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
window.open(url, '_blank')
}
// 秒数格式化为 m:ss 时长文本
function formatDuration(seconds: number | undefined) {
if (!seconds || seconds <= 0) return ''
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return `${minutes}:${rest.toString().padStart(2, '0')}`
}
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
const musicCover = computed(() => getW500Image(
props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '',
))
// 音乐详情外链
const musicLink = computed(() => props.context?.media_info?.detail_link || '')
// 打开音乐详情外链
function openMusicDetail() {
if (musicLink.value) window.open(musicLink.value, '_blank')
}
</script>
<template>
@@ -33,7 +59,22 @@ function openTmdbPage(type: string, tmdbId: number) {
v-if="recognizedName"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div v-if="context?.media_info?.poster_path" class="ma-auto">
<div v-if="isMusic && musicCover" class="ma-auto">
<VImg
width="10rem"
aspect-ratio="1"
class="object-cover rounded-lg ring-1 ring-gray-500"
:src="musicCover"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover" />
</div>
</template>
</VImg>
</div>
<div v-else-if="context?.media_info?.poster_path" class="ma-auto">
<VImg
width="10rem"
aspect-ratio="2/3"
@@ -77,46 +118,133 @@ function openTmdbPage(type: string, tmdbId: number) {
>
{{ context?.media_info?.type || context?.meta_info?.type }}
</VChip>
<!-- 音乐实体类型 -->
<VChip
v-if="isMusic && context?.media_info?.music_type"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ context?.media_info?.music_type }}
</VChip>
<!-- 艺术家 -->
<VChip v-if="context?.media_info?.artist" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.media_info?.artist }}
</VChip>
<!-- 专辑 -->
<VChip v-if="context?.media_info?.album" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.media_info?.album }}
</VChip>
<!-- 专辑艺术家 -->
<VChip
v-if="context?.media_info?.album_artist"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.media_info?.album_artist }}
</VChip>
<!-- 发行日期 -->
<VChip
v-if="context?.media_info?.release_date"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.media_info?.release_date }}
</VChip>
<!-- 风格 -->
<VChip
v-for="genre in context?.media_info?.genres"
:key="genre"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ genre }}
</VChip>
<!-- 音频技术参数 -->
<VChip
v-if="context?.meta_info?.audio_format"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_format }}
</VChip>
<VChip
v-if="context?.meta_info?.bit_depth || context?.meta_info?.sample_rate"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ [context?.meta_info?.bit_depth, context?.meta_info?.sample_rate].filter(Boolean).join(' kHz ') }}
</VChip>
<!-- 时长 -->
<VChip
v-if="formatDuration(context?.media_info?.duration)"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ formatDuration(context?.media_info?.duration) }}
</VChip>
<!-- 曲目信息 -->
<VChip
v-if="context?.media_info?.track_number"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ `曲目 ${context?.media_info?.track_number}${context?.media_info?.total_tracks ? ` / ${context?.media_info?.total_tracks}` : ''}` }}
</VChip>
<!-- ISRC -->
<VChip v-if="context?.media_info?.isrc" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.media_info?.isrc }}
</VChip>
<!-- MusicBrainz 外链 -->
<VChip
v-if="musicLink"
variant="elevated"
class="me-1 mb-1 text-white bg-green-500"
@click="openMusicDetail"
>
详情
</VChip>
<!-- 二级分类 -->
<VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
<VChip v-if="!isMusic && context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
{{ context?.media_info?.category }}
</VChip>
<!-- TMDBID -->
<VChip
v-if="context?.media_info?.tmdb_id"
v-if="!isMusic && context?.media_info?.tmdb_id"
variant="elevated"
class="me-1 mb-1 text-white bg-green-500"
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
>
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="context?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_encode }}
</VChip>
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
{{ context?.meta_info?.resource_team }}
</VChip>
<!-- meta_info音乐不显示影视资源信息 -->
<template v-if="!isMusic">
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="context?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_encode }}
</VChip>
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
{{ context?.meta_info?.resource_team }}
</VChip>
</template>
</VCardItem>
</div>
</div>
@@ -143,4 +271,4 @@ function openTmdbPage(type: string, tmdbId: number) {
</VExpansionPanel>
</VExpansionPanels>
</div>
</template>
</template>

View File

@@ -27,9 +27,6 @@ const keyword = ref<string>()
// 选择分类
const selectCategory = ref<number[]>([])
// 选择主媒体类型,用于按站点定义的电影、电视剧、音乐分类浏览。
const selectMediaType = ref<string>()
// 全部分类
const siteCategoryList = ref<SiteCategory[]>()
@@ -69,11 +66,8 @@ const categoryOptions = computed(() => {
})
})
const mediaTypeOptions = computed(() => [
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
])
// 站点是否配置了资源分类
const hasSiteCategory = computed(() => (siteCategoryList.value?.length ?? 0) > 0)
// 总条数
const resourceTotalItems = computed(() => resourceDataList.value.length)
@@ -169,7 +163,6 @@ async function getResourceList() {
params: {
keyword: keyword.value,
cat: selectCategory.value?.join(','),
mtype: selectMediaType.value,
},
})
@@ -288,20 +281,9 @@ onMounted(() => {
clearable
prepend-inner-icon="mdi-folder"
hide-details
/>
</VCol>
<VCol cols="12" md="2">
<VSelect
v-model="selectMediaType"
:items="mediaTypeOptions"
class="site-resource-filter-input"
density="compact"
variant="solo-filled"
flat
clearable
:label="t('common.type')"
prepend-inner-icon="mdi-shape-outline"
hide-details
:disabled="!hasSiteCategory"
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3" class="d-flex align-center">
@@ -369,20 +351,6 @@ onMounted(() => {
@keyup.enter="getResourceList"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="selectMediaType"
:items="mediaTypeOptions"
class="site-resource-filter-input"
density="compact"
variant="solo-filled"
flat
clearable
:label="t('common.type')"
prepend-inner-icon="mdi-shape-outline"
hide-details
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="selectCategory"
@@ -398,6 +366,9 @@ onMounted(() => {
clearable
prepend-inner-icon="mdi-folder"
hide-details
:disabled="!hasSiteCategory"
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
persistent-hint
/>
</VCol>
<VCol cols="12" class="d-flex gap-2">

View File

@@ -58,19 +58,15 @@ async function searchMedias() {
// 调用API搜索词条
try {
loading.value = true
const result: MediaInfo[] =
props.type === 'musicbrainz'
? await api.get('music/search', {
params: { query: searchKeyword, count: 20 },
})
: await api.get('media/search', {
params: {
title: searchKeyword,
page: 1,
count: 20,
source: props.type,
},
})
const result: MediaInfo[] = await api.get('media/search', {
params: {
title: searchKeyword,
type: props.type === 'musicbrainz' ? 'music' : 'media',
page: 1,
count: 20,
source: props.type,
},
})
// 清空
items.value = []

View File

@@ -317,7 +317,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const result: { [key: string]: any } = await api.post('subscribe/', {
name: media.title,
type: media.type,
year: media.year,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '',
tmdbid: media.tmdb_id,
doubanid: media.douban_id,
bangumiid: media.bangumi_id,

View File

@@ -2273,6 +2273,9 @@ export default {
seasonThumb: 'Thumb',
episodeNfo: 'NFO',
episodeThumb: 'Thumb',
music: 'Music',
musicNfo: 'Audio Tags',
musicPoster: 'Cover Art',
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
scrapingSwitchSaveError: 'Scraping switch settings save failed',
policy: {
@@ -3482,6 +3485,7 @@ export default {
browseTitle: 'Browse - {name}',
searchKeyword: 'Search Keyword',
resourceCategory: 'Resource Category',
noCategory: 'This site has no resource categories configured',
search: 'Search',
itemsPerPage: 'Items Per Page',
noData: 'No Data',

View File

@@ -2236,6 +2236,9 @@ export default {
seasonThumb: '缩略图',
episodeNfo: 'NFO',
episodeThumb: '缩略图',
music: '音乐',
musicNfo: '音乐标签',
musicPoster: '封面',
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
scrapingSwitchSaveError: '刮削开关设置保存失败',
policy: {
@@ -3424,6 +3427,7 @@ export default {
browseTitle: '浏览 - {name}',
searchKeyword: '搜索关键字',
resourceCategory: '资源分类',
noCategory: '该站点未配置资源分类',
search: '搜索',
itemsPerPage: '每页条数',
noData: '没有数据',

View File

@@ -2235,6 +2235,9 @@ export default {
seasonThumb: '縮略圖',
episodeNfo: 'NFO',
episodeThumb: '縮略圖',
music: '音樂',
musicNfo: '音樂標籤',
musicPoster: '封面',
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
scrapingSwitchSaveError: '刮削開關設定保存失敗',
policy: {
@@ -3423,6 +3426,7 @@ export default {
browseTitle: '瀏覽 - {name}',
searchKeyword: '搜索關鍵字',
resourceCategory: '資源分類',
noCategory: '該站點未配置資源分類',
search: '搜索',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',

View File

@@ -47,7 +47,7 @@ const musicResult = {
/** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/search') return Promise.resolve([musicResult])
if (path === 'media/search') return Promise.resolve([musicResult])
if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
@@ -77,8 +77,8 @@ describe('music page', () => {
await renderMusicPage()
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/search', {
params: { count: 30, query: '晴天' },
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: { type: 'music', count: 30, title: '晴天' },
}),
)
expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument()

View File

@@ -26,7 +26,7 @@ async function searchMusic() {
loading.value = true
searched.value = true
try {
results.value = (await api.get('music/search', { params: { query: query.value, count: 30 } })) || []
results.value = (await api.get('media/search', { params: { title: query.value, type: 'music', count: 30 } })) || []
} catch (error) {
console.error(error)
results.value = []

View File

@@ -183,6 +183,13 @@ const scrapingConfig = [
{ key: 'episode_thumb', label: 'setting.system.episodeThumb' },
],
},
{
section: 'music',
items: [
{ key: 'music_nfo', label: 'setting.system.musicNfo' },
{ key: 'music_poster', label: 'setting.system.musicPoster' },
],
},
]
// 刮削策略设置
@@ -2203,7 +2210,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<span class="ml-2">{{ t(item.label) }}</span>
</div>
</VCol>
<VDivider v-if="section.section !== 'episode'" class="my-4" />
<VDivider v-if="section.section !== 'music'" class="my-4" />
</VRow>
</VExpansionPanelText>
</VExpansionPanel>