mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 06:57:17 +08:00
feat(music): add audio quality controls
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
// 音乐详情外链
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '音乐' })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user