feat: complete music entity workflows

This commit is contained in:
jxxghp
2026-08-12 06:51:58 +08:00
parent d29033c233
commit 155ecffd89
22 changed files with 324 additions and 23 deletions

View File

@@ -9,6 +9,8 @@ export interface ManualScrapeOptions {
media_id?: string
// 媒体类型
type_name?: string
// 音乐实体类型
music_type?: MusicEntityType
}
// 订阅
@@ -1906,6 +1908,8 @@ export interface TransferForm {
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string | null
// 音乐实体类型
music_type?: Exclude<MusicEntityType, 'artist'> | null
// 季号
season?: number
// 类型
@@ -2132,6 +2136,12 @@ export interface TorrentCacheItem {
media_year?: string
// 识别的媒体类型
media_type?: string
// 识别结果的数据源
media_source?: MediaDataSource
// 数据源原生媒体 ID
media_id?: string
// 音乐实体类型
music_type?: Exclude<MusicEntityType, 'artist'>
// 季集信息
season_episode?: string
// 资源信息

View File

@@ -396,6 +396,7 @@ function handleSearch() {
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
...(props.media?.type === '音乐' ? { music_type: props.media.music_type ?? 'recording' } : {}),
sites: selectedSites.value.join(','),
},
})

View File

@@ -7,6 +7,7 @@ import type {
DownloaderConf,
MediaDataSource,
MediaInfo,
MusicEntityType,
TorrentInfo,
TransferDirectoryConf,
} from '@/api/types'
@@ -14,7 +15,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { isValidMediaSourceId } from '@/utils/mediaId'
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
import { useGlobalSettingsStore } from '@/stores'
// 多语言支持
@@ -46,6 +47,7 @@ const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
const mediaSource = computed<MediaDataSource>(() => {
const source = props.media?.source as MediaDataSource | undefined
if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return source
if (props.torrent?.category === '音乐' || props.torrent?.category === 'music') return 'musicbrainz'
if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) {
return globalSettings.RECOGNIZE_SOURCE as MediaDataSource
}
@@ -79,6 +81,16 @@ const showAdvancedOptions = ref(false)
// 当前数据源的原生媒体ID
const mediaId = ref<string | undefined>(undefined)
// 无完整媒体上下文时,音乐原生 ID 需要实体命名空间才能区分单曲和专辑。
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.media?.music_type === 'album' ? 'album' : 'recording')
const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value))
const musicEntityOptions = computed(() => [
{ title: t('setting.cache.musicType.recording'), value: 'recording' },
{ title: t('setting.cache.musicType.album'), value: 'album' },
])
// 音乐媒体自带来源原生 ID打开对话框时预填到高级选项中辅助识别。
watch(
() => props.media,
@@ -86,10 +98,20 @@ watch(
if (media?.source && SUPPORTED_MEDIA_SOURCES.includes(media.source) && media.media_id) {
mediaId.value = media.media_id
}
if (media?.music_type === 'recording' || media?.music_type === 'album') {
musicType.value = media.music_type
}
},
{ immediate: true },
)
// 同步媒体选择器返回的音乐实体,避免只保存 ID 后默认回落到单曲。
function handleMediaSelected(item: Pick<MediaInfo, 'music_type'>) {
if (item.music_type === 'recording' || item.music_type === 'album') {
musicType.value = item.music_type
}
}
// 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = {
@@ -186,6 +208,7 @@ async function addDownload() {
media_id?: string
media_in?: MediaInfo
media_source?: MediaDataSource
music_type?: Exclude<MusicEntityType, 'artist'>
save_path: string | null
torrent_in: TorrentInfo | undefined
} = {
@@ -202,6 +225,7 @@ async function addDownload() {
if (mediaId.value) {
payload.media_source = mediaSource.value
payload.media_id = mediaId.value
if (isMusicSelection.value) payload.music_type = musicType.value
}
const endpoint = props.media ? 'download/' : 'download/add'
@@ -324,6 +348,16 @@ onMounted(() => {
</VCol>
</VRow>
<VRow v-show="showAdvancedOptions" class="px-5">
<VCol v-if="isMusicSelection" cols="12">
<VSelect
v-model="musicType"
:items="musicEntityOptions"
:label="t('dialog.reorganize.musicEntity')"
prepend-inner-icon="mdi-music-box-multiple-outline"
variant="underlined"
density="comfortable"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaId"
@@ -351,7 +385,13 @@ onMounted(() => {
</VCard>
<!-- 媒体ID选择器 -->
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
<MediaIdSelector
v-model="mediaId"
:type="mediaSource"
:music-types="isMusicSelection ? ['recording', 'album'] : undefined"
@select="handleMediaSelected"
@close="mediaSelectorDialog = false"
/>
</VDialog>
</VDialog>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MediaDataSource } from '@/api/types'
import type { MediaDataSource, MusicEntityType } from '@/api/types'
import { isMusicMediaSource } from '@/utils/mediaId'
const { t } = useI18n()
@@ -10,23 +11,38 @@ const props = withDefaults(
loading?: boolean
modelValue?: boolean
recognizeSource?: string
musicType?: Exclude<MusicEntityType, 'artist'>
}>(),
{
itemTitle: '',
loading: false,
modelValue: true,
recognizeSource: '',
musicType: 'recording',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'confirm', payload: { mediaSource?: MediaDataSource; mediaId?: string }): void
(
event: 'confirm',
payload: {
mediaSource?: MediaDataSource
mediaId?: string
musicType?: Exclude<MusicEntityType, 'artist'>
},
): void
(event: 'update:modelValue', value: boolean): void
}>()
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
const mediaId = ref<string>()
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.musicType)
const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value))
const musicEntityItems = computed(() => [
{ title: t('setting.cache.musicType.recording'), value: 'recording' },
{ title: t('setting.cache.musicType.album'), value: 'album' },
])
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
@@ -63,6 +79,7 @@ function submitReidentify() {
emit('confirm', {
mediaSource: mediaSource.value,
mediaId: mediaId.value?.trim() || undefined,
musicType: isMusicSelection.value ? musicType.value : undefined,
})
}
</script>
@@ -91,6 +108,14 @@ function submitReidentify() {
persistent-hint
/>
</VCol>
<VCol v-if="isMusicSelection" cols="12">
<VSelect
v-model="musicType"
:items="musicEntityItems"
:label="t('dialog.reorganize.musicEntity')"
prepend-inner-icon="mdi-music-box-multiple-outline"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaId"

View File

@@ -331,6 +331,7 @@ const transferForm = reactive<TransferForm>({
target_path: initialTargetPath,
media_source: getDefaultMediaSource(),
media_id: null,
music_type: null,
transfer_type: null,
min_filesize: 0,
scrape: initialTargetPath ? false : null,
@@ -362,11 +363,14 @@ const mediaIdLabel = computed(() => {
})
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
function handleMediaSelected(item: Pick<MediaInfo, 'type' | 'music_type'>) {
const typeName = resolveTransferMediaType(item.type)
if (!typeName) return
transferForm.type_name = typeName
if (item.music_type === 'recording' || item.music_type === 'album') {
transferForm.music_type = item.music_type
}
}
// 所有媒体库目录
@@ -480,6 +484,7 @@ watch(
if (typeName === '音乐' && !isMusicMediaSource(transferForm.media_source)) {
transferForm.media_source = 'musicbrainz'
}
transferForm.music_type = typeName === '音乐' ? (transferForm.music_type ?? 'recording') : null
},
)
@@ -1515,7 +1520,7 @@ onUnmounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
<VSelect
v-model="transferForm.type_name"
:label="t('dialog.reorganize.mediaType')"
@@ -1530,7 +1535,7 @@ onUnmounted(() => {
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
<VSelect
v-model="transferForm.media_source"
:items="mediaSourceItems"
@@ -1540,7 +1545,18 @@ onUnmounted(() => {
prepend-inner-icon="mdi-database-search"
/>
</VCol>
<VCol cols="12" md="4">
<VCol v-if="transferForm.type_name === '音乐'" cols="12" md="3">
<VSelect
v-model="transferForm.music_type"
:label="t('dialog.reorganize.musicEntity')"
:items="[
{ title: t('music.entityRecording'), value: 'recording' },
{ title: t('music.entityAlbum'), value: 'album' },
]"
prepend-inner-icon="mdi-music-box-multiple"
/>
</VCol>
<VCol cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
<VTextField
v-model="transferForm.media_id"
:disabled="transferForm.type_name === ''"
@@ -1882,6 +1898,7 @@ onUnmounted(() => {
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
:type="mediaSource"
:music-types="['recording', 'album']"
/>
</VDialog>
</VDialog>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo, MusicEntityType } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
@@ -38,7 +38,10 @@ const globalSettingsStore = useGlobalSettingsStore()
const mediaType = ref('')
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
const mediaId = ref<string | null>(null)
const musicType = ref<Exclude<MusicEntityType, 'artist'>>('recording')
const mediaSelectorDialog = ref(false)
const scrapeMusicTypes: MusicEntityType[] = ['recording', 'album']
const isMusicSelection = computed(() => mediaType.value === '音乐' || isMusicMediaSource(mediaSource.value))
const dialogVisible = computed({
get: () => props.modelValue,
@@ -91,8 +94,11 @@ function validateMediaId(value?: string | null) {
}
// 选择搜索结果后同步媒体类型,减少手动填写出错。
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
function handleMediaSelected(item: Pick<MediaInfo, 'type' | 'music_type'>) {
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
if (item.music_type === 'recording' || item.music_type === 'album') {
musicType.value = item.music_type
}
}
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
@@ -104,11 +110,13 @@ function closeDialog() {
// 提交本次手动刮削的请求级识别条件。
function submitScrape() {
const normalizedMediaId = mediaId.value?.trim()
emit('scrape', {
const options: ManualScrapeOptions = {
media_source: mediaSource.value,
media_id: normalizedMediaId || undefined,
type_name: mediaType.value || undefined,
})
}
if (isMusicSelection.value) options.music_type = musicType.value
emit('scrape', options)
}
// 切换数据源时清空上一来源的原生 ID避免错用同一编号。
@@ -116,6 +124,7 @@ watch(mediaSource, () => {
mediaId.value = null
mediaSelectorDialog.value = false
if (isMusicMediaSource(mediaSource.value)) mediaType.value = '音乐'
else musicType.value = 'recording'
})
watch(mediaType, type => {
@@ -137,7 +146,7 @@ watch(mediaType, type => {
<VDivider />
<VCardText class="pt-6">
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" :md="isMusicSelection ? 3 : 4">
<VSelect
v-model="mediaType"
:label="t('dialog.reorganize.mediaType')"
@@ -152,7 +161,7 @@ watch(mediaType, type => {
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" :md="isMusicSelection ? 3 : 4">
<VSelect
v-model="mediaSource"
:items="mediaSourceItems"
@@ -162,7 +171,18 @@ watch(mediaType, type => {
prepend-inner-icon="mdi-database-search"
/>
</VCol>
<VCol cols="12" md="4">
<VCol v-if="isMusicSelection" cols="12" md="3">
<VSelect
v-model="musicType"
:label="t('dialog.reorganize.musicEntity')"
:items="[
{ title: t('music.entityRecording'), value: 'recording' },
{ title: t('music.entityAlbum'), value: 'album' },
]"
prepend-inner-icon="mdi-music-box-multiple"
/>
</VCol>
<VCol cols="12" :md="isMusicSelection ? 3 : 4">
<VTextField
v-model="mediaId"
:disabled="mediaType === ''"
@@ -197,6 +217,7 @@ watch(mediaType, type => {
<MediaIdSelector
v-model="mediaId"
:type="mediaSource"
:music-types="scrapeMusicTypes"
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
/>

View File

@@ -371,6 +371,29 @@ describe('AddDownloadDialog submissions', () => {
expect(submitButton).not.toBeDisabled()
})
it('submits the selected album namespace for a music torrent without media context', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({
recognizeSource: 'themoviedb',
torrent: createTorrent({ category: '音乐', title: '周杰伦 - 叶惠美 FLAC' }),
})
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.selectOptions(screen.getByLabelText('音乐实体'), 'album')
await user.type(screen.getByLabelText('MusicBrainz ID'), '977e6978-139d-425c-bb98-6b0c62d1e45e')
await user.click(screen.getByRole('button', { name: '开始下载' }))
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
expect(submitted.mock.calls[0][0]).toMatchObject({
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
media_source: 'musicbrainz',
music_type: 'album',
})
})
it('uses download/ for an existing media without locking unrelated optional fields', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted))

View File

@@ -0,0 +1,43 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import CacheReidentifyDialog from '@/components/dialog/CacheReidentifyDialog.vue'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { describe, expect, it, vi } from 'vitest'
// 渲染缓存重识别弹窗并收集提交事件。
async function renderDialog() {
const confirm = vi.fn()
const result = await renderWithProviders(CacheReidentifyDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
},
props: {
itemTitle: '周杰伦 - 叶惠美 FLAC',
modelValue: true,
musicType: 'album',
recognizeSource: 'musicbrainz',
onConfirm: confirm,
},
})
return { ...result, confirm }
}
describe('CacheReidentifyDialog', () => {
it('submits the music entity namespace with a source-native id', async () => {
const user = userEvent.setup()
const { confirm } = await renderDialog()
expect(screen.getByLabelText('音乐实体')).toBeInTheDocument()
await user.type(screen.getByLabelText('MusicBrainz ID'), '977e6978-139d-425c-bb98-6b0c62d1e45e')
await user.click(screen.getByRole('button', { name: '重新识别' }))
expect(confirm).toHaveBeenCalledWith({
mediaId: '977e6978-139d-425c-bb98-6b0c62d1e45e',
mediaSource: 'musicbrainz',
musicType: 'album',
})
})
})

View File

@@ -734,6 +734,38 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
)
})
it('submits an explicit music entity namespace for manual transfer', async () => {
const bodies: unknown[] = []
server.use(
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
bodies.push(await request.json())
return HttpResponse.json({ success: true })
}),
)
const user = userEvent.setup()
await renderDialog({
items: [createFileItem({ name: '叶惠美', path: '/downloads/叶惠美', type: 'dir' })],
})
await selectOption('类型', 3)
await waitFor(() => expect(screen.getByLabelText('MusicBrainz ID')).toBeInTheDocument())
await selectOption('音乐实体', 1)
await fireEvent.input(screen.getByLabelText('MusicBrainz ID'), {
target: { value: '977e6978-139d-425c-bb98-6b0c62d1e45e' },
})
await user.click(screen.getByRole('button', { name: '加入整理队列' }))
await waitFor(() => expect(bodies).toHaveLength(1))
expect(bodies[0]).toEqual(
expect.objectContaining({
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
media_source: 'musicbrainz',
music_type: 'album',
type_name: '音乐',
}),
)
})
it('recommends an episode format and includes it in the next request', async () => {
const recommendationBodies: unknown[] = []
const transferBodies: unknown[] = []

View File

@@ -92,6 +92,7 @@ describe('ScrapeDialog', () => {
media_source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
type_name: '音乐',
music_type: 'recording',
})
})
@@ -112,6 +113,28 @@ describe('ScrapeDialog', () => {
media_source: 'theaudiodb',
media_id: '32793500',
type_name: '音乐',
music_type: 'recording',
})
})
it('submits an explicit album namespace for music album ids', async () => {
const user = userEvent.setup()
const { events } = await renderDialog('musicbrainz', [
{ name: '叶惠美', path: '/music/叶惠美', storage: 'local', type: 'dir' },
])
await user.click(screen.getByLabelText('类型'))
await user.click(await screen.findByRole('option', { name: '音乐' }))
await user.click(screen.getByLabelText('音乐实体'))
await user.click(await screen.findByRole('option', { name: '专辑' }))
await user.type(screen.getByLabelText('MusicBrainz ID'), '977e6978-139d-425c-bb98-6b0c62d1e45e')
await user.click(screen.getByRole('button', { name: '确认' }))
expect(events.scrape).toHaveBeenCalledWith({
media_source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
type_name: '音乐',
music_type: 'album',
})
})
})

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { MediaDataSource, MediaInfo } from '@/api/types'
import type { MediaDataSource, MediaInfo, MusicEntityType } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { isMusicMediaSource } from '@/utils/mediaId'
@@ -9,6 +9,7 @@ const { t } = useI18n()
// 定义输入变量
const props = defineProps<{
type?: MediaDataSource
musicTypes?: MusicEntityType[]
}>()
interface MediaSelectorItem {
@@ -22,6 +23,8 @@ interface MediaSelectorItem {
poster: string
// 媒体类型
type?: string
// 音乐实体类型
music_type?: MusicEntityType
}
// update:modelValue 事件
@@ -74,6 +77,9 @@ async function searchMedias() {
// 赋值
for (const item of result) {
if (props.musicTypes?.length && item.music_type && !props.musicTypes.includes(item.music_type)) {
continue
}
const mediaId =
item.media_id ||
item.tmdb_id?.toString() ||
@@ -86,6 +92,7 @@ async function searchMedias() {
id: mediaId,
poster: getW500Image(item.cover_url || item.poster_path),
type: item.type,
music_type: item.music_type,
title: item.year ? `${item.title}${item.year}` : item.title || '',
overview:
item.type === '音乐'

View File

@@ -3303,6 +3303,7 @@ export default {
targetPathPlaceholder: 'Choose Auto or enter a path',
mediaType: 'Type',
mediaTypeHint: 'File media type',
musicEntity: 'Music Entity',
mediaSource: 'Data Source',
mediaSourceHint: 'Uses the backend recognition setting by default; switch it for this organization and scrape',
tmdbId: 'TheMovieDb ID',

View File

@@ -3248,6 +3248,7 @@ export default {
targetPathPlaceholder: '选择自动或输入路径',
mediaType: '类型',
mediaTypeHint: '文件的媒体类型',
musicEntity: '音乐实体',
mediaSource: '数据源',
mediaSourceHint: '默认使用后台识别设置,可为本次整理与刮削单独切换',
tmdbId: 'TheMovieDb编号',

View File

@@ -124,6 +124,7 @@ describe('music detail page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
source: 'musicbrainz',
media_id: 'recording-1',
music_type: 'recording',
})
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {

View File

@@ -325,6 +325,35 @@ const searchRouteCases: SearchRouteCase[] = [
year: '2025',
},
},
{
apiEndpoint: 'search/media/musicbrainz:album-1',
apiParams: {
area: 'title',
mtype: '音乐',
music_type: 'album',
title: '叶惠美',
year: '2003',
},
displayTitle: '专辑资源结果',
expectedPath: '/api/v1/search/media/musicbrainz%3Aalbum-1/stream',
query: {
area: 'title',
keyword: 'musicbrainz:album-1',
music_type: 'album',
result_type: 'torrent',
title: '叶惠美',
type: '音乐',
year: '2003',
},
result: createTorrent({ title: '专辑资源结果' }),
streamParams: {
area: 'title',
mtype: '音乐',
music_type: 'album',
title: '叶惠美',
year: '2003',
},
},
{
apiEndpoint: 'search/subtitle/title',
apiParams: { keyword: '字幕标题', sites: '2' },
@@ -417,6 +446,7 @@ describe('resource page search flow', () => {
area: query.area ?? '',
episode: query.episode ?? '',
keyword: query.keyword,
music_type: query.music_type ?? '',
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
season: query.season ?? '',
sites: query.sites ?? '',
@@ -447,6 +477,7 @@ describe('resource page search flow', () => {
area: '',
episode: '',
keyword: '上次关键词',
music_type: '',
result_type: 'torrent',
season: '',
sites: '3',

View File

@@ -52,6 +52,7 @@ interface SearchParams {
season: string
episode: string
sites: string
music_type: string
result_type: string
}
@@ -78,6 +79,7 @@ function createSearchParams(query: LocationQuery): SearchParams {
season: query?.season?.toString() ?? '',
episode: query?.episode?.toString() ?? '',
sites: query?.sites?.toString() ?? '',
music_type: query?.music_type?.toString() ?? '',
result_type: query?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent',
}
}
@@ -92,6 +94,7 @@ function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchPar
season: params?.season?.toString() ?? '',
episode: params?.episode?.toString() ?? '',
sites: params?.sites?.toString() ?? '',
music_type: params?.music_type?.toString() ?? '',
result_type: params?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent',
}
}
@@ -580,6 +583,7 @@ function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
setSearchParam(url.searchParams, 'year', params.year)
setSearchParam(url.searchParams, 'season', params.season)
setSearchParam(url.searchParams, 'sites', params.sites)
setSearchParam(url.searchParams, 'music_type', params.music_type)
} else {
setSearchParam(url.searchParams, 'keyword', params.keyword)
setSearchParam(url.searchParams, 'mtype', params.type)
@@ -848,8 +852,9 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
area: params.area,
title: params.title,
year: params.year,
season: params.season,
sites: params.sites,
...(params.season ? { season: params.season } : {}),
...(params.sites ? { sites: params.sites } : {}),
...(params.music_type ? { music_type: params.music_type } : {}),
_ts: requestToken,
},
})

View File

@@ -10,6 +10,8 @@ describe('media source identity utils', () => {
expect(isValidMediaSourceId('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
expect(isValidMediaSourceId('1401853', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:3', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:track', 'doubanmusic')).toBe(false)
expect(isValidMediaSourceId('not-a-number', 'theaudiodb')).toBe(false)
})
})

View File

@@ -77,7 +77,12 @@ describe('music utils', () => {
),
).toMatchObject({
path: '/resource',
query: { keyword: 'musicbrainz:recording-1', sites: '11,12', type: '音乐' },
query: {
keyword: 'musicbrainz:recording-1',
music_type: 'recording',
sites: '11,12',
type: '音乐',
},
})
})

View File

@@ -8,7 +8,7 @@ export function isMusicMediaSource(source?: MediaDataSource): boolean {
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
}
/** 按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它内置来源使用数字 ID。 */
/** 按媒体数据源校验原生 ID并兼容豆瓣音乐的曲目复合 ID。 */
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
const normalized = value?.toString().trim()
if (!normalized) return true
@@ -16,5 +16,8 @@ export function isValidMediaSourceId(value: string | number | null | undefined,
if (source === 'musicbrainz' || MUSICBRAINZ_ID_PATTERN.test(normalized)) {
return MUSICBRAINZ_ID_PATTERN.test(normalized)
}
if (source === 'doubanmusic' && normalized.includes(':')) {
return /^\d+:\d+$/.test(normalized)
}
return /^\d+$/.test(normalized)
}

View File

@@ -122,6 +122,7 @@ export function buildMusicResourceRoute(
query: {
keyword: `${source}:${item.media_id}`,
type: '音乐',
music_type: (item as MusicRouteTarget).music_type || 'recording',
title: item.title,
year: item.year,
area: 'title',

View File

@@ -87,6 +87,7 @@ async function loadMusicDetail() {
music.value = await api.post('music/recognize', {
source: props.source,
media_id: props.mediaid,
music_type: 'recording',
})
} catch (error) {
console.error(error)

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
import type { MusicEntityType, TorrentCacheData, TorrentCacheItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
@@ -231,7 +231,8 @@ function openReidentifyDialog(item: TorrentCacheItem) {
{
itemTitle: item.title,
loading: loading.value,
recognizeSource: globalSettings.RECOGNIZE_SOURCE,
recognizeSource: item.media_source || globalSettings.RECOGNIZE_SOURCE,
musicType: item.music_type === 'album' ? 'album' : 'recording',
},
{
close: () => {
@@ -247,7 +248,13 @@ function openReidentifyDialog(item: TorrentCacheItem) {
}
/** 执行缓存项重新识别。 */
async function performReidentify(payload: { mediaSource?: string; mediaId?: string } = {}) {
async function performReidentify(
payload: {
mediaSource?: string
mediaId?: string
musicType?: Exclude<MusicEntityType, 'artist'>
} = {},
) {
if (!currentReidentifyItem.value) return
try {
@@ -256,6 +263,7 @@ async function performReidentify(payload: { mediaSource?: string; mediaId?: stri
const params: any = {}
if (payload.mediaSource) params.media_source = payload.mediaSource
if (payload.mediaId) params.media_id = payload.mediaId
if (payload.musicType) params.music_type = payload.musicType
const res: any = await api.post(
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,