mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-13 17:44:52 +08:00
feat: complete music entity workflows
This commit is contained in:
@@ -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
|
||||
// 资源信息
|
||||
|
||||
@@ -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(','),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 === '音乐'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3248,6 +3248,7 @@ export default {
|
||||
targetPathPlaceholder: '选择自动或输入路径',
|
||||
mediaType: '类型',
|
||||
mediaTypeHint: '文件的媒体类型',
|
||||
musicEntity: '音乐实体',
|
||||
mediaSource: '数据源',
|
||||
mediaSourceHint: '默认使用后台识别设置,可为本次整理与刮削单独切换',
|
||||
tmdbId: 'TheMovieDb编号',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function isMusicMediaSource(source?: MediaDataSource): boolean {
|
||||
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
|
||||
}
|
||||
|
||||
/** 按媒体数据源校验原生 ID,MusicBrainz 使用 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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user