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
+1
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(','),
},
})
+42 -2
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>
@@ -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"
+21 -4
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>
+28 -7
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"
/>
@@ -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',
})
})
})
+8 -1
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 === '音乐'