Add one-click Navidrome ingestion workflow

This commit is contained in:
2026-07-19 02:23:41 +08:00
parent 81977a157e
commit ff59f341cf
19 changed files with 2340 additions and 46 deletions
+369
View File
@@ -0,0 +1,369 @@
<template>
<div class="ingest-container">
<el-row :gutter="24">
<!-- 任务控制卡片 -->
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<task-card-header :icon="Upload" title="一键导入" />
</template>
<div class="ingest-description">
<p>自动扫描 Input 目录中的音频文件校验元数据繁简转换转码为 FLAC去重后整理入 Library 目录</p>
</div>
<el-form label-width="100px" label-position="left">
<el-form-item label="输入目录">
<el-input :model-value="config.inputDir" disabled>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="库目录">
<el-input :model-value="config.libraryDir" disabled>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="拒绝目录">
<el-input :model-value="config.rejectedDir" disabled>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-divider />
<el-form-item>
<el-button
type="primary"
:loading="submitting"
:disabled="!canStart"
@click="startIngest"
size="large"
style="width: 100%"
>
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
<span style="margin-left: 4px">{{ submitting ? '导入中...' : '开始一键导入' }}</span>
</el-button>
</el-form-item>
<el-form-item>
<el-button
:disabled="!config.basePath"
@click="loadConfig"
size="default"
style="width: 100%"
>
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">刷新配置</span>
</el-button>
</el-form-item>
</el-form>
<!-- 配置提示 -->
<el-alert
v-if="!config.basePath"
type="warning"
:closable="false"
show-icon
>
<template #title>
请先在<strong>配置</strong>页面设置工作根目录
</template>
</el-alert>
</el-card>
</el-col>
<!-- 任务进度卡片 -->
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<task-card-header :icon="DataLine" title="导入进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
<el-icon class="empty-icon"><Upload /></el-icon>
<p class="empty-text">准备就绪点击开始一键导入...</p>
</div>
<div v-else class="progress-content">
<!-- 统计信息 -->
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : progress.message?.includes('失败') ? 'exception' : undefined"
:stroke-width="12"
:show-text="true"
:format="formatProgress"
/>
</div>
<!-- 当前文件 -->
<div v-if="progress.currentFile" class="current-file-section">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-text">{{ progress.currentFile }}</span>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
:closable="false"
show-icon
>
<template #title>
<span>{{ progress.message }}</span>
</template>
</el-alert>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue';
import { getConfig } from '../api/config';
import type { ConfigResponse } from '../api/config';
import { startIngest as apiStartIngest, getIngestStatus } from '../api/ingest';
import type { IngestStatusResponse } from '../api/ingest';
import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket';
import TaskCardHeader from './common/TaskCardHeader.vue';
import TaskStatsGrid from './common/TaskStatsGrid.vue';
import type { StatItem } from './common/TaskStatsGrid.vue';
const submitting = ref(false);
const taskId = ref<string | null>(null);
// 配置
const config = reactive<{ basePath: string; inputDir: string; libraryDir: string; rejectedDir: string }>({
basePath: '',
inputDir: '',
libraryDir: '',
rejectedDir: ''
});
// 进度
const progress = reactive<ProgressMessage>({
taskId: '',
type: '',
total: 0,
processed: 0,
success: 0,
failed: 0,
currentFile: '',
message: '',
completed: false
});
// 连接状态
const connectionStatus = ref<'connected' | 'connecting' | 'completed' | null>(null);
const canStart = computed(() => !!config.basePath && !submitting.value);
const percentage = computed(() => {
if (!progress.total || !progress.processed) return 0;
if (progress.completed) return 100;
return Math.round((progress.processed / progress.total) * 100);
});
const progressStats = computed((): StatItem[] => {
if (!progress.taskId) return [];
return [
{ label: '已处理', value: progress.processed ?? 0 },
{ label: '已入库', value: progress.ingestedFiles ?? 0, tone: 'success' },
{ label: '重复跳过', value: progress.duplicateFiles ?? 0, tone: 'warning' },
{ label: '缺元数据', value: progress.missingMetadataFiles ?? 0, tone: 'failed' },
{ label: '不可读', value: progress.unreadableFiles ?? 0, tone: 'failed' },
{ label: '转码失败', value: progress.conversionFailedFiles ?? 0, tone: 'failed' },
{ label: '其他拒绝', value: progress.otherRejectedFiles ?? 0, tone: 'failed' },
];
});
function formatProgress() {
if (progress.completed) return '完成';
return `${progress.processed ?? 0} / ${progress.total ?? 0}`;
}
// WebSocket 消息处理
function handleProgressMessage(msg: ProgressMessage) {
Object.assign(progress, msg);
connectionStatus.value = msg.completed ? 'completed' : 'connected';
if (msg.completed) {
submitting.value = false;
}
}
// WebSocket
const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, handleProgressMessage);
// 轮询兜底
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
if (!taskId.value) return;
try {
const resp = await getIngestStatus(taskId.value);
if (resp) {
handleProgressMessage({
taskId: resp.taskId,
type: 'ingest',
total: resp.total,
processed: resp.processed,
success: resp.ingestedFiles,
failed: resp.otherRejectedFiles,
currentFile: '',
message: resp.message,
completed: resp.completed,
ingestedFiles: resp.ingestedFiles,
duplicateFiles: resp.duplicateFiles,
missingMetadataFiles: resp.missingMetadataFiles,
unreadableFiles: resp.unreadableFiles,
conversionFailedFiles: resp.conversionFailedFiles,
otherRejectedFiles: resp.otherRejectedFiles,
});
if (resp.completed) {
stopPolling();
}
}
} catch {
// 忽略轮询错误
}
}, 2000);
}
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// 开始导入
async function startIngest() {
if (submitting.value) return;
submitting.value = true;
try {
const resp = await apiStartIngest();
taskId.value = resp.taskId;
// 重置进度
Object.assign(progress, {
taskId: resp.taskId, type: 'ingest', total: 0, processed: 0,
success: 0, failed: 0, currentFile: '', message: '开始导入任务...',
completed: false, ingestedFiles: 0, duplicateFiles: 0,
missingMetadataFiles: 0, unreadableFiles: 0,
conversionFailedFiles: 0, otherRejectedFiles: 0
});
// 连接 WebSocket
wsConnect();
// 开启轮询兜底
startPolling();
} catch (e: any) {
submitting.value = false;
const msg = e?.response?.data?.message || e?.message || '启动任务失败';
ElMessage.error(msg);
}
}
// 页面挂载恢复:查询是否存在正在运行或最近完成的任务
async function resumeFromCurrentTask() {
try {
const { getIngestCurrentStatus } = await import('../api/ingest');
let resp: IngestStatusResponse | null = null;
try {
resp = await getIngestCurrentStatus();
} catch {
// 没有当前任务或接口不可用
return;
}
if (!resp || (!resp.taskId && !resp.running)) return;
taskId.value = resp.taskId;
submitting.value = resp.running;
// 组装进度消息
handleProgressMessage({
taskId: resp.taskId,
type: 'ingest',
total: resp.total,
processed: resp.processed,
success: resp.ingestedFiles,
failed: resp.otherRejectedFiles,
currentFile: '',
message: resp.message || (resp.running ? '恢复轮询...' : '已完成'),
completed: resp.completed,
ingestedFiles: resp.ingestedFiles,
duplicateFiles: resp.duplicateFiles,
missingMetadataFiles: resp.missingMetadataFiles,
unreadableFiles: resp.unreadableFiles,
conversionFailedFiles: resp.conversionFailedFiles,
otherRejectedFiles: resp.otherRejectedFiles,
});
if (resp.running) {
wsConnect();
startPolling();
}
} catch {
// 恢复失败不阻塞页面
}
}
// 加载配置
async function loadConfig() {
try {
const cfg: ConfigResponse | null = await getConfig();
if (cfg) {
config.basePath = cfg.basePath || '';
config.inputDir = cfg.inputDir || '';
config.libraryDir = cfg.libraryDir || '';
config.rejectedDir = cfg.rejectedDir || '';
}
} catch {
ElMessage.error('加载配置失败');
}
}
onMounted(() => {
loadConfig();
resumeFromCurrentTask();
});
onUnmounted(() => {
if (wsDisconnect) wsDisconnect();
stopPolling();
});
</script>
<style scoped>
@import '../styles/panel-shared.css';
.ingest-description {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
margin-bottom: 16px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
</style>