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

View File

@@ -125,6 +125,19 @@ export const innerFilterRules = [
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.audioHires'), value: ' HIRES ' },
{ title: i18n.global.t('filterRules.audioLossless'), value: ' LOSSLESS ' },
{ title: i18n.global.t('filterRules.audioFlac'), value: ' FLAC ' },
{ title: i18n.global.t('filterRules.audioAlac'), value: ' ALAC ' },
{ title: i18n.global.t('filterRules.audioApe'), value: ' APE ' },
{ title: i18n.global.t('filterRules.audioWav'), value: ' WAV ' },
{ title: i18n.global.t('filterRules.audioDsd'), value: ' DSD ' },
{ title: i18n.global.t('filterRules.audioMp3'), value: ' MP3 ' },
{ title: i18n.global.t('filterRules.audioAac'), value: ' AAC ' },
{ title: i18n.global.t('filterRules.audioOpus'), value: ' OPUS ' },
{ title: i18n.global.t('filterRules.bitrate320'), value: ' BITRATE320 ' },
{ title: i18n.global.t('filterRules.bitrate256'), value: ' BITRATE256 ' },
{ title: i18n.global.t('filterRules.bitrate192'), value: ' BITRATE192 ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
@@ -247,6 +260,46 @@ export const effectOptions = ref([
},
])
// 音乐音质等级选择框数据
export const audioQualityOptions = ref([
{ title: i18n.global.t('audioQualityOptions.all'), value: '' },
{ title: i18n.global.t('audioQualityOptions.hires'), value: 'hires' },
{ title: i18n.global.t('audioQualityOptions.lossless'), value: 'hires|lossless' },
{ title: i18n.global.t('audioQualityOptions.lossy'), value: 'lossy' },
])
// 音频格式选择框数据
export const audioFormatOptions = ref([
{ title: i18n.global.t('audioFormatOptions.all'), value: '' },
{ title: i18n.global.t('audioFormatOptions.lossless'), value: 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM' },
...['DSD', 'FLAC', 'ALAC', 'APE', 'WAV', 'AIFF', 'PCM', 'MP3', 'AAC', 'OGG', 'OPUS', 'WMA'].map(value => ({
title: value,
value,
})),
])
export const audioBitrateOptions = [
{ title: '128 kbps', value: 128000 },
{ title: '192 kbps', value: 192000 },
{ title: '256 kbps', value: 256000 },
{ title: '320 kbps', value: 320000 },
]
export const audioBitDepthOptions = [
{ title: '16-bit', value: 16 },
{ title: '24-bit', value: 24 },
{ title: '32-bit', value: 32 },
]
export const audioSampleRateOptions = [
{ title: '44.1 kHz', value: 44100 },
{ title: '48 kHz', value: 48000 },
{ title: '88.2 kHz', value: 88200 },
{ title: '96 kHz', value: 96000 },
{ title: '176.4 kHz', value: 176400 },
{ title: '192 kHz', value: 192000 },
]
// 媒体类型选项
export const mediaTypeOptions = [
{

View File

@@ -62,6 +62,16 @@ export interface Subscribe {
resolution?: string
// 特效
effect?: string
// 音乐音质等级正则
audio_quality?: string
// 音频格式正则
audio_format?: string
// 最低码率bps
min_bitrate?: number
// 最低位深bit
min_bit_depth?: number
// 最低采样率Hz
min_sample_rate?: number
// 总集数
total_episode?: number
// 开始集数
@@ -88,6 +98,14 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 当前音乐版本格式
current_audio_format?: string
// 当前音乐版本码率bps
current_bitrate?: number
// 当前音乐版本位深bit
current_bit_depth?: number
// 当前音乐版本采样率Hz
current_sample_rate?: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
@@ -272,6 +290,20 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 音乐实体类型
music_type?: MusicEntityType
// 专辑预期总曲目数
total_tracks?: number
// 实际音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 实际位深bit
bit_depth?: number
// 实际采样率Hz
sample_rate?: number
// 实际码率bps
bitrate?: number
// 源文件项
src_fileitem?: FileItem
}
@@ -476,6 +508,22 @@ export interface MediaInfo {
cover_url?: string
// ListenBrainz 收听次数
listen_count?: number
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
}
// 音乐可浏览实体类型
@@ -1209,6 +1257,14 @@ export interface MetaInfo {
audio_encode?: string
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率

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,
})
})
// 音乐详情外链

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,

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(' · '),
}
})

View File

@@ -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',

View File

@@ -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: '音乐' })

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')"

View File

@@ -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 () => {

View File

@@ -274,7 +274,10 @@ describe('useMediaSubscribe entry flows', () => {
year: '2003',
})
const created = vi.fn()
server.use(createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created))
server.use(
createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created),
defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
@@ -305,7 +308,10 @@ describe('useMediaSubscribe entry flows', () => {
year: '2003',
})
const created = vi.fn()
server.use(createSubscribeHandler({ data: { id: 503 }, success: true }, 200, created))
server.use(
createSubscribeHandler({ data: { id: 503 }, success: true }, 200, created),
defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))

View File

@@ -273,14 +273,15 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 查询系统默认订阅配置。
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
if (!options.canSubscribe()) return undefined
if (currentMedia()?.type === '音乐') return undefined
try {
const media = currentMedia()
const subscribeConfigUrl =
media?.type === '电影'
? 'system/setting/public/DefaultMovieSubscribeConfig'
: 'system/setting/public/DefaultTvSubscribeConfig'
const subscribeConfigUrl = {
: 'system/setting/public/DefaultMovieSubscribeConfig',
: 'system/setting/public/DefaultTvSubscribeConfig',
: 'system/setting/public/DefaultMusicSubscribeConfig',
}[media?.type || '']
if (!subscribeConfigUrl) return undefined
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
return result.data?.value

View File

@@ -128,6 +128,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: 'All Audio Qualities',
hires: 'Hi-Res Lossless',
lossless: 'Lossless (including Hi-Res)',
lossy: 'Lossy',
},
audioFormatOptions: {
all: 'All Formats',
lossless: 'All Lossless Formats',
},
resolutionOptions: {
all: 'All',
'4k': '4K',
@@ -445,6 +455,10 @@ export default {
entityRecording: 'Track',
entityAlbum: 'Album',
entityArtist: 'Artist',
audioQualityHires: 'Hi-Res',
audioQualityLossless: 'Lossless',
audioQualityLossy: 'Lossy',
audioFormatLossless: 'Lossless Formats',
gender: 'Gender',
area: 'Area',
lifeSpan: 'Active Period',
@@ -1501,6 +1515,19 @@ export default {
hkVoi: 'Cantonese Dubbing',
notHkVoi: 'Exclude: Cantonese Dubbing',
free: 'Promotion: Free',
audioHires: 'Audio Quality: Hi-Res',
audioLossless: 'Audio Quality: Lossless',
audioFlac: 'Audio Format: FLAC',
audioAlac: 'Audio Format: ALAC',
audioApe: 'Audio Format: APE',
audioWav: 'Audio Format: WAV',
audioDsd: 'Audio Format: DSD',
audioMp3: 'Audio Format: MP3',
audioAac: 'Audio Format: AAC',
audioOpus: 'Audio Format: OPUS',
bitrate320: 'Minimum Bitrate: 320 kbps',
bitrate256: 'Minimum Bitrate: 256 kbps',
bitrate192: 'Minimum Bitrate: 192 kbps',
resolution4k: 'Resolution: 4K',
resolution1080p: 'Resolution: 1080P',
resolution720p: 'Resolution: 720P',
@@ -3378,6 +3405,16 @@ export default {
resolutionHint: 'Subscription resource resolution',
effect: 'Effects',
effectHint: 'Subscription resource effects',
audioQuality: 'Audio Quality',
audioQualityHint: 'Filter by Hi-Res, lossless, or lossy tier',
audioFormat: 'Audio Format',
audioFormatHint: 'Filter by FLAC, ALAC, DSD, MP3, and other formats',
minBitrate: 'Minimum Bitrate',
minBitrateHint: 'Resource titles must declare at least this bitrate',
minBitDepth: 'Minimum Bit Depth',
minBitDepthHint: 'Resource titles must declare at least this bit depth',
minSampleRate: 'Minimum Sample Rate',
minSampleRateHint: 'Resource titles must declare at least this sample rate',
subscribeSites: 'Subscription Sites',
subscribeSitesHint: 'Range of sites for subscription, use system settings if none selected',
downloader: 'Downloader',
@@ -3386,6 +3423,7 @@ export default {
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
bestVersion: 'Version Upgrade',
bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',
musicBestVersionHint: 'Keep downloading higher-scoring audio versions until top-tier Hi-Res is reached',
bestVersionFull: 'Full Season Upgrade',
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
searchImdbid: 'Search Using ImdbID',

View File

@@ -126,6 +126,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: '全部音质',
hires: 'Hi-Res 高解析无损',
lossless: '无损(含 Hi-Res',
lossy: '有损',
},
audioFormatOptions: {
all: '全部格式',
lossless: '全部无损格式',
},
resolutionOptions: {
all: '全部',
'4k': '4k',
@@ -437,6 +447,10 @@ export default {
entityRecording: '单曲',
entityAlbum: '专辑',
entityArtist: '艺术家',
audioQualityHires: 'Hi-Res',
audioQualityLossless: '无损',
audioQualityLossy: '有损',
audioFormatLossless: '无损格式',
gender: '性别',
area: '地区',
lifeSpan: '活跃时间',
@@ -1491,6 +1505,19 @@ export default {
hkVoi: '粤语配音',
notHkVoi: '排除: 粤语配音',
free: '促销: 免费',
audioHires: '音质: Hi-Res',
audioLossless: '音质: 无损',
audioFlac: '音频格式: FLAC',
audioAlac: '音频格式: ALAC',
audioApe: '音频格式: APE',
audioWav: '音频格式: WAV',
audioDsd: '音频格式: DSD',
audioMp3: '音频格式: MP3',
audioAac: '音频格式: AAC',
audioOpus: '音频格式: OPUS',
bitrate320: '最低码率: 320 kbps',
bitrate256: '最低码率: 256 kbps',
bitrate192: '最低码率: 192 kbps',
resolution4k: '分辨率: 4K',
resolution1080p: '分辨率: 1080P',
resolution720p: '分辨率: 720P',
@@ -3320,6 +3347,16 @@ export default {
resolutionHint: '订阅资源分辨率',
effect: '特效',
effectHint: '订阅资源特效',
audioQuality: '音质等级',
audioQualityHint: '按 Hi-Res、无损或有损等级筛选',
audioFormat: '音频格式',
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式筛选',
minBitrate: '最低码率',
minBitrateHint: '资源标题必须声明不低于该码率',
minBitDepth: '最低位深',
minBitDepthHint: '资源标题必须声明不低于该位深',
minSampleRate: '最低采样率',
minSampleRateHint: '资源标题必须声明不低于该采样率',
subscribeSites: '订阅站点',
subscribeSitesHint: '订阅的站点范围,不选使用系统设置',
downloader: '下载器',
@@ -3328,6 +3365,7 @@ export default {
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
bestVersion: '洗版',
bestVersionHint: '根据洗版优先级进行洗版订阅',
musicBestVersionHint: '持续下载音质分数更高的版本,达到顶级 Hi-Res 后完成',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
searchImdbid: '使用 ImdbID 搜索',

View File

@@ -126,6 +126,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: '全部音質',
hires: 'Hi-Res 高解析無損',
lossless: '無損(含 Hi-Res',
lossy: '有損',
},
audioFormatOptions: {
all: '全部格式',
lossless: '全部無損格式',
},
resolutionOptions: {
all: '全部',
'4k': '4k',
@@ -437,6 +447,10 @@ export default {
entityRecording: '單曲',
entityAlbum: '專輯',
entityArtist: '藝術家',
audioQualityHires: 'Hi-Res',
audioQualityLossless: '無損',
audioQualityLossy: '有損',
audioFormatLossless: '無損格式',
gender: '性別',
area: '地區',
lifeSpan: '活躍時間',
@@ -1490,6 +1504,19 @@ export default {
hkVoi: '粵語配音',
notHkVoi: '排除: 粵語配音',
free: '促銷: 免費',
audioHires: '音質: Hi-Res',
audioLossless: '音質: 無損',
audioFlac: '音訊格式: FLAC',
audioAlac: '音訊格式: ALAC',
audioApe: '音訊格式: APE',
audioWav: '音訊格式: WAV',
audioDsd: '音訊格式: DSD',
audioMp3: '音訊格式: MP3',
audioAac: '音訊格式: AAC',
audioOpus: '音訊格式: OPUS',
bitrate320: '最低碼率: 320 kbps',
bitrate256: '最低碼率: 256 kbps',
bitrate192: '最低碼率: 192 kbps',
resolution4k: '解析度: 4K',
resolution1080p: '解析度: 1080P',
resolution720p: '解析度: 720P',
@@ -3319,6 +3346,16 @@ export default {
resolutionHint: '訂閱資源分辨率',
effect: '特效',
effectHint: '訂閱資源特效',
audioQuality: '音質等級',
audioQualityHint: '按 Hi-Res、無損或有損等級篩選',
audioFormat: '音訊格式',
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式篩選',
minBitrate: '最低碼率',
minBitrateHint: '資源標題必須聲明不低於該碼率',
minBitDepth: '最低位深',
minBitDepthHint: '資源標題必須聲明不低於該位深',
minSampleRate: '最低採樣率',
minSampleRateHint: '資源標題必須聲明不低於該採樣率',
subscribeSites: '訂閱站點',
subscribeSitesHint: '訂閱的站點範圍,不選使用系統設置',
downloader: '下載器',
@@ -3327,6 +3364,7 @@ export default {
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
bestVersion: '洗版',
bestVersionHint: '根據洗版優先級進行洗版訂閱',
musicBestVersionHint: '持續下載音質分數更高的版本,達到頂級 Hi-Res 後完成',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
searchImdbid: '使用 ImdbID 搜索',

View File

@@ -17,6 +17,46 @@ export interface MusicArtistLink {
id?: string
}
export interface MusicAudioInfo {
audio_format?: string
audio_lossless?: boolean
audio_quality?: 'hires' | 'lossless' | 'lossy'
audio_specs?: string
bit_depth?: number
sample_rate?: number
bitrate?: number
}
/** 将采样率统一换算为 kHz。 */
export function formatMusicSampleRate(sampleRate?: number): string {
if (!sampleRate) return ''
const value = sampleRate >= 1000 ? sampleRate / 1000 : sampleRate
return `${Number.isInteger(value) ? value : value.toFixed(1)} kHz`
}
/** 将码率统一换算为 kbps。 */
export function formatMusicBitrate(bitrate?: number): string {
if (!bitrate) return ''
const value = bitrate >= 1000 ? Math.round(bitrate / 1000) : bitrate
return `${value.toLocaleString()} kbps`
}
/** 返回识别卡、音乐卡和历史记录共用的音频规格片段。 */
export function getMusicAudioSpecItems(item?: MusicAudioInfo): string[] {
if (!item) return []
return [
item.audio_format?.toUpperCase(),
item.bit_depth ? `${item.bit_depth}-bit` : '',
formatMusicSampleRate(item.sample_rate),
formatMusicBitrate(item.bitrate),
].filter((value): value is string => Boolean(value))
}
/** 返回紧凑的完整音频规格文本,优先使用后端规范化结果。 */
export function formatMusicAudioSpecs(item?: MusicAudioInfo): string {
return item?.audio_specs || getMusicAudioSpecItems(item).join(' · ')
}
/** 返回音乐对象可用于路由和订阅的统一来源。 */
export function getMusicSource(item: MusicRouteTarget): string | undefined {
return item.source || item.media_source

View File

@@ -4,6 +4,7 @@ import type { TransferHistory } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { formatMusicAudioSpecs } from '@/utils/music'
const { t } = useI18n()
@@ -32,6 +33,7 @@ function getPosterUrl(item: TransferHistory) {
/** 组合媒体类型、季集和文件大小作为记录副标题。 */
function getImportMeta(item: TransferHistory) {
const values = [item.type, item.seasons, item.episodes]
if (item.type === '音乐') values.push(formatMusicAudioSpecs(item))
const fileSize = Number(item.src_fileitem?.size ?? 0)
if (fileSize > 0) values.push(formatFileSize(fileSize))

View File

@@ -30,4 +30,26 @@ describe('dashboard recent imports', () => {
const renderedItem = await screen.findByText('异步入库记录')
expect(container.querySelector('[data-layout-size-source]')).toContainElement(renderedItem)
})
it('shows normalized audio specs for recent music imports', async () => {
mocks.apiGet.mockResolvedValue({
data: {
list: [
{
id: 2,
title: '晴天',
type: '音乐',
audio_format: 'FLAC',
bit_depth: 24,
sample_rate: 96_000,
bitrate: 2_304_000,
},
],
},
})
await renderWithProviders(DashboardRecentImports)
expect(await screen.findByText(/FLAC · 24-bit · 96 kHz · 2,304 kbps/)).toBeInTheDocument()
})
})

View File

@@ -19,6 +19,7 @@ import { useGlobalSettingsStore, useUserStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { formatMusicAudioSpecs } from '@/utils/music'
const TransferHistoryDeleteDialog = defineAsyncComponent(
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
@@ -1091,7 +1092,7 @@ function getHistoryDisplayTitle(item: TransferHistory) {
// 获取移动端卡片副标题,优先展示二级分类和年份。
function getHistorySubtitle(item: TransferHistory) {
return [item.category, item.year].filter(Boolean).join(' / ')
return [item.category, item.year, item.type === '音乐' ? formatMusicAudioSpecs(item) : ''].filter(Boolean).join(' / ')
}
// 获取存储展示名称,配置缺失时回退到原始存储标识。

View File

@@ -530,6 +530,28 @@ describe('TransferHistoryView', () => {
expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument()
})
it('shows actual audio specs in mobile music history', async () => {
mocks.desktop = false
const item = createHistory(2, '晴天', {
audio_format: 'FLAC',
bit_depth: 24,
bitrate: 2_304_000,
category: '华语流行',
sample_rate: 96_000,
type: '音乐',
year: '2003',
})
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
return Promise.resolve(historyResponse([item]))
})
await renderHistory()
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
expect(await screen.findByText('华语流行 / 2003 / FLAC · 24-bit · 96 kHz · 2,304 kbps')).toBeInTheDocument()
})
it('prevents a mobile request invalidated by a route reset from appending stale records', async () => {
mocks.desktop = false
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()

View File

@@ -12,7 +12,7 @@ import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export type SubscribeMediaType = '电影' | '电视剧'
export type SubscribeMediaType = '电影' | '电视剧' | '音乐'
export interface SubscribeMutationResponse {
success: boolean
@@ -24,7 +24,13 @@ export const subscribeApiUrls = {
create: new URL('subscribe/', API_BASE_URL).href,
defaultConfig: (type: SubscribeMediaType, writable = false) =>
new URL(
`system/setting/${writable ? '' : 'public/'}${type === '电影' ? 'DefaultMovieSubscribeConfig' : 'DefaultTvSubscribeConfig'}`,
`system/setting/${writable ? '' : 'public/'}${
{
: 'DefaultMovieSubscribeConfig',
: 'DefaultTvSubscribeConfig',
: 'DefaultMusicSubscribeConfig',
}[type]
}`,
API_BASE_URL,
).href,
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
@@ -43,7 +49,10 @@ export const subscribeApiUrls = {
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
list: new URL('subscribe/', API_BASE_URL).href,
orderConfig: (type: SubscribeMediaType) =>
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
new URL(
`user/config/${{ : 'SubscribeMovieOrder', : 'SubscribeTvOrder', : 'SubscribeMusicOrder' }[type]}`,
API_BASE_URL,
).href,
popular: new URL('subscribe/popular', API_BASE_URL).href,
resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href,
searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href,
@@ -318,7 +327,12 @@ export function deleteSubscribeByMediaHandler(
})
}
export function subscribeDetailsHandler(id: number, subscribe: Subscribe, status = 200, onRequest: () => void = () => {}) {
export function subscribeDetailsHandler(
id: number,
subscribe: Subscribe,
status = 200,
onRequest: () => void = () => {},
) {
return http.get(subscribeApiUrls.details(id), () => {
onRequest()
return jsonResponse(subscribe as unknown as JsonBodyType, status)