mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
feat: support manual media selection for file scraping
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
media_source: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 媒体类型
|
||||
type_name?: string
|
||||
}
|
||||
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
|
||||
187
src/components/dialog/ScrapeDialog.vue
Normal file
187
src/components/dialog/ScrapeDialog.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts" setup>
|
||||
import { numberValidator } from '@/@validators'
|
||||
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'scrape', options: ManualScrapeOptions): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const mediaSourceItems: { title: string; value: MediaDataSource }[] = [
|
||||
{ title: 'TheMovieDb', value: 'themoviedb' },
|
||||
{ title: '豆瓣', value: 'douban' },
|
||||
{ title: 'Bangumi', value: 'bangumi' },
|
||||
{ title: 'AniList', value: 'anilist' },
|
||||
]
|
||||
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const mediaType = ref('')
|
||||
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
|
||||
const mediaId = ref<string | null>(null)
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const dialogSubtitle = computed(() => {
|
||||
if (props.items.length > 1) {
|
||||
return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
|
||||
}
|
||||
return t('dialog.reorganize.singleItemTitle', { path: props.items[0]?.path ?? '' })
|
||||
})
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const normalizedMediaId = mediaId.value?.trim()
|
||||
return !normalizedMediaId || /^\d+$/.test(normalizedMediaId)
|
||||
})
|
||||
|
||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
|
||||
function getDefaultMediaSource(): MediaDataSource {
|
||||
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||
return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||
}
|
||||
|
||||
// 将搜索结果媒体类型映射为手动刮削接口接受的类型名。
|
||||
function resolveMediaType(type?: string) {
|
||||
const normalizedType = type?.trim().toLowerCase()
|
||||
if (['电影', 'movie'].includes(normalizedType ?? '')) return '电影'
|
||||
if (['电视剧', 'tv', 'series'].includes(normalizedType ?? '')) return '电视剧'
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 选择搜索结果后同步媒体类型,减少手动填写出错。
|
||||
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
|
||||
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
|
||||
}
|
||||
|
||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// 提交本次手动刮削的请求级识别条件。
|
||||
function submitScrape() {
|
||||
const normalizedMediaId = mediaId.value?.trim()
|
||||
emit('scrape', {
|
||||
media_source: mediaSource.value,
|
||||
media_id: normalizedMediaId || undefined,
|
||||
type_name: mediaType.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 切换数据源时清空上一来源的原生 ID,避免错用同一编号。
|
||||
watch(mediaSource, () => {
|
||||
mediaId.value = null
|
||||
mediaSelectorDialog.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="45rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-auto-fix" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.manualScrape') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-6">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="mediaType"
|
||||
:label="t('dialog.reorganize.mediaType')"
|
||||
:items="[
|
||||
{ title: t('dialog.reorganize.auto'), value: '' },
|
||||
{ title: t('dialog.reorganize.movie'), value: '电影' },
|
||||
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
|
||||
]"
|
||||
:hint="t('dialog.reorganize.mediaTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="mediaSource"
|
||||
:items="mediaSourceItems"
|
||||
:label="t('dialog.reorganize.mediaSource')"
|
||||
:hint="t('dialog.reorganize.mediaSourceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-database-search"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
:disabled="mediaType === ''"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
class="px-5"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitScrape"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-model="mediaId"
|
||||
:type="mediaSource"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
/>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
79
src/components/dialog/__tests__/ScrapeDialog.spec.ts
Normal file
79
src/components/dialog/__tests__/ScrapeDialog.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import ScrapeDialog from '@/components/dialog/ScrapeDialog.vue'
|
||||
import type { FileItem, ManualScrapeOptions } from '@/api/types'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.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(recognizeSource = 'themoviedb', items?: FileItem[]) {
|
||||
const events = {
|
||||
close: vi.fn(),
|
||||
scrape: vi.fn<(options: ManualScrapeOptions) => void>(),
|
||||
}
|
||||
const result = await renderWithProviders(ScrapeDialog, {
|
||||
global: {
|
||||
components: {
|
||||
VDialogCloseBtn: DialogCloseBtn,
|
||||
},
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
RECOGNIZE_SOURCE: recognizeSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
items: items ?? [{ name: 'Test Movie.mkv', path: '/media/Test Movie.mkv', storage: 'local', type: 'file' }],
|
||||
modelValue: true,
|
||||
onClose: events.close,
|
||||
onScrape: events.scrape,
|
||||
},
|
||||
})
|
||||
|
||||
return { ...result, events }
|
||||
}
|
||||
|
||||
describe('ScrapeDialog', () => {
|
||||
it('uses the configured source while keeping media id optional', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('douban')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
expect(events.scrape).toHaveBeenCalledWith({
|
||||
media_source: 'douban',
|
||||
media_id: undefined,
|
||||
type_name: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the selected media type, source, and native id', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
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('豆瓣编号'), '1295644')
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
expect(events.scrape).toHaveBeenCalledWith({
|
||||
media_source: 'douban',
|
||||
media_id: '1295644',
|
||||
type_name: '电影',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the selected item count for batch scraping', async () => {
|
||||
await renderDialog('themoviedb', [
|
||||
{ name: 'Test Show S01E01.mkv', path: '/tv/Test Show S01E01.mkv', storage: 'local', type: 'file' },
|
||||
{ name: 'Test Show S01E02.mkv', path: '/tv/Test Show S01E02.mkv', storage: 'local', type: 'file' },
|
||||
])
|
||||
|
||||
expect(screen.getByText('共 2 项')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type { PropType } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
import type { Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -18,6 +18,7 @@ const FileRenameDialog = defineAsyncComponent(() => import('../dialog/FileRename
|
||||
const MediaInfoDialog = defineAsyncComponent(() => import('../dialog/MediaInfoDialog.vue'))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||
const ReorganizeDialog = defineAsyncComponent(() => import('../dialog/ReorganizeDialog.vue'))
|
||||
const ScrapeDialog = defineAsyncComponent(() => import('../dialog/ScrapeDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -105,10 +106,12 @@ const currentItem = ref<FileItem>()
|
||||
// 选中的项目
|
||||
const selected = ref<FileItem[]>([])
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? inProps.item.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
}
|
||||
|
||||
// 按存储、类型和路径去重文件项。
|
||||
function dedupeFileItems(fileItems: FileItem[]) {
|
||||
const uniqueItems = new Map<string, FileItem>()
|
||||
fileItems.forEach(item => {
|
||||
@@ -118,6 +121,7 @@ function dedupeFileItems(fileItems: FileItem[]) {
|
||||
return Array.from(uniqueItems.values())
|
||||
}
|
||||
|
||||
// 列表刷新后将选中项同步为最新文件对象。
|
||||
function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
||||
if (!selected.value.length) return
|
||||
|
||||
@@ -129,10 +133,12 @@ function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
||||
|
||||
const selectedKeys = computed(() => new Set(selected.value.map(item => getFileItemKey(item))))
|
||||
|
||||
// 判断文件项当前是否已选中。
|
||||
function isSelected(item: FileItem) {
|
||||
return selectedKeys.value.has(getFileItemKey(item))
|
||||
}
|
||||
|
||||
// 更新单个文件项的选中状态。
|
||||
function setItemSelected(item: FileItem, checked: boolean) {
|
||||
const itemKey = getFileItemKey(item)
|
||||
|
||||
@@ -219,6 +225,7 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 释放当前图片预览使用的临时对象地址。
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
@@ -610,7 +617,7 @@ watch(
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item)
|
||||
showScrape(_item)
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -672,58 +679,67 @@ async function recognize(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API刮削
|
||||
async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
// 调用 API 按请求级媒体条件刮削单个文件项。
|
||||
async function scrape(item: FileItem, options: ManualScrapeOptions) {
|
||||
try {
|
||||
if (confirm) {
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('file.confirmScrape', { path: item.path }),
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
// 显示进度条
|
||||
progressText.value = t('file.scraping', { path: item.path })
|
||||
openProgressDialog(progressText.value)
|
||||
progressDialogController?.updateProps({ text: progressText.value })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item, {
|
||||
params: options,
|
||||
})
|
||||
|
||||
// 关闭进度条
|
||||
closeProgressDialog()
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
|
||||
} catch (error) {
|
||||
closeProgressDialog()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量刮削
|
||||
async function batchScrape() {
|
||||
if (!selected.value.length) return
|
||||
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('file.confirmBatchScrape', { count: selected.value.length }),
|
||||
})
|
||||
if (!confirmed) return
|
||||
// 按同一媒体条件依次刮削选中的文件项。
|
||||
async function scrapeItems(itemsToScrape: FileItem[], options: ManualScrapeOptions) {
|
||||
const normalizedItems = dedupeFileItems(itemsToScrape)
|
||||
if (!normalizedItems.length) return
|
||||
|
||||
progressText.value = t('file.scraping', { path: normalizedItems[0].path })
|
||||
progressValue.value = 0
|
||||
openProgressDialog(progressText.value, progressValue.value)
|
||||
try {
|
||||
const selectedItems = dedupeFileItems(selected.value)
|
||||
|
||||
for (const item of selectedItems) {
|
||||
await scrape(item, false)
|
||||
for (const [index, item] of normalizedItems.entries()) {
|
||||
await scrape(item, options)
|
||||
progressValue.value = Math.round(((index + 1) / normalizedItems.length) * 100)
|
||||
progressDialogController?.updateProps({ value: progressValue.value })
|
||||
}
|
||||
|
||||
exitSelectMode()
|
||||
} finally {
|
||||
closeProgressDialog()
|
||||
if (selectMode.value) exitSelectMode()
|
||||
list_files({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 打开单项手动刮削弹窗。
|
||||
function showScrape(item: FileItem) {
|
||||
openScrapeDialog([item])
|
||||
}
|
||||
|
||||
// 打开批量手动刮削弹窗。
|
||||
function showBatchScrape() {
|
||||
openScrapeDialog(dedupeFileItems(selected.value))
|
||||
}
|
||||
|
||||
// 打开手动刮削弹窗,并将确认结果交给文件列表执行。
|
||||
function openScrapeDialog(itemsToScrape: FileItem[]) {
|
||||
if (!itemsToScrape.length) return
|
||||
openSharedDialog(
|
||||
ScrapeDialog,
|
||||
{ items: itemsToScrape },
|
||||
{
|
||||
scrape: (options: ManualScrapeOptions) => scrapeItems(itemsToScrape, options),
|
||||
},
|
||||
{ closeOn: ['close', 'scrape'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 进度SSE消息处理函数
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
@@ -802,7 +818,7 @@ onUnmounted(() => {
|
||||
</IconBtn>
|
||||
<!-- 批量操作按钮 -->
|
||||
<span v-if="selected.length > 0">
|
||||
<IconBtn @click.stop="batchScrape">
|
||||
<IconBtn @click.stop="showBatchScrape">
|
||||
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showBatchTransfer">
|
||||
@@ -892,7 +908,7 @@ onUnmounted(() => {
|
||||
<IconBtn @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="scrape(item)">
|
||||
<IconBtn @click.stop="showScrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showRenmae(item)">
|
||||
|
||||
@@ -3332,6 +3332,7 @@ export default {
|
||||
recognizing: 'Recognizing {path}...',
|
||||
recognizeFailed: '{path} recognition failed!',
|
||||
scrape: 'Scrape',
|
||||
manualScrape: 'Manual Scrape',
|
||||
scraping: 'Scraping {path}...',
|
||||
scrapeCompleted: '{path} scraping completed!',
|
||||
confirmScrape: 'Are you sure you want to scrape {path}?',
|
||||
|
||||
@@ -3280,6 +3280,7 @@ export default {
|
||||
recognizing: '正在识别 {path}...',
|
||||
recognizeFailed: '{path} 识别失败!',
|
||||
scrape: '刮削',
|
||||
manualScrape: '手动刮削',
|
||||
scraping: '正在刮削 {path}...',
|
||||
scrapeCompleted: '{path} 削刮完成!',
|
||||
confirmScrape: '是否确认刮削 {path}?',
|
||||
|
||||
@@ -3279,6 +3279,7 @@ export default {
|
||||
recognizing: '正在識別 {path}...',
|
||||
recognizeFailed: '{path} 識別失敗!',
|
||||
scrape: '刮削',
|
||||
manualScrape: '手動刮削',
|
||||
scraping: '正在刮削 {path}...',
|
||||
scrapeCompleted: '{path} 削刮完成!',
|
||||
confirmScrape: '是否確認刮削 {path}?',
|
||||
|
||||
Reference in New Issue
Block a user