mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
feat(music): add audio quality controls
This commit is contained in:
@@ -125,6 +125,19 @@ export const innerFilterRules = [
|
|||||||
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
|
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
|
||||||
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
|
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
|
||||||
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
|
{ 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.resolution4k'), value: ' 4K ' },
|
||||||
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
|
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
|
||||||
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
|
{ 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 = [
|
export const mediaTypeOptions = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export interface Subscribe {
|
|||||||
resolution?: string
|
resolution?: string
|
||||||
// 特效
|
// 特效
|
||||||
effect?: 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
|
total_episode?: number
|
||||||
// 开始集数
|
// 开始集数
|
||||||
@@ -88,6 +98,14 @@ export interface Subscribe {
|
|||||||
search_imdbid?: any
|
search_imdbid?: any
|
||||||
// 当前优先级
|
// 当前优先级
|
||||||
current_priority: number
|
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>
|
episode_priority?: Record<string, number>
|
||||||
// 保存目录
|
// 保存目录
|
||||||
@@ -272,6 +290,20 @@ export interface TransferHistory {
|
|||||||
errmsg?: string
|
errmsg?: string
|
||||||
// 日期
|
// 日期
|
||||||
date?: 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
|
src_fileitem?: FileItem
|
||||||
}
|
}
|
||||||
@@ -476,6 +508,22 @@ export interface MediaInfo {
|
|||||||
cover_url?: string
|
cover_url?: string
|
||||||
// ListenBrainz 收听次数
|
// ListenBrainz 收听次数
|
||||||
listen_count?: number
|
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_encode?: string
|
||||||
// 音频格式
|
// 音频格式
|
||||||
audio_format?: string
|
audio_format?: string
|
||||||
|
// 是否无损音频
|
||||||
|
audio_lossless?: boolean
|
||||||
|
// 音质等级
|
||||||
|
audio_quality?: 'hires' | 'lossless' | 'lossy'
|
||||||
|
// 音质洗版分数
|
||||||
|
audio_quality_score?: number
|
||||||
|
// 格式化音频参数
|
||||||
|
audio_specs?: string
|
||||||
// 音频位深
|
// 音频位深
|
||||||
bit_depth?: number
|
bit_depth?: number
|
||||||
// 音频采样率
|
// 音频采样率
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
|
|||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { formatMusicDuration } from '@/utils/music'
|
import { formatMusicDuration, getMusicAudioSpecItems } from '@/utils/music'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -100,29 +100,16 @@ const musicSummary = computed(() => {
|
|||||||
return values.join(' · ')
|
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 musicAudioChips = computed(() => {
|
||||||
const metaInfo = props.context?.meta_info
|
const metaInfo = props.context?.meta_info
|
||||||
return [
|
const mediaInfo = props.context?.media_info
|
||||||
metaInfo?.audio_format?.toUpperCase(),
|
return getMusicAudioSpecItems({
|
||||||
metaInfo?.bit_depth ? `${metaInfo.bit_depth}-bit` : '',
|
audio_format: metaInfo?.audio_format || mediaInfo?.audio_format,
|
||||||
formatSampleRate(metaInfo?.sample_rate),
|
bit_depth: metaInfo?.bit_depth || mediaInfo?.bit_depth,
|
||||||
formatBitrate(metaInfo?.bitrate),
|
sample_rate: metaInfo?.sample_rate || mediaInfo?.sample_rate,
|
||||||
].filter(Boolean)
|
bitrate: metaInfo?.bitrate || mediaInfo?.bitrate,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 音乐详情外链
|
// 音乐详情外链
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
buildMusicDetailRoute,
|
buildMusicDetailRoute,
|
||||||
buildMusicResourceRoute,
|
buildMusicResourceRoute,
|
||||||
formatMusicDuration,
|
formatMusicDuration,
|
||||||
|
formatMusicAudioSpecs,
|
||||||
getMusicArtistLinks,
|
getMusicArtistLinks,
|
||||||
getMusicKey,
|
getMusicKey,
|
||||||
} from '@/utils/music'
|
} from '@/utils/music'
|
||||||
@@ -58,6 +59,8 @@ const metaItems = computed(() => {
|
|||||||
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
|
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
|
||||||
const duration = formatMusicDuration(props.music?.duration)
|
const duration = formatMusicDuration(props.music?.duration)
|
||||||
if (duration) items.push({ icon: 'mdi-clock-outline', label: 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)
|
if (props.music?.track_number)
|
||||||
items.push({
|
items.push({
|
||||||
hideOnNarrow: true,
|
hideOnNarrow: true,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
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 SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||||
@@ -139,19 +139,38 @@ const subscribeProgressText = computed(() => {
|
|||||||
// 音乐订阅始终展示实体类型;旧数据缺少 music_type 时按既有单曲语义兼容。
|
// 音乐订阅始终展示实体类型;旧数据缺少 music_type 时按既有单曲语义兼容。
|
||||||
const musicSubscribeMeta = computed(() => {
|
const musicSubscribeMeta = computed(() => {
|
||||||
if (props.media?.type !== '音乐') return null
|
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') {
|
if (props.media.music_type === 'album') {
|
||||||
const trackCount = props.media.total_tracks
|
const trackCount = props.media.total_tracks
|
||||||
|
const entityText = trackCount
|
||||||
|
? `${t('music.entityAlbum')} · ${t('music.trackCount', { count: trackCount })}`
|
||||||
|
: t('music.entityAlbum')
|
||||||
return {
|
return {
|
||||||
icon: 'mdi-album',
|
icon: 'mdi-album',
|
||||||
text: trackCount
|
text: [entityText, qualityText].filter(Boolean).join(' · '),
|
||||||
? `${t('music.entityAlbum')} · ${t('music.trackCount', { count: trackCount })}`
|
|
||||||
: t('music.entityAlbum'),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: 'mdi-music-note',
|
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('FLAC')).toBeInTheDocument()
|
||||||
expect(screen.getByText('24-bit')).toBeInTheDocument()
|
expect(screen.getByText('24-bit')).toBeInTheDocument()
|
||||||
expect(screen.getByText('48 kHz')).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(screen.getByText('TWA530505002')).toBeInTheDocument()
|
||||||
expect(document.querySelector('.v-img__img')).toHaveAttribute(
|
expect(document.querySelector('.v-img__img')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
|
|||||||
@@ -194,6 +194,21 @@ describe('SubscribeCard display and progress', () => {
|
|||||||
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
|
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 => {
|
it.each([480, 1024])('identifies recording subscriptions at %ipx', async width => {
|
||||||
setViewport(width)
|
setViewport(width)
|
||||||
await renderCard({ music_type: 'recording', name: '晴天', total_tracks: undefined, type: '音乐' })
|
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 { useDisplay } from 'vuetify'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { formatSeason } from '@/@core/utils/formatters'
|
import { formatSeason } from '@/@core/utils/formatters'
|
||||||
@@ -217,7 +226,8 @@ async function saveDefaultSubscribeConfig() {
|
|||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
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)
|
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -248,7 +258,8 @@ async function queryDefaultSubscribeConfig() {
|
|||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
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)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
|
|
||||||
@@ -468,6 +479,67 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</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>
|
<VRow>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VAutocomplete
|
<VAutocomplete
|
||||||
@@ -505,12 +577,16 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow v-if="!isMusicSubscribe">
|
<VRow>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="subscribeForm.best_version"
|
v-model="subscribeForm.best_version"
|
||||||
:label="t('dialog.subscribeEdit.bestVersion')"
|
:label="t('dialog.subscribeEdit.bestVersion')"
|
||||||
:hint="t('dialog.subscribeEdit.bestVersionHint')"
|
:hint="
|
||||||
|
isMusicSubscribe
|
||||||
|
? t('dialog.subscribeEdit.musicBestVersionHint')
|
||||||
|
: t('dialog.subscribeEdit.bestVersionHint')
|
||||||
|
"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
@@ -522,7 +598,7 @@ onMounted(() => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol v-if="!isMusicSubscribe" cols="12" md="4">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="subscribeForm.search_imdbid"
|
v-model="subscribeForm.search_imdbid"
|
||||||
:label="t('dialog.subscribeEdit.searchImdbid')"
|
:label="t('dialog.subscribeEdit.searchImdbid')"
|
||||||
|
|||||||
@@ -219,7 +219,9 @@ describe('SubscribeEditDialog', () => {
|
|||||||
expect(events.save).not.toHaveBeenCalled()
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
|
it.each(['电影', '电视剧', '音乐'] as const)(
|
||||||
|
'loads and saves %s default configuration as an administrator',
|
||||||
|
async type => {
|
||||||
const configRequested = vi.fn()
|
const configRequested = vi.fn()
|
||||||
const saved = vi.fn()
|
const saved = vi.fn()
|
||||||
server.use(
|
server.use(
|
||||||
@@ -245,6 +247,52 @@ describe('SubscribeEditDialog', () => {
|
|||||||
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
|
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
|
||||||
expect(events.save).toHaveBeenCalledOnce()
|
expect(events.save).toHaveBeenCalledOnce()
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
|
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(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 () => {
|
it('submits the complete TV editing form and exposes the close action', async () => {
|
||||||
|
|||||||
@@ -274,7 +274,10 @@ describe('useMediaSubscribe entry flows', () => {
|
|||||||
year: '2003',
|
year: '2003',
|
||||||
})
|
})
|
||||||
const created = vi.fn()
|
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 renderSubscribeHarness({ media })
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||||
@@ -305,7 +308,10 @@ describe('useMediaSubscribe entry flows', () => {
|
|||||||
year: '2003',
|
year: '2003',
|
||||||
})
|
})
|
||||||
const created = vi.fn()
|
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 renderSubscribeHarness({ media })
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||||
|
|||||||
@@ -273,14 +273,15 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
// 查询系统默认订阅配置。
|
// 查询系统默认订阅配置。
|
||||||
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
||||||
if (!options.canSubscribe()) return undefined
|
if (!options.canSubscribe()) return undefined
|
||||||
if (currentMedia()?.type === '音乐') return undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const media = currentMedia()
|
const media = currentMedia()
|
||||||
const subscribeConfigUrl =
|
const subscribeConfigUrl = {
|
||||||
media?.type === '电影'
|
电影: 'system/setting/public/DefaultMovieSubscribeConfig',
|
||||||
? 'system/setting/public/DefaultMovieSubscribeConfig'
|
电视剧: 'system/setting/public/DefaultTvSubscribeConfig',
|
||||||
: 'system/setting/public/DefaultTvSubscribeConfig'
|
音乐: 'system/setting/public/DefaultMusicSubscribeConfig',
|
||||||
|
}[media?.type || '']
|
||||||
|
if (!subscribeConfigUrl) return undefined
|
||||||
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
||||||
|
|
||||||
return result.data?.value
|
return result.data?.value
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ export default {
|
|||||||
h265: 'H265',
|
h265: 'H265',
|
||||||
h264: 'H264',
|
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: {
|
resolutionOptions: {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
'4k': '4K',
|
'4k': '4K',
|
||||||
@@ -445,6 +455,10 @@ export default {
|
|||||||
entityRecording: 'Track',
|
entityRecording: 'Track',
|
||||||
entityAlbum: 'Album',
|
entityAlbum: 'Album',
|
||||||
entityArtist: 'Artist',
|
entityArtist: 'Artist',
|
||||||
|
audioQualityHires: 'Hi-Res',
|
||||||
|
audioQualityLossless: 'Lossless',
|
||||||
|
audioQualityLossy: 'Lossy',
|
||||||
|
audioFormatLossless: 'Lossless Formats',
|
||||||
gender: 'Gender',
|
gender: 'Gender',
|
||||||
area: 'Area',
|
area: 'Area',
|
||||||
lifeSpan: 'Active Period',
|
lifeSpan: 'Active Period',
|
||||||
@@ -1501,6 +1515,19 @@ export default {
|
|||||||
hkVoi: 'Cantonese Dubbing',
|
hkVoi: 'Cantonese Dubbing',
|
||||||
notHkVoi: 'Exclude: Cantonese Dubbing',
|
notHkVoi: 'Exclude: Cantonese Dubbing',
|
||||||
free: 'Promotion: Free',
|
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',
|
resolution4k: 'Resolution: 4K',
|
||||||
resolution1080p: 'Resolution: 1080P',
|
resolution1080p: 'Resolution: 1080P',
|
||||||
resolution720p: 'Resolution: 720P',
|
resolution720p: 'Resolution: 720P',
|
||||||
@@ -3378,6 +3405,16 @@ export default {
|
|||||||
resolutionHint: 'Subscription resource resolution',
|
resolutionHint: 'Subscription resource resolution',
|
||||||
effect: 'Effects',
|
effect: 'Effects',
|
||||||
effectHint: 'Subscription resource 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',
|
subscribeSites: 'Subscription Sites',
|
||||||
subscribeSitesHint: 'Range of sites for subscription, use system settings if none selected',
|
subscribeSitesHint: 'Range of sites for subscription, use system settings if none selected',
|
||||||
downloader: 'Downloader',
|
downloader: 'Downloader',
|
||||||
@@ -3386,6 +3423,7 @@ export default {
|
|||||||
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
|
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
|
||||||
bestVersion: 'Version Upgrade',
|
bestVersion: 'Version Upgrade',
|
||||||
bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',
|
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',
|
bestVersionFull: 'Full Season Upgrade',
|
||||||
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
|
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
|
||||||
searchImdbid: 'Search Using ImdbID',
|
searchImdbid: 'Search Using ImdbID',
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ export default {
|
|||||||
h265: 'H265',
|
h265: 'H265',
|
||||||
h264: 'H264',
|
h264: 'H264',
|
||||||
},
|
},
|
||||||
|
audioQualityOptions: {
|
||||||
|
all: '全部音质',
|
||||||
|
hires: 'Hi-Res 高解析无损',
|
||||||
|
lossless: '无损(含 Hi-Res)',
|
||||||
|
lossy: '有损',
|
||||||
|
},
|
||||||
|
audioFormatOptions: {
|
||||||
|
all: '全部格式',
|
||||||
|
lossless: '全部无损格式',
|
||||||
|
},
|
||||||
resolutionOptions: {
|
resolutionOptions: {
|
||||||
all: '全部',
|
all: '全部',
|
||||||
'4k': '4k',
|
'4k': '4k',
|
||||||
@@ -437,6 +447,10 @@ export default {
|
|||||||
entityRecording: '单曲',
|
entityRecording: '单曲',
|
||||||
entityAlbum: '专辑',
|
entityAlbum: '专辑',
|
||||||
entityArtist: '艺术家',
|
entityArtist: '艺术家',
|
||||||
|
audioQualityHires: 'Hi-Res',
|
||||||
|
audioQualityLossless: '无损',
|
||||||
|
audioQualityLossy: '有损',
|
||||||
|
audioFormatLossless: '无损格式',
|
||||||
gender: '性别',
|
gender: '性别',
|
||||||
area: '地区',
|
area: '地区',
|
||||||
lifeSpan: '活跃时间',
|
lifeSpan: '活跃时间',
|
||||||
@@ -1491,6 +1505,19 @@ export default {
|
|||||||
hkVoi: '粤语配音',
|
hkVoi: '粤语配音',
|
||||||
notHkVoi: '排除: 粤语配音',
|
notHkVoi: '排除: 粤语配音',
|
||||||
free: '促销: 免费',
|
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',
|
resolution4k: '分辨率: 4K',
|
||||||
resolution1080p: '分辨率: 1080P',
|
resolution1080p: '分辨率: 1080P',
|
||||||
resolution720p: '分辨率: 720P',
|
resolution720p: '分辨率: 720P',
|
||||||
@@ -3320,6 +3347,16 @@ export default {
|
|||||||
resolutionHint: '订阅资源分辨率',
|
resolutionHint: '订阅资源分辨率',
|
||||||
effect: '特效',
|
effect: '特效',
|
||||||
effectHint: '订阅资源特效',
|
effectHint: '订阅资源特效',
|
||||||
|
audioQuality: '音质等级',
|
||||||
|
audioQualityHint: '按 Hi-Res、无损或有损等级筛选',
|
||||||
|
audioFormat: '音频格式',
|
||||||
|
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式筛选',
|
||||||
|
minBitrate: '最低码率',
|
||||||
|
minBitrateHint: '资源标题必须声明不低于该码率',
|
||||||
|
minBitDepth: '最低位深',
|
||||||
|
minBitDepthHint: '资源标题必须声明不低于该位深',
|
||||||
|
minSampleRate: '最低采样率',
|
||||||
|
minSampleRateHint: '资源标题必须声明不低于该采样率',
|
||||||
subscribeSites: '订阅站点',
|
subscribeSites: '订阅站点',
|
||||||
subscribeSitesHint: '订阅的站点范围,不选使用系统设置',
|
subscribeSitesHint: '订阅的站点范围,不选使用系统设置',
|
||||||
downloader: '下载器',
|
downloader: '下载器',
|
||||||
@@ -3328,6 +3365,7 @@ export default {
|
|||||||
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
|
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
|
||||||
bestVersion: '洗版',
|
bestVersion: '洗版',
|
||||||
bestVersionHint: '根据洗版优先级进行洗版订阅',
|
bestVersionHint: '根据洗版优先级进行洗版订阅',
|
||||||
|
musicBestVersionHint: '持续下载音质分数更高的版本,达到顶级 Hi-Res 后完成',
|
||||||
bestVersionFull: '全集洗版',
|
bestVersionFull: '全集洗版',
|
||||||
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
|
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
|
||||||
searchImdbid: '使用 ImdbID 搜索',
|
searchImdbid: '使用 ImdbID 搜索',
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ export default {
|
|||||||
h265: 'H265',
|
h265: 'H265',
|
||||||
h264: 'H264',
|
h264: 'H264',
|
||||||
},
|
},
|
||||||
|
audioQualityOptions: {
|
||||||
|
all: '全部音質',
|
||||||
|
hires: 'Hi-Res 高解析無損',
|
||||||
|
lossless: '無損(含 Hi-Res)',
|
||||||
|
lossy: '有損',
|
||||||
|
},
|
||||||
|
audioFormatOptions: {
|
||||||
|
all: '全部格式',
|
||||||
|
lossless: '全部無損格式',
|
||||||
|
},
|
||||||
resolutionOptions: {
|
resolutionOptions: {
|
||||||
all: '全部',
|
all: '全部',
|
||||||
'4k': '4k',
|
'4k': '4k',
|
||||||
@@ -437,6 +447,10 @@ export default {
|
|||||||
entityRecording: '單曲',
|
entityRecording: '單曲',
|
||||||
entityAlbum: '專輯',
|
entityAlbum: '專輯',
|
||||||
entityArtist: '藝術家',
|
entityArtist: '藝術家',
|
||||||
|
audioQualityHires: 'Hi-Res',
|
||||||
|
audioQualityLossless: '無損',
|
||||||
|
audioQualityLossy: '有損',
|
||||||
|
audioFormatLossless: '無損格式',
|
||||||
gender: '性別',
|
gender: '性別',
|
||||||
area: '地區',
|
area: '地區',
|
||||||
lifeSpan: '活躍時間',
|
lifeSpan: '活躍時間',
|
||||||
@@ -1490,6 +1504,19 @@ export default {
|
|||||||
hkVoi: '粵語配音',
|
hkVoi: '粵語配音',
|
||||||
notHkVoi: '排除: 粵語配音',
|
notHkVoi: '排除: 粵語配音',
|
||||||
free: '促銷: 免費',
|
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',
|
resolution4k: '解析度: 4K',
|
||||||
resolution1080p: '解析度: 1080P',
|
resolution1080p: '解析度: 1080P',
|
||||||
resolution720p: '解析度: 720P',
|
resolution720p: '解析度: 720P',
|
||||||
@@ -3319,6 +3346,16 @@ export default {
|
|||||||
resolutionHint: '訂閱資源分辨率',
|
resolutionHint: '訂閱資源分辨率',
|
||||||
effect: '特效',
|
effect: '特效',
|
||||||
effectHint: '訂閱資源特效',
|
effectHint: '訂閱資源特效',
|
||||||
|
audioQuality: '音質等級',
|
||||||
|
audioQualityHint: '按 Hi-Res、無損或有損等級篩選',
|
||||||
|
audioFormat: '音訊格式',
|
||||||
|
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式篩選',
|
||||||
|
minBitrate: '最低碼率',
|
||||||
|
minBitrateHint: '資源標題必須聲明不低於該碼率',
|
||||||
|
minBitDepth: '最低位深',
|
||||||
|
minBitDepthHint: '資源標題必須聲明不低於該位深',
|
||||||
|
minSampleRate: '最低採樣率',
|
||||||
|
minSampleRateHint: '資源標題必須聲明不低於該採樣率',
|
||||||
subscribeSites: '訂閱站點',
|
subscribeSites: '訂閱站點',
|
||||||
subscribeSitesHint: '訂閱的站點範圍,不選使用系統設置',
|
subscribeSitesHint: '訂閱的站點範圍,不選使用系統設置',
|
||||||
downloader: '下載器',
|
downloader: '下載器',
|
||||||
@@ -3327,6 +3364,7 @@ export default {
|
|||||||
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
|
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
|
||||||
bestVersion: '洗版',
|
bestVersion: '洗版',
|
||||||
bestVersionHint: '根據洗版優先級進行洗版訂閱',
|
bestVersionHint: '根據洗版優先級進行洗版訂閱',
|
||||||
|
musicBestVersionHint: '持續下載音質分數更高的版本,達到頂級 Hi-Res 後完成',
|
||||||
bestVersionFull: '全集洗版',
|
bestVersionFull: '全集洗版',
|
||||||
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
|
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
|
||||||
searchImdbid: '使用 ImdbID 搜索',
|
searchImdbid: '使用 ImdbID 搜索',
|
||||||
|
|||||||
@@ -17,6 +17,46 @@ export interface MusicArtistLink {
|
|||||||
id?: string
|
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 {
|
export function getMusicSource(item: MusicRouteTarget): string | undefined {
|
||||||
return item.source || item.media_source
|
return item.source || item.media_source
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TransferHistory } from '@/api/types'
|
|||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { formatMusicAudioSpecs } from '@/utils/music'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function getPosterUrl(item: TransferHistory) {
|
|||||||
/** 组合媒体类型、季集和文件大小作为记录副标题。 */
|
/** 组合媒体类型、季集和文件大小作为记录副标题。 */
|
||||||
function getImportMeta(item: TransferHistory) {
|
function getImportMeta(item: TransferHistory) {
|
||||||
const values = [item.type, item.seasons, item.episodes]
|
const values = [item.type, item.seasons, item.episodes]
|
||||||
|
if (item.type === '音乐') values.push(formatMusicAudioSpecs(item))
|
||||||
const fileSize = Number(item.src_fileitem?.size ?? 0)
|
const fileSize = Number(item.src_fileitem?.size ?? 0)
|
||||||
if (fileSize > 0) values.push(formatFileSize(fileSize))
|
if (fileSize > 0) values.push(formatFileSize(fileSize))
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,26 @@ describe('dashboard recent imports', () => {
|
|||||||
const renderedItem = await screen.findByText('异步入库记录')
|
const renderedItem = await screen.findByText('异步入库记录')
|
||||||
expect(container.querySelector('[data-layout-size-source]')).toContainElement(renderedItem)
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
import { formatMusicAudioSpecs } from '@/utils/music'
|
||||||
|
|
||||||
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
||||||
@@ -1091,7 +1092,7 @@ function getHistoryDisplayTitle(item: TransferHistory) {
|
|||||||
|
|
||||||
// 获取移动端卡片副标题,优先展示二级分类和年份。
|
// 获取移动端卡片副标题,优先展示二级分类和年份。
|
||||||
function getHistorySubtitle(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(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取存储展示名称,配置缺失时回退到原始存储标识。
|
// 获取存储展示名称,配置缺失时回退到原始存储标识。
|
||||||
|
|||||||
@@ -530,6 +530,28 @@ describe('TransferHistoryView', () => {
|
|||||||
expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument()
|
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 () => {
|
it('prevents a mobile request invalidated by a route reset from appending stale records', async () => {
|
||||||
mocks.desktop = false
|
mocks.desktop = false
|
||||||
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
|
|||||||
|
|
||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
export type SubscribeMediaType = '电影' | '电视剧'
|
export type SubscribeMediaType = '电影' | '电视剧' | '音乐'
|
||||||
|
|
||||||
export interface SubscribeMutationResponse {
|
export interface SubscribeMutationResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -24,7 +24,13 @@ export const subscribeApiUrls = {
|
|||||||
create: new URL('subscribe/', API_BASE_URL).href,
|
create: new URL('subscribe/', API_BASE_URL).href,
|
||||||
defaultConfig: (type: SubscribeMediaType, writable = false) =>
|
defaultConfig: (type: SubscribeMediaType, writable = false) =>
|
||||||
new URL(
|
new URL(
|
||||||
`system/setting/${writable ? '' : 'public/'}${type === '电影' ? 'DefaultMovieSubscribeConfig' : 'DefaultTvSubscribeConfig'}`,
|
`system/setting/${writable ? '' : 'public/'}${
|
||||||
|
{
|
||||||
|
电影: 'DefaultMovieSubscribeConfig',
|
||||||
|
电视剧: 'DefaultTvSubscribeConfig',
|
||||||
|
音乐: 'DefaultMusicSubscribeConfig',
|
||||||
|
}[type]
|
||||||
|
}`,
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
).href,
|
).href,
|
||||||
deleteById: (id: number) => new URL(`subscribe/${id}`, 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,
|
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
||||||
list: new URL('subscribe/', API_BASE_URL).href,
|
list: new URL('subscribe/', API_BASE_URL).href,
|
||||||
orderConfig: (type: SubscribeMediaType) =>
|
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,
|
popular: new URL('subscribe/popular', API_BASE_URL).href,
|
||||||
resetById: (id: number) => new URL(`subscribe/reset/${id}`, 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,
|
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), () => {
|
return http.get(subscribeApiUrls.details(id), () => {
|
||||||
onRequest()
|
onRequest()
|
||||||
return jsonResponse(subscribe as unknown as JsonBodyType, status)
|
return jsonResponse(subscribe as unknown as JsonBodyType, status)
|
||||||
|
|||||||
Reference in New Issue
Block a user