mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-24 17:59:40 +08:00
521 lines
17 KiB
Vue
521 lines
17 KiB
Vue
<script lang="ts" setup>
|
||
import { useToast } from 'vue-toastification'
|
||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||
import api from '@/api'
|
||
import { transferTypeOptions } from '@/api/constants'
|
||
import { numberValidator } from '@/@validators'
|
||
import { useDisplay } from 'vuetify'
|
||
import ProgressDialog from './ProgressDialog.vue'
|
||
import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useGlobalSettingsStore } from '@/stores'
|
||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||
import CryptoJS from 'crypto-js'
|
||
|
||
// 国际化
|
||
const { t } = useI18n()
|
||
const { useProgressSSE } = useBackgroundOptimization()
|
||
|
||
// 显示器宽度
|
||
const display = useDisplay()
|
||
|
||
// 输入参数
|
||
const props = defineProps({
|
||
logids: Array<number>,
|
||
items: Array<FileItem>,
|
||
target_storage: String,
|
||
target_path: String,
|
||
})
|
||
|
||
// 从 provide 中获取全局设置
|
||
// 全局设置
|
||
const globalSettingsStore = useGlobalSettingsStore()
|
||
const globalSettings = globalSettingsStore.globalSettings
|
||
|
||
// 当前识别类型
|
||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||
|
||
// 定义事件
|
||
const emit = defineEmits(['done', 'close'])
|
||
|
||
// 生成1到100季的下拉框选项
|
||
const seasonItems = ref(
|
||
Array.from({ length: 101 }, (_, i) => i).map(item => ({
|
||
title: `${t('dialog.subscribeEdit.seasonFormat', { number: item })}`,
|
||
value: item,
|
||
})),
|
||
)
|
||
|
||
// 提示框
|
||
const $toast = useToast()
|
||
|
||
// TMDB选择对话框
|
||
const mediaSelectorDialog = ref(false)
|
||
|
||
// 进度是否激活
|
||
const progressActive = ref(false)
|
||
|
||
// 整理进度条
|
||
const progressDialog = ref(false)
|
||
|
||
// 整理进度文本
|
||
const progressText = ref(t('dialog.reorganize.processing'))
|
||
|
||
// 整理进度
|
||
const progressValue = ref(0)
|
||
|
||
// 进度SSE连接
|
||
const progressSSE = ref<any>(null)
|
||
|
||
// 所有存储
|
||
const storages = ref<StorageConf[]>([])
|
||
|
||
// 查询存储
|
||
async function loadStorages() {
|
||
try {
|
||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||
|
||
storages.value = result.data?.value ?? []
|
||
} catch (error) {
|
||
console.log(error)
|
||
}
|
||
}
|
||
|
||
// 存储字典
|
||
const storageOptions = computed(() => {
|
||
return storages.value.map(item => ({
|
||
title: item.name,
|
||
value: item.type,
|
||
}))
|
||
})
|
||
|
||
// 标题
|
||
const dialogTitle = computed(() => {
|
||
return t('dialog.reorganize.manualTitle')
|
||
})
|
||
|
||
// 副标题
|
||
const dialogSubtitle = computed(() => {
|
||
if (props.items) {
|
||
if (props.items.length > 1) return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
|
||
return t('dialog.reorganize.singleItemTitle', { path: props.items[0].path })
|
||
} else if (props.logids) {
|
||
return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length })
|
||
}
|
||
})
|
||
// 禁用指定集数
|
||
const disableEpisodeDetail = computed(() => {
|
||
if (props.items) {
|
||
if (transferForm.episode_format) return false
|
||
return !(props.items.length === 1 && props.items[0].type !== 'dir')
|
||
}
|
||
})
|
||
|
||
// 表单
|
||
const transferForm = reactive<TransferForm>({
|
||
fileitem: {} as FileItem,
|
||
logid: 0,
|
||
target_storage: props.target_storage ?? 'local',
|
||
transfer_type: '',
|
||
target_path: '',
|
||
min_filesize: 0,
|
||
scrape: false,
|
||
from_history: false,
|
||
})
|
||
|
||
// 所有媒体库目录
|
||
const directories = ref<TransferDirectoryConf[]>([])
|
||
|
||
// 查询目录
|
||
async function loadDirectories() {
|
||
try {
|
||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||
directories.value = result.data?.value ?? []
|
||
} catch (error) {
|
||
console.log(error)
|
||
}
|
||
}
|
||
|
||
// 目的目录下拉框
|
||
const targetDirectories = computed(() => {
|
||
const libraryDirectories = directories.value.map(item => item.library_path)
|
||
return [...new Set(libraryDirectories)]
|
||
})
|
||
|
||
// 监听目的路径变化,配置默认值
|
||
watch(
|
||
() => transferForm.target_path,
|
||
async newPath => {
|
||
if (newPath) {
|
||
const directory = directories.value.find(item => item.library_path === newPath)
|
||
if (directory) {
|
||
transferForm.target_storage = directory.library_storage ?? 'local'
|
||
transferForm.transfer_type = transferForm.transfer_type || directory.transfer_type
|
||
transferForm.scrape = directory.scraping ?? false
|
||
transferForm.library_category_folder = directory.library_category_folder ?? false
|
||
transferForm.library_type_folder = directory.library_type_folder ?? false
|
||
} else {
|
||
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
||
transferForm.scrape = false
|
||
transferForm.library_category_folder = false
|
||
transferForm.library_type_folder = false
|
||
}
|
||
} else {
|
||
// 路径为空时, 恢复到`自动`条件
|
||
transferForm.transfer_type = ''
|
||
transferForm.library_type_folder = undefined
|
||
transferForm.library_category_folder = undefined
|
||
}
|
||
},
|
||
)
|
||
|
||
// 整理文件
|
||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||
transferForm.fileitem = item
|
||
transferForm.logid = 0
|
||
try {
|
||
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
|
||
if (!result.success) $toast.error(result.message)
|
||
else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name }))
|
||
} catch (e) {
|
||
console.log(e)
|
||
}
|
||
}
|
||
|
||
// 整理日志
|
||
async function handleTransferLog(logid: number, background: boolean = false) {
|
||
transferForm.logid = logid
|
||
transferForm.fileitem = {} as FileItem
|
||
try {
|
||
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
|
||
if (!result.success) $toast.error(result.message)
|
||
else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
|
||
} catch (e) {
|
||
console.log(e)
|
||
}
|
||
}
|
||
|
||
// 进度SSE消息处理函数
|
||
function handleProgressMessage(event: MessageEvent) {
|
||
const progress = JSON.parse(event.data)
|
||
if (progress) {
|
||
progressText.value = progress.text
|
||
progressValue.value = progress.value
|
||
}
|
||
}
|
||
|
||
// 使用SSE监听加载进度
|
||
function startLoadingProgress(key: string) {
|
||
progressText.value = t('dialog.reorganize.processing')
|
||
progressActive.value = true
|
||
|
||
// 如果已经有连接,先停止
|
||
if (progressSSE.value) {
|
||
progressSSE.value.stop()
|
||
}
|
||
|
||
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`
|
||
|
||
// 创建新的SSE连接
|
||
progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)
|
||
|
||
progressSSE.value.start()
|
||
}
|
||
|
||
// 停止监听加载进度
|
||
function stopLoadingProgress() {
|
||
progressActive.value = false
|
||
if (progressSSE.value) {
|
||
progressSSE.value.stop()
|
||
progressSSE.value = null
|
||
}
|
||
}
|
||
|
||
// 整理文件
|
||
async function transfer(background: boolean = false) {
|
||
if (!props.logids && !props.items) return
|
||
|
||
// 显示进度条
|
||
progressDialog.value = true
|
||
|
||
// 文件整理
|
||
if (props.items) {
|
||
for (const item of props.items) {
|
||
if (!background) {
|
||
// 如果是文件,计算MD5
|
||
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
|
||
|
||
// 开始监听进度
|
||
startLoadingProgress(key)
|
||
}
|
||
await handleTransfer(item, background)
|
||
}
|
||
}
|
||
|
||
// 日志整理
|
||
if (props.logids) {
|
||
if (!background) {
|
||
// 为日志整理任务开启进度监听
|
||
startLoadingProgress('filetransfer')
|
||
}
|
||
for (const logid of props.logids) {
|
||
await handleTransferLog(logid, background)
|
||
}
|
||
}
|
||
if (!background) {
|
||
// 停止监听进度
|
||
stopLoadingProgress()
|
||
}
|
||
|
||
// 关闭进度条
|
||
progressDialog.value = false
|
||
// 重新加载
|
||
emit('done')
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadDirectories()
|
||
loadStorages()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopLoadingProgress()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||
<VCard>
|
||
<VCardItem class="py-2">
|
||
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
||
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
||
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||
</VCardItem>
|
||
<VDialogCloseBtn @click="emit('close')" />
|
||
<VDivider />
|
||
<VCardText>
|
||
<VForm @submit.prevent="() => {}">
|
||
<VRow>
|
||
<VCol cols="12" md="6">
|
||
<VSelect
|
||
v-model="transferForm.target_storage"
|
||
:items="storageOptions"
|
||
:label="t('dialog.reorganize.targetStorage')"
|
||
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
||
:hint="t('dialog.reorganize.targetStorageHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-harddisk"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6">
|
||
<VSelect
|
||
v-model="transferForm.transfer_type"
|
||
:label="t('dialog.reorganize.transferType')"
|
||
:items="transferTypeOptions"
|
||
:hint="t('dialog.reorganize.transferTypeHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-swap-horizontal"
|
||
>
|
||
<template v-slot:selection="{ item }">
|
||
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
|
||
</template>
|
||
</VSelect>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<VCombobox
|
||
v-model="transferForm.target_path"
|
||
:items="targetDirectories"
|
||
:label="t('dialog.reorganize.targetPath')"
|
||
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
||
:hint="t('dialog.reorganize.targetPathHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-folder-outline"
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
<VRow>
|
||
<VCol cols="12" md="6">
|
||
<VSelect
|
||
v-model="transferForm.type_name"
|
||
: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="6">
|
||
<VTextField
|
||
v-if="mediaSource === 'themoviedb'"
|
||
v-model="transferForm.tmdbid"
|
||
:disabled="transferForm.type_name === ''"
|
||
:label="t('dialog.reorganize.tmdbId')"
|
||
: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"
|
||
/>
|
||
<VTextField
|
||
v-else
|
||
v-model="transferForm.doubanid"
|
||
:disabled="transferForm.type_name === ''"
|
||
:label="t('dialog.reorganize.doubanId')"
|
||
: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>
|
||
<VRow v-show="transferForm.type_name === '电视剧'">
|
||
<VCol cols="12" md="6">
|
||
<VTextField
|
||
v-model="transferForm.episode_group"
|
||
:label="t('dialog.reorganize.episodeGroup')"
|
||
:placeholder="t('dialog.reorganize.episodeGroupPlaceholder')"
|
||
:hint="t('dialog.reorganize.episodeGroupHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-view-list"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="3">
|
||
<VSelect
|
||
v-model.number="transferForm.season"
|
||
:label="t('dialog.reorganize.season')"
|
||
:items="seasonItems"
|
||
:hint="t('dialog.reorganize.seasonHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-calendar"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="3">
|
||
<VTextField
|
||
v-model="transferForm.episode_detail"
|
||
:disabled="disableEpisodeDetail"
|
||
:label="t('dialog.reorganize.episodeDetail')"
|
||
:placeholder="t('dialog.reorganize.episodeDetailPlaceholder')"
|
||
:hint="t('dialog.reorganize.episodeDetailHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-playlist-play"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6">
|
||
<VTextField
|
||
v-model="transferForm.episode_format"
|
||
:label="t('dialog.reorganize.episodeFormat')"
|
||
:placeholder="t('dialog.reorganize.episodeFormatPlaceholder')"
|
||
:hint="t('dialog.reorganize.episodeFormatHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-format-text"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6">
|
||
<VTextField
|
||
v-model="transferForm.episode_offset"
|
||
:label="t('dialog.reorganize.episodeOffset')"
|
||
:placeholder="t('dialog.reorganize.episodeOffsetPlaceholder')"
|
||
:hint="t('dialog.reorganize.episodeOffsetHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-numeric"
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
<VRow>
|
||
<VCol cols="12" md="6">
|
||
<VTextField
|
||
v-model="transferForm.episode_part"
|
||
:label="t('dialog.reorganize.episodePart')"
|
||
:placeholder="t('dialog.reorganize.episodePartPlaceholder')"
|
||
:hint="t('dialog.reorganize.episodePartHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-file-multiple"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6">
|
||
<VTextField
|
||
v-model.number="transferForm.min_filesize"
|
||
:label="t('dialog.reorganize.minFileSize')"
|
||
:rules="[numberValidator]"
|
||
placeholder="0"
|
||
:hint="t('dialog.reorganize.minFileSizeHint')"
|
||
persistent-hint
|
||
prepend-inner-icon="mdi-file-document-outline"
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
<VRow>
|
||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||
<VSwitch
|
||
v-model="transferForm.library_type_folder"
|
||
:label="t('dialog.reorganize.typeFolderOption')"
|
||
:hint="t('dialog.reorganize.typeFolderHint')"
|
||
persistent-hint
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||
<VSwitch
|
||
v-model="transferForm.library_category_folder"
|
||
:label="t('dialog.reorganize.categoryFolderOption')"
|
||
:hint="t('dialog.reorganize.categoryFolderHint')"
|
||
persistent-hint
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6">
|
||
<VSwitch
|
||
v-model="transferForm.scrape"
|
||
:label="t('dialog.reorganize.scrapeOption')"
|
||
:hint="t('dialog.reorganize.scrapeHint')"
|
||
persistent-hint
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12" md="6" v-if="props.logids">
|
||
<VSwitch
|
||
v-model="transferForm.from_history"
|
||
:label="t('dialog.reorganize.fromHistoryOption')"
|
||
:hint="t('dialog.reorganize.fromHistoryHint')"
|
||
persistent-hint
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
</VForm>
|
||
</VCardText>
|
||
<VCardActions class="pt-3">
|
||
<VSpacer />
|
||
<VBtn color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
|
||
{{ t('dialog.reorganize.addToQueue') }}
|
||
</VBtn>
|
||
<VBtn @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
|
||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||
</VBtn>
|
||
</VCardActions>
|
||
</VCard>
|
||
<!-- 手动整理进度框 -->
|
||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||
<!-- TMDB ID搜索框 -->
|
||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||
<MediaIdSelector
|
||
v-if="mediaSource === 'themoviedb'"
|
||
v-model="transferForm.tmdbid"
|
||
@close="mediaSelectorDialog = false"
|
||
:type="mediaSource"
|
||
/>
|
||
<MediaIdSelector
|
||
v-else
|
||
v-model="transferForm.doubanid"
|
||
@close="mediaSelectorDialog = false"
|
||
:type="mediaSource"
|
||
/>
|
||
</VDialog>
|
||
</VDialog>
|
||
</template>
|