mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
feat: complete music entity workflows
This commit is contained in:
@@ -9,6 +9,8 @@ export interface ManualScrapeOptions {
|
|||||||
media_id?: string
|
media_id?: string
|
||||||
// 媒体类型
|
// 媒体类型
|
||||||
type_name?: string
|
type_name?: string
|
||||||
|
// 音乐实体类型
|
||||||
|
music_type?: MusicEntityType
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅
|
// 订阅
|
||||||
@@ -1906,6 +1908,8 @@ export interface TransferForm {
|
|||||||
media_source?: MediaDataSource
|
media_source?: MediaDataSource
|
||||||
// 数据源原生ID
|
// 数据源原生ID
|
||||||
media_id?: string | null
|
media_id?: string | null
|
||||||
|
// 音乐实体类型
|
||||||
|
music_type?: Exclude<MusicEntityType, 'artist'> | null
|
||||||
// 季号
|
// 季号
|
||||||
season?: number
|
season?: number
|
||||||
// 类型
|
// 类型
|
||||||
@@ -2132,6 +2136,12 @@ export interface TorrentCacheItem {
|
|||||||
media_year?: string
|
media_year?: string
|
||||||
// 识别的媒体类型
|
// 识别的媒体类型
|
||||||
media_type?: string
|
media_type?: string
|
||||||
|
// 识别结果的数据源
|
||||||
|
media_source?: MediaDataSource
|
||||||
|
// 数据源原生媒体 ID
|
||||||
|
media_id?: string
|
||||||
|
// 音乐实体类型
|
||||||
|
music_type?: Exclude<MusicEntityType, 'artist'>
|
||||||
// 季集信息
|
// 季集信息
|
||||||
season_episode?: string
|
season_episode?: string
|
||||||
// 资源信息
|
// 资源信息
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ function handleSearch() {
|
|||||||
title: props.media?.title,
|
title: props.media?.title,
|
||||||
year: props.media?.year,
|
year: props.media?.year,
|
||||||
season: props.media?.season,
|
season: props.media?.season,
|
||||||
|
...(props.media?.type === '音乐' ? { music_type: props.media.music_type ?? 'recording' } : {}),
|
||||||
sites: selectedSites.value.join(','),
|
sites: selectedSites.value.join(','),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
DownloaderConf,
|
DownloaderConf,
|
||||||
MediaDataSource,
|
MediaDataSource,
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
|
MusicEntityType,
|
||||||
TorrentInfo,
|
TorrentInfo,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
@@ -14,7 +15,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
|||||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
import { isValidMediaSourceId } from '@/utils/mediaId'
|
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
// 多语言支持
|
// 多语言支持
|
||||||
@@ -46,6 +47,7 @@ const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
|
|||||||
const mediaSource = computed<MediaDataSource>(() => {
|
const mediaSource = computed<MediaDataSource>(() => {
|
||||||
const source = props.media?.source as MediaDataSource | undefined
|
const source = props.media?.source as MediaDataSource | undefined
|
||||||
if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return source
|
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)) {
|
if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) {
|
||||||
return globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
return globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,16 @@ const showAdvancedOptions = ref(false)
|
|||||||
// 当前数据源的原生媒体ID
|
// 当前数据源的原生媒体ID
|
||||||
const mediaId = ref<string | undefined>(undefined)
|
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,打开对话框时预填到高级选项中辅助识别。
|
// 音乐媒体自带来源原生 ID,打开对话框时预填到高级选项中辅助识别。
|
||||||
watch(
|
watch(
|
||||||
() => props.media,
|
() => props.media,
|
||||||
@@ -86,10 +98,20 @@ watch(
|
|||||||
if (media?.source && SUPPORTED_MEDIA_SOURCES.includes(media.source) && media.media_id) {
|
if (media?.source && SUPPORTED_MEDIA_SOURCES.includes(media.source) && media.media_id) {
|
||||||
mediaId.value = media.media_id
|
mediaId.value = media.media_id
|
||||||
}
|
}
|
||||||
|
if (media?.music_type === 'recording' || media?.music_type === 'album') {
|
||||||
|
musicType.value = media.music_type
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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标签。
|
// 当前数据源对应的原生ID标签。
|
||||||
const mediaIdLabel = computed(() => {
|
const mediaIdLabel = computed(() => {
|
||||||
const labels: Record<MediaDataSource, string> = {
|
const labels: Record<MediaDataSource, string> = {
|
||||||
@@ -186,6 +208,7 @@ async function addDownload() {
|
|||||||
media_id?: string
|
media_id?: string
|
||||||
media_in?: MediaInfo
|
media_in?: MediaInfo
|
||||||
media_source?: MediaDataSource
|
media_source?: MediaDataSource
|
||||||
|
music_type?: Exclude<MusicEntityType, 'artist'>
|
||||||
save_path: string | null
|
save_path: string | null
|
||||||
torrent_in: TorrentInfo | undefined
|
torrent_in: TorrentInfo | undefined
|
||||||
} = {
|
} = {
|
||||||
@@ -202,6 +225,7 @@ async function addDownload() {
|
|||||||
if (mediaId.value) {
|
if (mediaId.value) {
|
||||||
payload.media_source = mediaSource.value
|
payload.media_source = mediaSource.value
|
||||||
payload.media_id = mediaId.value
|
payload.media_id = mediaId.value
|
||||||
|
if (isMusicSelection.value) payload.music_type = musicType.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = props.media ? 'download/' : 'download/add'
|
const endpoint = props.media ? 'download/' : 'download/add'
|
||||||
@@ -324,6 +348,16 @@ onMounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
<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">
|
<VCol cols="12">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="mediaId"
|
v-model="mediaId"
|
||||||
@@ -351,7 +385,13 @@ onMounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
<!-- 媒体ID选择器 -->
|
<!-- 媒体ID选择器 -->
|
||||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
<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>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -10,23 +11,38 @@ const props = withDefaults(
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
recognizeSource?: string
|
recognizeSource?: string
|
||||||
|
musicType?: Exclude<MusicEntityType, 'artist'>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
itemTitle: '',
|
itemTitle: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
recognizeSource: '',
|
recognizeSource: '',
|
||||||
|
musicType: 'recording',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(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
|
(event: 'update:modelValue', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
|
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
|
||||||
const mediaId = ref<string>()
|
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 }[]>(() => [
|
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||||
@@ -63,6 +79,7 @@ function submitReidentify() {
|
|||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
mediaSource: mediaSource.value,
|
mediaSource: mediaSource.value,
|
||||||
mediaId: mediaId.value?.trim() || undefined,
|
mediaId: mediaId.value?.trim() || undefined,
|
||||||
|
musicType: isMusicSelection.value ? musicType.value : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -91,6 +108,14 @@ function submitReidentify() {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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">
|
<VCol cols="12">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="mediaId"
|
v-model="mediaId"
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ const transferForm = reactive<TransferForm>({
|
|||||||
target_path: initialTargetPath,
|
target_path: initialTargetPath,
|
||||||
media_source: getDefaultMediaSource(),
|
media_source: getDefaultMediaSource(),
|
||||||
media_id: null,
|
media_id: null,
|
||||||
|
music_type: null,
|
||||||
transfer_type: null,
|
transfer_type: null,
|
||||||
min_filesize: 0,
|
min_filesize: 0,
|
||||||
scrape: initialTargetPath ? false : null,
|
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)
|
const typeName = resolveTransferMediaType(item.type)
|
||||||
if (!typeName) return
|
if (!typeName) return
|
||||||
|
|
||||||
transferForm.type_name = typeName
|
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)) {
|
if (typeName === '音乐' && !isMusicMediaSource(transferForm.media_source)) {
|
||||||
transferForm.media_source = 'musicbrainz'
|
transferForm.media_source = 'musicbrainz'
|
||||||
}
|
}
|
||||||
|
transferForm.music_type = typeName === '音乐' ? (transferForm.music_type ?? 'recording') : null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1515,7 +1520,7 @@ onUnmounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="transferForm.type_name"
|
v-model="transferForm.type_name"
|
||||||
:label="t('dialog.reorganize.mediaType')"
|
:label="t('dialog.reorganize.mediaType')"
|
||||||
@@ -1530,7 +1535,7 @@ onUnmounted(() => {
|
|||||||
prepend-inner-icon="mdi-movie-open"
|
prepend-inner-icon="mdi-movie-open"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="transferForm.media_source"
|
v-model="transferForm.media_source"
|
||||||
:items="mediaSourceItems"
|
:items="mediaSourceItems"
|
||||||
@@ -1540,7 +1545,18 @@ onUnmounted(() => {
|
|||||||
prepend-inner-icon="mdi-database-search"
|
prepend-inner-icon="mdi-database-search"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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
|
<VTextField
|
||||||
v-model="transferForm.media_id"
|
v-model="transferForm.media_id"
|
||||||
:disabled="transferForm.type_name === ''"
|
:disabled="transferForm.type_name === ''"
|
||||||
@@ -1882,6 +1898,7 @@ onUnmounted(() => {
|
|||||||
@close="mediaSelectorDialog = false"
|
@close="mediaSelectorDialog = false"
|
||||||
@select="handleMediaSelected"
|
@select="handleMediaSelected"
|
||||||
:type="mediaSource"
|
:type="mediaSource"
|
||||||
|
:music-types="['recording', 'album']"
|
||||||
/>
|
/>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<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 { useGlobalSettingsStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
@@ -38,7 +38,10 @@ const globalSettingsStore = useGlobalSettingsStore()
|
|||||||
const mediaType = ref('')
|
const mediaType = ref('')
|
||||||
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
|
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
|
||||||
const mediaId = ref<string | null>(null)
|
const mediaId = ref<string | null>(null)
|
||||||
|
const musicType = ref<Exclude<MusicEntityType, 'artist'>>('recording')
|
||||||
const mediaSelectorDialog = ref(false)
|
const mediaSelectorDialog = ref(false)
|
||||||
|
const scrapeMusicTypes: MusicEntityType[] = ['recording', 'album']
|
||||||
|
const isMusicSelection = computed(() => mediaType.value === '音乐' || isMusicMediaSource(mediaSource.value))
|
||||||
|
|
||||||
const dialogVisible = computed({
|
const dialogVisible = computed({
|
||||||
get: () => props.modelValue,
|
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
|
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
|
||||||
|
if (item.music_type === 'recording' || item.music_type === 'album') {
|
||||||
|
musicType.value = item.music_type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||||
@@ -104,11 +110,13 @@ function closeDialog() {
|
|||||||
// 提交本次手动刮削的请求级识别条件。
|
// 提交本次手动刮削的请求级识别条件。
|
||||||
function submitScrape() {
|
function submitScrape() {
|
||||||
const normalizedMediaId = mediaId.value?.trim()
|
const normalizedMediaId = mediaId.value?.trim()
|
||||||
emit('scrape', {
|
const options: ManualScrapeOptions = {
|
||||||
media_source: mediaSource.value,
|
media_source: mediaSource.value,
|
||||||
media_id: normalizedMediaId || undefined,
|
media_id: normalizedMediaId || undefined,
|
||||||
type_name: mediaType.value || undefined,
|
type_name: mediaType.value || undefined,
|
||||||
})
|
}
|
||||||
|
if (isMusicSelection.value) options.music_type = musicType.value
|
||||||
|
emit('scrape', options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换数据源时清空上一来源的原生 ID,避免错用同一编号。
|
// 切换数据源时清空上一来源的原生 ID,避免错用同一编号。
|
||||||
@@ -116,6 +124,7 @@ watch(mediaSource, () => {
|
|||||||
mediaId.value = null
|
mediaId.value = null
|
||||||
mediaSelectorDialog.value = false
|
mediaSelectorDialog.value = false
|
||||||
if (isMusicMediaSource(mediaSource.value)) mediaType.value = '音乐'
|
if (isMusicMediaSource(mediaSource.value)) mediaType.value = '音乐'
|
||||||
|
else musicType.value = 'recording'
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(mediaType, type => {
|
watch(mediaType, type => {
|
||||||
@@ -137,7 +146,7 @@ watch(mediaType, type => {
|
|||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText class="pt-6">
|
<VCardText class="pt-6">
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" :md="isMusicSelection ? 3 : 4">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="mediaType"
|
v-model="mediaType"
|
||||||
:label="t('dialog.reorganize.mediaType')"
|
:label="t('dialog.reorganize.mediaType')"
|
||||||
@@ -152,7 +161,7 @@ watch(mediaType, type => {
|
|||||||
prepend-inner-icon="mdi-movie-open"
|
prepend-inner-icon="mdi-movie-open"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" :md="isMusicSelection ? 3 : 4">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="mediaSource"
|
v-model="mediaSource"
|
||||||
:items="mediaSourceItems"
|
:items="mediaSourceItems"
|
||||||
@@ -162,7 +171,18 @@ watch(mediaType, type => {
|
|||||||
prepend-inner-icon="mdi-database-search"
|
prepend-inner-icon="mdi-database-search"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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
|
<VTextField
|
||||||
v-model="mediaId"
|
v-model="mediaId"
|
||||||
:disabled="mediaType === ''"
|
:disabled="mediaType === ''"
|
||||||
@@ -197,6 +217,7 @@ watch(mediaType, type => {
|
|||||||
<MediaIdSelector
|
<MediaIdSelector
|
||||||
v-model="mediaId"
|
v-model="mediaId"
|
||||||
:type="mediaSource"
|
:type="mediaSource"
|
||||||
|
:music-types="scrapeMusicTypes"
|
||||||
@close="mediaSelectorDialog = false"
|
@close="mediaSelectorDialog = false"
|
||||||
@select="handleMediaSelected"
|
@select="handleMediaSelected"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -371,6 +371,29 @@ describe('AddDownloadDialog submissions', () => {
|
|||||||
expect(submitButton).not.toBeDisabled()
|
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 () => {
|
it('uses download/ for an existing media without locking unrelated optional fields', async () => {
|
||||||
const submitted = vi.fn()
|
const submitted = vi.fn()
|
||||||
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted))
|
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 () => {
|
it('recommends an episode format and includes it in the next request', async () => {
|
||||||
const recommendationBodies: unknown[] = []
|
const recommendationBodies: unknown[] = []
|
||||||
const transferBodies: unknown[] = []
|
const transferBodies: unknown[] = []
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('ScrapeDialog', () => {
|
|||||||
media_source: 'musicbrainz',
|
media_source: 'musicbrainz',
|
||||||
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
|
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
|
||||||
type_name: '音乐',
|
type_name: '音乐',
|
||||||
|
music_type: 'recording',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,6 +113,28 @@ describe('ScrapeDialog', () => {
|
|||||||
media_source: 'theaudiodb',
|
media_source: 'theaudiodb',
|
||||||
media_id: '32793500',
|
media_id: '32793500',
|
||||||
type_name: '音乐',
|
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>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { isMusicMediaSource } from '@/utils/mediaId'
|
import { isMusicMediaSource } from '@/utils/mediaId'
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ const { t } = useI18n()
|
|||||||
// 定义输入变量
|
// 定义输入变量
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
type?: MediaDataSource
|
type?: MediaDataSource
|
||||||
|
musicTypes?: MusicEntityType[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
interface MediaSelectorItem {
|
interface MediaSelectorItem {
|
||||||
@@ -22,6 +23,8 @@ interface MediaSelectorItem {
|
|||||||
poster: string
|
poster: string
|
||||||
// 媒体类型
|
// 媒体类型
|
||||||
type?: string
|
type?: string
|
||||||
|
// 音乐实体类型
|
||||||
|
music_type?: MusicEntityType
|
||||||
}
|
}
|
||||||
|
|
||||||
// update:modelValue 事件
|
// update:modelValue 事件
|
||||||
@@ -74,6 +77,9 @@ async function searchMedias() {
|
|||||||
|
|
||||||
// 赋值
|
// 赋值
|
||||||
for (const item of result) {
|
for (const item of result) {
|
||||||
|
if (props.musicTypes?.length && item.music_type && !props.musicTypes.includes(item.music_type)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const mediaId =
|
const mediaId =
|
||||||
item.media_id ||
|
item.media_id ||
|
||||||
item.tmdb_id?.toString() ||
|
item.tmdb_id?.toString() ||
|
||||||
@@ -86,6 +92,7 @@ async function searchMedias() {
|
|||||||
id: mediaId,
|
id: mediaId,
|
||||||
poster: getW500Image(item.cover_url || item.poster_path),
|
poster: getW500Image(item.cover_url || item.poster_path),
|
||||||
type: item.type,
|
type: item.type,
|
||||||
|
music_type: item.music_type,
|
||||||
title: item.year ? `${item.title}(${item.year})` : item.title || '',
|
title: item.year ? `${item.title}(${item.year})` : item.title || '',
|
||||||
overview:
|
overview:
|
||||||
item.type === '音乐'
|
item.type === '音乐'
|
||||||
|
|||||||
@@ -3303,6 +3303,7 @@ export default {
|
|||||||
targetPathPlaceholder: 'Choose Auto or enter a path',
|
targetPathPlaceholder: 'Choose Auto or enter a path',
|
||||||
mediaType: 'Type',
|
mediaType: 'Type',
|
||||||
mediaTypeHint: 'File media type',
|
mediaTypeHint: 'File media type',
|
||||||
|
musicEntity: 'Music Entity',
|
||||||
mediaSource: 'Data Source',
|
mediaSource: 'Data Source',
|
||||||
mediaSourceHint: 'Uses the backend recognition setting by default; switch it for this organization and scrape',
|
mediaSourceHint: 'Uses the backend recognition setting by default; switch it for this organization and scrape',
|
||||||
tmdbId: 'TheMovieDb ID',
|
tmdbId: 'TheMovieDb ID',
|
||||||
|
|||||||
@@ -3248,6 +3248,7 @@ export default {
|
|||||||
targetPathPlaceholder: '选择自动或输入路径',
|
targetPathPlaceholder: '选择自动或输入路径',
|
||||||
mediaType: '类型',
|
mediaType: '类型',
|
||||||
mediaTypeHint: '文件的媒体类型',
|
mediaTypeHint: '文件的媒体类型',
|
||||||
|
musicEntity: '音乐实体',
|
||||||
mediaSource: '数据源',
|
mediaSource: '数据源',
|
||||||
mediaSourceHint: '默认使用后台识别设置,可为本次整理与刮削单独切换',
|
mediaSourceHint: '默认使用后台识别设置,可为本次整理与刮削单独切换',
|
||||||
tmdbId: 'TheMovieDb编号',
|
tmdbId: 'TheMovieDb编号',
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ describe('music detail page', () => {
|
|||||||
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
|
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
|
||||||
source: 'musicbrainz',
|
source: 'musicbrainz',
|
||||||
media_id: 'recording-1',
|
media_id: 'recording-1',
|
||||||
|
music_type: 'recording',
|
||||||
})
|
})
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
|
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
|
||||||
|
|||||||
@@ -325,6 +325,35 @@ const searchRouteCases: SearchRouteCase[] = [
|
|||||||
year: '2025',
|
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',
|
apiEndpoint: 'search/subtitle/title',
|
||||||
apiParams: { keyword: '字幕标题', sites: '2' },
|
apiParams: { keyword: '字幕标题', sites: '2' },
|
||||||
@@ -417,6 +446,7 @@ describe('resource page search flow', () => {
|
|||||||
area: query.area ?? '',
|
area: query.area ?? '',
|
||||||
episode: query.episode ?? '',
|
episode: query.episode ?? '',
|
||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
|
music_type: query.music_type ?? '',
|
||||||
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
|
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
|
||||||
season: query.season ?? '',
|
season: query.season ?? '',
|
||||||
sites: query.sites ?? '',
|
sites: query.sites ?? '',
|
||||||
@@ -447,6 +477,7 @@ describe('resource page search flow', () => {
|
|||||||
area: '',
|
area: '',
|
||||||
episode: '',
|
episode: '',
|
||||||
keyword: '上次关键词',
|
keyword: '上次关键词',
|
||||||
|
music_type: '',
|
||||||
result_type: 'torrent',
|
result_type: 'torrent',
|
||||||
season: '',
|
season: '',
|
||||||
sites: '3',
|
sites: '3',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface SearchParams {
|
|||||||
season: string
|
season: string
|
||||||
episode: string
|
episode: string
|
||||||
sites: string
|
sites: string
|
||||||
|
music_type: string
|
||||||
result_type: string
|
result_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ function createSearchParams(query: LocationQuery): SearchParams {
|
|||||||
season: query?.season?.toString() ?? '',
|
season: query?.season?.toString() ?? '',
|
||||||
episode: query?.episode?.toString() ?? '',
|
episode: query?.episode?.toString() ?? '',
|
||||||
sites: query?.sites?.toString() ?? '',
|
sites: query?.sites?.toString() ?? '',
|
||||||
|
music_type: query?.music_type?.toString() ?? '',
|
||||||
result_type: query?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent',
|
result_type: query?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,7 @@ function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchPar
|
|||||||
season: params?.season?.toString() ?? '',
|
season: params?.season?.toString() ?? '',
|
||||||
episode: params?.episode?.toString() ?? '',
|
episode: params?.episode?.toString() ?? '',
|
||||||
sites: params?.sites?.toString() ?? '',
|
sites: params?.sites?.toString() ?? '',
|
||||||
|
music_type: params?.music_type?.toString() ?? '',
|
||||||
result_type: params?.result_type?.toString() === 'subtitle' ? 'subtitle' : 'torrent',
|
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, 'year', params.year)
|
||||||
setSearchParam(url.searchParams, 'season', params.season)
|
setSearchParam(url.searchParams, 'season', params.season)
|
||||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||||
|
setSearchParam(url.searchParams, 'music_type', params.music_type)
|
||||||
} else {
|
} else {
|
||||||
setSearchParam(url.searchParams, 'keyword', params.keyword)
|
setSearchParam(url.searchParams, 'keyword', params.keyword)
|
||||||
setSearchParam(url.searchParams, 'mtype', params.type)
|
setSearchParam(url.searchParams, 'mtype', params.type)
|
||||||
@@ -848,8 +852,9 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
|
|||||||
area: params.area,
|
area: params.area,
|
||||||
title: params.title,
|
title: params.title,
|
||||||
year: params.year,
|
year: params.year,
|
||||||
season: params.season,
|
...(params.season ? { season: params.season } : {}),
|
||||||
sites: params.sites,
|
...(params.sites ? { sites: params.sites } : {}),
|
||||||
|
...(params.music_type ? { music_type: params.music_type } : {}),
|
||||||
_ts: requestToken,
|
_ts: requestToken,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ describe('media source identity utils', () => {
|
|||||||
expect(isValidMediaSourceId('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
|
expect(isValidMediaSourceId('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
|
||||||
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
|
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
|
||||||
expect(isValidMediaSourceId('1401853', 'doubanmusic')).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)
|
expect(isValidMediaSourceId('not-a-number', 'theaudiodb')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ describe('music utils', () => {
|
|||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
path: '/resource',
|
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])
|
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 {
|
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
|
||||||
const normalized = value?.toString().trim()
|
const normalized = value?.toString().trim()
|
||||||
if (!normalized) return true
|
if (!normalized) return true
|
||||||
@@ -16,5 +16,8 @@ export function isValidMediaSourceId(value: string | number | null | undefined,
|
|||||||
if (source === 'musicbrainz' || MUSICBRAINZ_ID_PATTERN.test(normalized)) {
|
if (source === 'musicbrainz' || MUSICBRAINZ_ID_PATTERN.test(normalized)) {
|
||||||
return 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)
|
return /^\d+$/.test(normalized)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function buildMusicResourceRoute(
|
|||||||
query: {
|
query: {
|
||||||
keyword: `${source}:${item.media_id}`,
|
keyword: `${source}:${item.media_id}`,
|
||||||
type: '音乐',
|
type: '音乐',
|
||||||
|
music_type: (item as MusicRouteTarget).music_type || 'recording',
|
||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year,
|
year: item.year,
|
||||||
area: 'title',
|
area: 'title',
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ async function loadMusicDetail() {
|
|||||||
music.value = await api.post('music/recognize', {
|
music.value = await api.post('music/recognize', {
|
||||||
source: props.source,
|
source: props.source,
|
||||||
media_id: props.mediaid,
|
media_id: props.mediaid,
|
||||||
|
music_type: 'recording',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
|
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
@@ -231,7 +231,8 @@ function openReidentifyDialog(item: TorrentCacheItem) {
|
|||||||
{
|
{
|
||||||
itemTitle: item.title,
|
itemTitle: item.title,
|
||||||
loading: loading.value,
|
loading: loading.value,
|
||||||
recognizeSource: globalSettings.RECOGNIZE_SOURCE,
|
recognizeSource: item.media_source || globalSettings.RECOGNIZE_SOURCE,
|
||||||
|
musicType: item.music_type === 'album' ? 'album' : 'recording',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
close: () => {
|
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
|
if (!currentReidentifyItem.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -256,6 +263,7 @@ async function performReidentify(payload: { mediaSource?: string; mediaId?: stri
|
|||||||
const params: any = {}
|
const params: any = {}
|
||||||
if (payload.mediaSource) params.media_source = payload.mediaSource
|
if (payload.mediaSource) params.media_source = payload.mediaSource
|
||||||
if (payload.mediaId) params.media_id = payload.mediaId
|
if (payload.mediaId) params.media_id = payload.mediaId
|
||||||
|
if (payload.musicType) params.music_type = payload.musicType
|
||||||
|
|
||||||
const res: any = await api.post(
|
const res: any = await api.post(
|
||||||
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,
|
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user