feat: support manual media selection for file scraping

This commit is contained in:
jxxghp
2026-07-21 12:55:14 +08:00
parent 1aa8c9122d
commit 42ba550bb4
7 changed files with 332 additions and 37 deletions

View File

@@ -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

View 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>

View 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()
})
})

View File

@@ -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)">

View File

@@ -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}?',

View File

@@ -3280,6 +3280,7 @@ export default {
recognizing: '正在识别 {path}...',
recognizeFailed: '{path} 识别失败!',
scrape: '刮削',
manualScrape: '手动刮削',
scraping: '正在刮削 {path}...',
scrapeCompleted: '{path} 削刮完成!',
confirmScrape: '是否确认刮削 {path}',

View File

@@ -3279,6 +3279,7 @@ export default {
recognizing: '正在識別 {path}...',
recognizeFailed: '{path} 識別失敗!',
scrape: '刮削',
manualScrape: '手動刮削',
scraping: '正在刮削 {path}...',
scrapeCompleted: '{path} 削刮完成!',
confirmScrape: '是否確認刮削 {path}',