diff --git a/src/api/constants.ts b/src/api/constants.ts index 4b6744e3..ff9c35ce 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -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 = [ { diff --git a/src/api/types.ts b/src/api/types.ts index c72cadbc..41d70894 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 // 保存目录 @@ -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 // 音频采样率 diff --git a/src/components/cards/MediaInfoCard.vue b/src/components/cards/MediaInfoCard.vue index c49ce57d..039547d6 100644 --- a/src/components/cards/MediaInfoCard.vue +++ b/src/components/cards/MediaInfoCard.vue @@ -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, + }) }) // 音乐详情外链 diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue index 243cd87d..357de605 100644 --- a/src/components/cards/MusicCard.vue +++ b/src/components/cards/MusicCard.vue @@ -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, diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 00b765f4..db150f04 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -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(' · '), } }) diff --git a/src/components/cards/__tests__/MediaInfoCard.spec.ts b/src/components/cards/__tests__/MediaInfoCard.spec.ts index 0cb5352c..e616bc61 100644 --- a/src/components/cards/__tests__/MediaInfoCard.spec.ts +++ b/src/components/cards/__tests__/MediaInfoCard.spec.ts @@ -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', diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts index 57aaf8e8..81d5acba 100644 --- a/src/components/cards/__tests__/SubscribeCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -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: '音乐' }) diff --git a/src/components/dialog/SubscribeEditDialog.vue b/src/components/dialog/SubscribeEditDialog.vue index a1255aa1..676b2977 100644 --- a/src/components/dialog/SubscribeEditDialog.vue +++ b/src/components/dialog/SubscribeEditDialog.vue @@ -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(() => { /> + { /> - + @@ -522,7 +598,7 @@ onMounted(() => { persistent-hint /> - + { 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 () => { diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts index 54ddaaa1..a7d3a15d 100644 --- a/src/composables/__tests__/useMediaSubscribe.spec.ts +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -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' })) diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index ca41081f..07155839 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -273,14 +273,15 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { // 查询系统默认订阅配置。 async function queryDefaultSubscribeConfig(): Promise { 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 diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index d9eafd10..a60afc77 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 0400e97c..74c138f9 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -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 搜索', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index a99d4b02..89e1eda6 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -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 搜索', diff --git a/src/utils/music.ts b/src/utils/music.ts index 5d8ece2f..84110736 100644 --- a/src/utils/music.ts +++ b/src/utils/music.ts @@ -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 diff --git a/src/views/dashboard/DashboardRecentImports.vue b/src/views/dashboard/DashboardRecentImports.vue index 0e516831..89d7d346 100644 --- a/src/views/dashboard/DashboardRecentImports.vue +++ b/src/views/dashboard/DashboardRecentImports.vue @@ -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)) diff --git a/src/views/dashboard/__tests__/DashboardRecentImports.spec.ts b/src/views/dashboard/__tests__/DashboardRecentImports.spec.ts index d31cc5f4..f5c4cae4 100644 --- a/src/views/dashboard/__tests__/DashboardRecentImports.spec.ts +++ b/src/views/dashboard/__tests__/DashboardRecentImports.spec.ts @@ -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() + }) }) diff --git a/src/views/reorganize/TransferHistoryView.vue b/src/views/reorganize/TransferHistoryView.vue index 7873c0d0..6068612f 100644 --- a/src/views/reorganize/TransferHistoryView.vue +++ b/src/views/reorganize/TransferHistoryView.vue @@ -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(' / ') } // 获取存储展示名称,配置缺失时回退到原始存储标识。 diff --git a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts index 306a9e78..a16e6ce6 100644 --- a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts +++ b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts @@ -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>() diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts index bb3dae29..8f3d7862 100644 --- a/tests/support/msw/handlers/subscribe.ts +++ b/tests/support/msw/handlers/subscribe.ts @@ -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)