diff --git a/src/api/types.ts b/src/api/types.ts index 1cd08659..09046ae2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1154,7 +1154,25 @@ export interface MetaInfo { // 识别的英文名 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 // 识别的开始季 数字 @@ -1187,6 +1205,8 @@ export interface MetaInfo { bit_depth?: number // 音频采样率 sample_rate?: number + // 音频码率 + bitrate?: number // 名称(自动中英文) name: string // SXX-SXX diff --git a/src/components/cards/MediaInfoCard.vue b/src/components/cards/MediaInfoCard.vue index 5fc3d400..15548bed 100644 --- a/src/components/cards/MediaInfoCard.vue +++ b/src/components/cards/MediaInfoCard.vue @@ -2,6 +2,9 @@ import type { PropType } from 'vue' import type { Context } from '@/api/types' import { isNullOrEmptyObject } from '@/@core/utils' +import { formatMusicDuration } from '@/utils/music' + +const { t } = useI18n() // 输入参数 const props = defineProps({ @@ -16,6 +19,9 @@ const isMusic = computed( () => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐', ) +// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。 +const musicCoverLoadError = ref(false) + // TMDB图片转换为w500大小 function getW500Image(url = '') { if (!url) return '' @@ -30,19 +36,81 @@ function openTmdbPage(type: string, tmdbId: number) { 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 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 || '') @@ -50,6 +118,10 @@ const musicLink = computed(() => props.context?.media_info?.detail_link || '') function openMusicDetail() { if (musicLink.value) window.open(musicLink.value, '_blank') } + +watch(musicCover, () => { + musicCoverLoadError.value = false +}) @@ -59,13 +131,16 @@ function openMusicDetail() { v-if="recognizedName" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row" > - + @@ -73,6 +148,15 @@ function openMusicDetail() { + + + - + + + {{ musicArtist }} + + + {{ t('music.album') }}:{{ musicAlbum }} + + + {{ t('music.albumArtist') }}:{{ musicAlbumArtist }} + + + {{ musicSummary }} + + + {{ context?.media_info?.year || context?.meta_info?.year }} @@ -124,39 +222,15 @@ function openMusicDetail() { variant="elevated" class="me-1 mb-1 text-white bg-blue-500" > - {{ context?.media_info?.music_type }} + {{ musicTypeLabel }} - + - {{ context?.media_info?.artist }} - - - - {{ context?.media_info?.album }} - - - - {{ context?.media_info?.album_artist }} - - - - {{ context?.media_info?.release_date }} + {{ context?.media_info?.category || context?.media_info?.album_type }} - {{ context?.meta_info?.audio_format }} - - - {{ [context?.meta_info?.bit_depth, context?.meta_info?.sample_rate].filter(Boolean).join(' kHz ') }} - - - - {{ formatDuration(context?.media_info?.duration) }} - - - - {{ `曲目 ${context?.media_info?.track_number}${context?.media_info?.total_tracks ? ` / ${context?.media_info?.total_tracks}` : ''}` }} + {{ audioChip }} - \ No newline at end of file + + + diff --git a/src/components/cards/__tests__/MediaInfoCard.spec.ts b/src/components/cards/__tests__/MediaInfoCard.spec.ts index 1d5de87c..9e924448 100644 --- a/src/components/cards/__tests__/MediaInfoCard.spec.ts +++ b/src/components/cards/__tests__/MediaInfoCard.spec.ts @@ -9,25 +9,53 @@ describe('MediaInfoCard', () => { props: { context: { meta_info: { - artists: ['周杰伦'], + album: '完美的一天', + artists: ['孙燕姿'], audio_format: 'FLAC', bit_depth: 24, + bitrate: 1411200, + duration: 221, sample_rate: 48000, - title: '晴天', + title: '眼泪成诗', + total_tracks: 10, + track_number: 2, type: '音乐', }, media_info: { - artist: '周杰伦', - title: '晴天', + album: '完美的一天', + 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: '音乐', }, }, }, }) - 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('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() }) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index c4aa4b32..c0e03b0b 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -442,6 +442,9 @@ export default { artistAlbums: "Artist's Albums", artistType: 'Artist Type', artistNotFound: 'Artist not found', + entityRecording: 'Track', + entityAlbum: 'Album', + entityArtist: 'Artist', gender: 'Gender', area: 'Area', lifeSpan: 'Active Period', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 49dd451b..6193b454 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -434,6 +434,9 @@ export default { artistAlbums: '艺术家的专辑', artistType: '艺术家类型', artistNotFound: '没有找到该艺术家', + entityRecording: '单曲', + entityAlbum: '专辑', + entityArtist: '艺术家', gender: '性别', area: '地区', lifeSpan: '活跃时间', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index f2b12c3f..2473253b 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -434,6 +434,9 @@ export default { artistAlbums: '藝術家的專輯', artistType: '藝術家類型', artistNotFound: '沒有找到該藝術家', + entityRecording: '單曲', + entityAlbum: '專輯', + entityArtist: '藝術家', gender: '性別', area: '地區', lifeSpan: '活躍時間',