feat: 完善音乐识别结果弹窗

This commit is contained in:
jxxghp
2026-08-09 17:05:36 +08:00
parent b2c4a69173
commit 278a7587c1
6 changed files with 196 additions and 71 deletions
+21 -1
View File
@@ -1154,7 +1154,25 @@ export interface MetaInfo {
// 识别的英文名 // 识别的英文名
en_name?: string en_name?: string
// 年份 // 年份
year?: string year?: string | number
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐专辑
album?: string
// 音乐专辑艺术家
album_artist?: string
// 音乐碟号
disc_number?: number
// 音乐音轨号
track_number?: number
// 音乐总音轨数
total_tracks?: number
// 音乐时长(秒)
duration?: number
// 音乐 ISRC
isrc?: string
// 总季数 // 总季数
total_season: number total_season: number
// 识别的开始季 数字 // 识别的开始季 数字
@@ -1187,6 +1205,8 @@ export interface MetaInfo {
bit_depth?: number bit_depth?: number
// 音频采样率 // 音频采样率
sample_rate?: number sample_rate?: number
// 音频码率
bitrate?: number
// 名称(自动中英文) // 名称(自动中英文)
name: string name: string
// SXX-SXX // SXX-SXX
+132 -64
View File
@@ -2,6 +2,9 @@
import type { PropType } from 'vue' import type { PropType } from 'vue'
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
import { formatMusicDuration } from '@/utils/music'
const { t } = useI18n()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -16,6 +19,9 @@ const isMusic = computed(
() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐', () => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐',
) )
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
const musicCoverLoadError = ref(false)
// TMDB图片转换为w500大小 // TMDB图片转换为w500大小
function getW500Image(url = '') { function getW500Image(url = '') {
if (!url) return '' if (!url) return ''
@@ -30,19 +36,81 @@ function openTmdbPage(type: string, tmdbId: number) {
window.open(url, '_blank') 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 生效 // 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
const musicCover = computed(() => getW500Image( const musicCover = computed(() => getW500Image(
props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '', props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '',
)) ))
// 音乐艺术家优先使用标准识别结果,远端未命中时回退到文件标签。
const musicArtist = computed(() => {
const mediaInfo = props.context?.media_info
const metaInfo = props.context?.meta_info
return mediaInfo?.artist || mediaInfo?.artists?.join(' / ') || metaInfo?.artist || metaInfo?.artists?.join(' / ') || ''
})
// 专辑艺术家只在与歌曲艺术家不同时单独展示。
const musicAlbumArtist = computed(() => {
const albumArtist = props.context?.media_info?.album_artist || props.context?.meta_info?.album_artist
return albumArtist && albumArtist !== musicArtist.value ? albumArtist : ''
})
// 音乐专辑优先使用标准识别结果,远端未命中时回退到文件标签。
const musicAlbum = computed(() => props.context?.media_info?.album || props.context?.meta_info?.album || '')
// 音乐实体类型转换为面向用户的本地化名称。
const musicTypeLabel = computed(() => {
const labels = {
album: t('music.entityAlbum'),
artist: t('music.entityArtist'),
recording: t('music.entityRecording'),
}
const musicType = props.context?.media_info?.music_type
return musicType ? labels[musicType] : ''
})
// 音乐标题下方汇总发行时间、时长和曲序,信息密度与影视识别结果保持一致。
const musicSummary = computed(() => {
const mediaInfo = props.context?.media_info
const metaInfo = props.context?.meta_info
const values: string[] = []
const releaseDate = mediaInfo?.release_date || mediaInfo?.year || metaInfo?.year
if (releaseDate) values.push(String(releaseDate))
const duration = formatMusicDuration(mediaInfo?.duration || metaInfo?.duration)
if (duration) values.push(duration)
const trackNumber = mediaInfo?.track_number || metaInfo?.track_number
const totalTracks = mediaInfo?.total_tracks || metaInfo?.total_tracks
if (trackNumber) {
const track = totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber)
values.push(`${t('music.track')} ${track}`)
}
return values.join(' · ')
})
// 采样率统一换算为常见的 kHz 展示。
function formatSampleRate(sampleRate?: number) {
if (!sampleRate) return ''
const value = sampleRate >= 1000 ? sampleRate / 1000 : sampleRate
return `${Number.isInteger(value) ? value : value.toFixed(1)} kHz`
}
// 码率统一换算为 kbps 展示。
function formatBitrate(bitrate?: number) {
if (!bitrate) return ''
const value = bitrate >= 1000 ? Math.round(bitrate / 1000) : bitrate
return `${value} kbps`
}
// 音频规格仅展示文件标签中实际存在的字段。
const musicAudioChips = computed(() => {
const metaInfo = props.context?.meta_info
return [
metaInfo?.audio_format?.toUpperCase(),
metaInfo?.bit_depth ? `${metaInfo.bit_depth}-bit` : '',
formatSampleRate(metaInfo?.sample_rate),
formatBitrate(metaInfo?.bitrate),
].filter(Boolean)
})
// 音乐详情外链 // 音乐详情外链
const musicLink = computed(() => props.context?.media_info?.detail_link || '') const musicLink = computed(() => props.context?.media_info?.detail_link || '')
@@ -50,6 +118,10 @@ const musicLink = computed(() => props.context?.media_info?.detail_link || '')
function openMusicDetail() { function openMusicDetail() {
if (musicLink.value) window.open(musicLink.value, '_blank') if (musicLink.value) window.open(musicLink.value, '_blank')
} }
watch(musicCover, () => {
musicCoverLoadError.value = false
})
</script> </script>
<template> <template>
@@ -59,13 +131,16 @@ function openMusicDetail() {
v-if="recognizedName" v-if="recognizedName"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
> >
<div v-if="isMusic && musicCover" class="ma-auto"> <div v-if="isMusic" class="ma-auto recognized-music-cover">
<VImg <VImg
v-if="musicCover && !musicCoverLoadError"
width="10rem" width="10rem"
aspect-ratio="1" aspect-ratio="1"
class="object-cover rounded-lg ring-1 ring-gray-500" class="object-cover rounded-lg ring-1 ring-gray-500"
:src="musicCover" :src="musicCover"
cover cover
eager
@error="musicCoverLoadError = true"
> >
<template #placeholder> <template #placeholder>
<div class="w-full h-full"> <div class="w-full h-full">
@@ -73,6 +148,15 @@ function openMusicDetail() {
</div> </div>
</template> </template>
</VImg> </VImg>
<VSheet
v-else
width="10rem"
height="10rem"
rounded="lg"
class="d-flex align-center justify-center music-cover-placeholder"
>
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
</VSheet>
</div> </div>
<div v-else-if="context?.media_info?.poster_path" class="ma-auto"> <div v-else-if="context?.media_info?.poster_path" class="ma-auto">
<VImg <VImg
@@ -97,7 +181,21 @@ function openMusicDetail() {
{{ context?.meta_info?.season_episode }} {{ context?.meta_info?.season_episode }}
</span> </span>
</div> </div>
<VCardSubtitle class="text-center text-md-left"> <div v-if="isMusic" class="music-recognition-summary text-center text-md-left">
<div v-if="musicArtist" class="text-body-1 font-weight-medium text-high-emphasis">
{{ musicArtist }}
</div>
<div v-if="musicAlbum" class="text-body-2 text-medium-emphasis">
{{ t('music.album') }}{{ musicAlbum }}
</div>
<div v-if="musicAlbumArtist" class="text-body-2 text-medium-emphasis">
{{ t('music.albumArtist') }}{{ musicAlbumArtist }}
</div>
<div v-if="musicSummary" class="text-body-2 text-medium-emphasis">
{{ musicSummary }}
</div>
</div>
<VCardSubtitle v-else class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }} {{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle> </VCardSubtitle>
</VCardItem> </VCardItem>
@@ -124,39 +222,15 @@ function openMusicDetail() {
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-blue-500" class="me-1 mb-1 text-white bg-blue-500"
> >
{{ context?.media_info?.music_type }} {{ musicTypeLabel }}
</VChip> </VChip>
<!-- 艺术家 --> <!-- 音乐分类 -->
<VChip <VChip
v-if="isMusic && context?.media_info?.artist" v-if="isMusic && (context?.media_info?.category || context?.media_info?.album_type)"
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-purple-500" class="me-1 mb-1 text-white bg-purple-500"
> >
{{ context?.media_info?.artist }} {{ context?.media_info?.category || context?.media_info?.album_type }}
</VChip>
<!-- 专辑 -->
<VChip
v-if="isMusic && context?.media_info?.album"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.media_info?.album }}
</VChip>
<!-- 专辑艺术家 -->
<VChip
v-if="isMusic && 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="isMusic && context?.media_info?.release_date"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.media_info?.release_date }}
</VChip> </VChip>
<!-- 风格 --> <!-- 风格 -->
<VChip <VChip
@@ -169,34 +243,12 @@ function openMusicDetail() {
</VChip> </VChip>
<!-- 音频技术参数 --> <!-- 音频技术参数 -->
<VChip <VChip
v-if="isMusic && context?.meta_info?.audio_format" v-for="audioChip in isMusic ? musicAudioChips : []"
:key="audioChip"
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-orange-500" class="me-1 mb-1 text-white bg-orange-500"
> >
{{ context?.meta_info?.audio_format }} {{ audioChip }}
</VChip>
<VChip
v-if="isMusic && (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="isMusic && 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="isMusic && 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> </VChip>
<!-- ISRC --> <!-- ISRC -->
<VChip <VChip
@@ -283,4 +335,20 @@ function openMusicDetail() {
</VExpansionPanel> </VExpansionPanel>
</VExpansionPanels> </VExpansionPanels>
</div> </div>
</template> </template>
<style scoped>
.recognized-music-cover {
flex: 0 0 auto;
}
.music-cover-placeholder {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.music-recognition-summary {
display: grid;
gap: 0.2rem;
margin-block-start: 0.35rem;
}
</style>
@@ -9,25 +9,53 @@ describe('MediaInfoCard', () => {
props: { props: {
context: { context: {
meta_info: { meta_info: {
artists: ['周杰伦'], album: '完美的一天',
artists: ['孙燕姿'],
audio_format: 'FLAC', audio_format: 'FLAC',
bit_depth: 24, bit_depth: 24,
bitrate: 1411200,
duration: 221,
sample_rate: 48000, sample_rate: 48000,
title: '晴天', title: '眼泪成诗',
total_tracks: 10,
track_number: 2,
type: '音乐', type: '音乐',
}, },
media_info: { media_info: {
artist: '周杰伦', album: '完美的一天',
title: '晴天', artist: '孙燕姿',
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/album-1/front-500',
duration: 221,
genres: ['华语流行'],
isrc: 'TWA530505002',
music_type: 'recording',
release_date: '2005-10-07',
title: '眼泪成诗',
total_tracks: 10,
track_number: 2,
type: '音乐', type: '音乐',
}, },
}, },
}, },
}) })
expect(screen.getByText('晴天')).toBeInTheDocument() expect(screen.getByText('眼泪成诗')).toBeInTheDocument()
expect(screen.getByText('孙燕姿')).toBeInTheDocument()
expect(screen.getByText('专辑:完美的一天')).toBeInTheDocument()
expect(screen.getByText('2005-10-07 · 3:41 · 音轨 2/10')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
expect(screen.getByText('Album')).toBeInTheDocument()
expect(screen.getByText('华语流行')).toBeInTheDocument()
expect(screen.getByText('FLAC')).toBeInTheDocument() expect(screen.getByText('FLAC')).toBeInTheDocument()
expect(screen.getByText('24 kHz 48000')).toBeInTheDocument() expect(screen.getByText('24-bit')).toBeInTheDocument()
expect(screen.getByText('48 kHz')).toBeInTheDocument()
expect(screen.getByText('1411 kbps')).toBeInTheDocument()
expect(screen.getByText('TWA530505002')).toBeInTheDocument()
expect(document.querySelector('.v-img__img')).toHaveAttribute(
'src',
'https://coverartarchive.org/release-group/album-1/front-500',
)
expect(screen.queryByText('识别失败,无法识别到有效信息!')).not.toBeInTheDocument() expect(screen.queryByText('识别失败,无法识别到有效信息!')).not.toBeInTheDocument()
}) })
+3
View File
@@ -442,6 +442,9 @@ export default {
artistAlbums: "Artist's Albums", artistAlbums: "Artist's Albums",
artistType: 'Artist Type', artistType: 'Artist Type',
artistNotFound: 'Artist not found', artistNotFound: 'Artist not found',
entityRecording: 'Track',
entityAlbum: 'Album',
entityArtist: 'Artist',
gender: 'Gender', gender: 'Gender',
area: 'Area', area: 'Area',
lifeSpan: 'Active Period', lifeSpan: 'Active Period',
+3
View File
@@ -434,6 +434,9 @@ export default {
artistAlbums: '艺术家的专辑', artistAlbums: '艺术家的专辑',
artistType: '艺术家类型', artistType: '艺术家类型',
artistNotFound: '没有找到该艺术家', artistNotFound: '没有找到该艺术家',
entityRecording: '单曲',
entityAlbum: '专辑',
entityArtist: '艺术家',
gender: '性别', gender: '性别',
area: '地区', area: '地区',
lifeSpan: '活跃时间', lifeSpan: '活跃时间',
+3
View File
@@ -434,6 +434,9 @@ export default {
artistAlbums: '藝術家的專輯', artistAlbums: '藝術家的專輯',
artistType: '藝術家類型', artistType: '藝術家類型',
artistNotFound: '沒有找到該藝術家', artistNotFound: '沒有找到該藝術家',
entityRecording: '單曲',
entityAlbum: '專輯',
entityArtist: '藝術家',
gender: '性別', gender: '性別',
area: '地區', area: '地區',
lifeSpan: '活躍時間', lifeSpan: '活躍時間',