feat(music): add audio quality controls

This commit is contained in:
jxxghp
2026-08-10 10:23:02 +08:00
parent c5f7924484
commit bb53a181a9
20 changed files with 547 additions and 68 deletions
+9 -22
View File
@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { formatMusicDuration } from '@/utils/music'
import { formatMusicDuration, getMusicAudioSpecItems } from '@/utils/music'
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
@@ -100,29 +100,16 @@ const musicSummary = computed(() => {
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 mediaInfo = props.context?.media_info
return getMusicAudioSpecItems({
audio_format: metaInfo?.audio_format || mediaInfo?.audio_format,
bit_depth: metaInfo?.bit_depth || mediaInfo?.bit_depth,
sample_rate: metaInfo?.sample_rate || mediaInfo?.sample_rate,
bitrate: metaInfo?.bitrate || mediaInfo?.bitrate,
})
})
// 音乐详情外链
+3
View File
@@ -12,6 +12,7 @@ import {
buildMusicDetailRoute,
buildMusicResourceRoute,
formatMusicDuration,
formatMusicAudioSpecs,
getMusicArtistLinks,
getMusicKey,
} from '@/utils/music'
@@ -58,6 +59,8 @@ const metaItems = computed(() => {
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
const duration = formatMusicDuration(props.music?.duration)
if (duration) items.push({ icon: 'mdi-clock-outline', label: duration })
const audioSpecs = formatMusicAudioSpecs(props.music)
if (audioSpecs) items.push({ icon: 'mdi-waveform', label: audioSpecs })
if (props.music?.track_number)
items.push({
hideOnNarrow: true,
+24 -5
View File
@@ -11,7 +11,7 @@ import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute } from '@/utils/music'
import { buildMusicDetailRoute, formatMusicAudioSpecs, formatMusicBitrate } from '@/utils/music'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -139,19 +139,38 @@ const subscribeProgressText = computed(() => {
// 音乐订阅始终展示实体类型;旧数据缺少 music_type 时按既有单曲语义兼容。
const musicSubscribeMeta = computed(() => {
if (props.media?.type !== '音乐') return null
const currentSpecs = formatMusicAudioSpecs({
audio_format: props.media.current_audio_format,
bit_depth: props.media.current_bit_depth,
sample_rate: props.media.current_sample_rate,
bitrate: props.media.current_bitrate,
})
const selectedQuality = {
hires: t('music.audioQualityHires'),
'hires|lossless': t('music.audioQualityLossless'),
lossy: t('music.audioQualityLossy'),
}[props.media.audio_quality || '']
const selectedFormat = props.media.audio_format
? props.media.audio_format === 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM'
? t('music.audioFormatLossless')
: props.media.audio_format.replaceAll('|', '/')
: ''
const selectedBitrate = props.media.min_bitrate ? `${formatMusicBitrate(props.media.min_bitrate)}` : ''
const qualityText = currentSpecs || [selectedQuality, selectedFormat, selectedBitrate].filter(Boolean).join(' · ')
if (props.media.music_type === 'album') {
const trackCount = props.media.total_tracks
const entityText = trackCount
? `${t('music.entityAlbum')} · ${t('music.trackCount', { count: trackCount })}`
: t('music.entityAlbum')
return {
icon: 'mdi-album',
text: trackCount
? `${t('music.entityAlbum')} · ${t('music.trackCount', { count: trackCount })}`
: t('music.entityAlbum'),
text: [entityText, qualityText].filter(Boolean).join(' · '),
}
}
return {
icon: 'mdi-music-note',
text: t('music.entityRecording'),
text: [t('music.entityRecording'), qualityText].filter(Boolean).join(' · '),
}
})
@@ -50,7 +50,7 @@ describe('MediaInfoCard', () => {
expect(screen.getByText('FLAC')).toBeInTheDocument()
expect(screen.getByText('24-bit')).toBeInTheDocument()
expect(screen.getByText('48 kHz')).toBeInTheDocument()
expect(screen.getByText('1411 kbps')).toBeInTheDocument()
expect(screen.getByText('1,411 kbps')).toBeInTheDocument()
expect(screen.getByText('TWA530505002')).toBeInTheDocument()
expect(document.querySelector('.v-img__img')).toHaveAttribute(
'src',
@@ -194,6 +194,21 @@ describe('SubscribeCard display and progress', () => {
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it('shows the current music quality on a music subscription card', async () => {
await renderCard({
current_audio_format: 'FLAC',
current_bit_depth: 24,
current_bitrate: 2304000,
current_sample_rate: 96000,
music_type: 'album',
name: '高解析专辑',
total_tracks: 11,
type: '音乐',
})
expect(screen.getByText('专辑 · 11 首 · FLAC · 24-bit · 96 kHz · 2,304 kbps')).toBeInTheDocument()
})
it.each([480, 1024])('identifies recording subscriptions at %ipx', async width => {
setViewport(width)
await renderCard({ music_type: 'recording', name: '晴天', total_tracks: undefined, type: '音乐' })
+82 -6
View File
@@ -6,7 +6,16 @@ import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirector
import { useDisplay } from 'vuetify'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import {
qualityOptions,
resolutionOptions,
effectOptions,
audioQualityOptions,
audioFormatOptions,
audioBitrateOptions,
audioBitDepthOptions,
audioSampleRateOptions,
} from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters'
@@ -217,7 +226,8 @@ async function saveDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultMusicSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) {
@@ -248,7 +258,8 @@ async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultMusicSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
@@ -468,6 +479,67 @@ onMounted(() => {
/>
</VCol>
</VRow>
<template v-else>
<VRow>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.audio_quality"
:label="t('dialog.subscribeEdit.audioQuality')"
:items="audioQualityOptions"
:hint="t('dialog.subscribeEdit.audioQualityHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.audio_format"
:label="t('dialog.subscribeEdit.audioFormat')"
:items="audioFormatOptions"
:hint="t('dialog.subscribeEdit.audioFormatHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-file-music-outline"
/>
</VCol>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.min_bitrate"
:label="t('dialog.subscribeEdit.minBitrate')"
:items="audioBitrateOptions"
:hint="t('dialog.subscribeEdit.minBitrateHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-speedometer"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="subscribeForm.min_bit_depth"
:label="t('dialog.subscribeEdit.minBitDepth')"
:items="audioBitDepthOptions"
:hint="t('dialog.subscribeEdit.minBitDepthHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="subscribeForm.min_sample_rate"
:label="t('dialog.subscribeEdit.minSampleRate')"
:items="audioSampleRateOptions"
:hint="t('dialog.subscribeEdit.minSampleRateHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-sine-wave"
/>
</VCol>
</VRow>
</template>
<VRow>
<VCol cols="12">
<VAutocomplete
@@ -505,12 +577,16 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-if="!isMusicSubscribe">
<VRow>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
:label="t('dialog.subscribeEdit.bestVersion')"
:hint="t('dialog.subscribeEdit.bestVersionHint')"
:hint="
isMusicSubscribe
? t('dialog.subscribeEdit.musicBestVersionHint')
: t('dialog.subscribeEdit.bestVersionHint')
"
persistent-hint
/>
</VCol>
@@ -522,7 +598,7 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol v-if="!isMusicSubscribe" cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
:label="t('dialog.subscribeEdit.searchImdbid')"
@@ -219,32 +219,80 @@ describe('SubscribeEditDialog', () => {
expect(events.save).not.toHaveBeenCalled()
})
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
const configRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler(
type,
createSubscribe({ id: 0, show_edit_dialog: false, type }),
200,
configRequested,
),
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
)
useDialogOptions()
const user = userEvent.setup()
const { events } = await renderDialog({ default: true, type })
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
it.each(['电影', '电视剧', '音乐'] as const)(
'loads and saves %s default configuration as an administrator',
async type => {
const configRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler(
type,
createSubscribe({ id: 0, show_edit_dialog: false, type }),
200,
configRequested,
),
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
)
useDialogOptions()
const user = userEvent.setup()
const { events } = await renderDialog({ default: true, type })
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
},
)
it('loads and submits music quality filters and quality upgrades', async () => {
const record = createSubscribe({
audio_format: 'FLAC',
audio_quality: 'hires',
best_version: 1,
current_audio_format: 'MP3',
current_bitrate: 320000,
id: 811,
min_bit_depth: 24,
min_bitrate: 320000,
min_sample_rate: 96000,
music_type: 'album',
name: '音乐音质测试专辑',
tmdbid: 0,
type: '音乐',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(811, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions()
await renderDialog({ subid: 811 })
expect(await screen.findByText('音乐音质测试专辑')).toBeInTheDocument()
expect(screen.getByLabelText('音质等级')).toBeInTheDocument()
expect(screen.getByLabelText('音频格式')).toBeInTheDocument()
expect(screen.getByLabelText('最低码率')).toBeInTheDocument()
expect(screen.getByLabelText('最低位深')).toBeInTheDocument()
expect(screen.getByLabelText('最低采样率')).toBeInTheDocument()
expect(screen.getByLabelText('洗版')).toBeChecked()
expect(screen.queryByLabelText('使用 ImdbID 搜索')).not.toBeInTheDocument()
expect(screen.queryByLabelText('质量')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
audio_format: 'FLAC',
audio_quality: 'hires',
best_version: true,
min_bit_depth: 24,
min_bitrate: 320000,
min_sample_rate: 96000,
type: '音乐',
})
})
it('submits the complete TV editing form and exposes the close action', async () => {