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
+15 -2
View File
@@ -11,7 +11,7 @@
@select="handleSelect"
>
<el-menu-item
v-for="tab in tabs"
v-for="tab in visibleTabs"
:key="tab.key"
:index="tab.key"
>
@@ -42,6 +42,7 @@
import { computed, defineAsyncComponent, ref } from 'vue';
import type { Component } from 'vue';
const IngestTab = defineAsyncComponent(() => import('./components/IngestTab.vue'));
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
@@ -51,6 +52,7 @@ const MergeTab = defineAsyncComponent(() => import('./components/MergeTab.vue'))
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
type TabKey =
| 'ingest'
| 'aggregate'
| 'convert'
| 'dedup'
@@ -69,6 +71,14 @@ interface TabDefinition {
}
const tabs: TabDefinition[] = [
{
key: 'ingest',
menuLabel: '一键导入',
badge: 'INGEST',
title: '一键导入',
subtitle: '自动扫描、校验、转码、去重、整理入库 —— 一步到位。',
component: IngestTab
},
{
key: 'aggregate',
menuLabel: '01 音频文件汇聚',
@@ -132,7 +142,10 @@ const tabMap = tabs.reduce((acc, tab) => {
return acc;
}, {} as Record<TabKey, TabDefinition>);
const activeKey = ref<TabKey>('aggregate');
const activeKey = ref<TabKey>('ingest');
// 用户可见的导航菜单:仅保留一键导入 + 配置(旧版六步标签隐藏但保留功能)
const visibleTabs = computed(() => tabs.filter(t => t.key === 'ingest' || t.key === 'settings'));
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
const currentComponent = computed(() => currentTab.value.component);
+41
View File
@@ -0,0 +1,41 @@
import request from './request';
export interface IngestResponse {
taskId: string;
}
export interface IngestStatusResponse {
taskId: string;
running: boolean;
completed: boolean;
total: number;
processed: number;
ingestedFiles: number;
duplicateFiles: number;
missingMetadataFiles: number;
unreadableFiles: number;
conversionFailedFiles: number;
otherRejectedFiles: number;
message: string;
}
/**
* 启动一键导入任务
*/
export function startIngest(): Promise<IngestResponse> {
return request.post('/api/ingest/start', {});
}
/**
* 获取当前/最近一次导入任务状态
*/
export function getIngestCurrentStatus(): Promise<IngestStatusResponse> {
return request.get('/api/ingest/status');
}
/**
* 获取导入任务状态
*/
export function getIngestStatus(taskId: string): Promise<IngestStatusResponse> {
return request.get(`/api/ingest/status/${taskId}`);
}
+7
View File
@@ -20,6 +20,13 @@ export interface ProgressMessage {
tracksMerged?: number;
upgradedFiles?: number;
skippedFiles?: number;
// ingest 任务字段
ingestedFiles?: number;
duplicateFiles?: number;
missingMetadataFiles?: number;
unreadableFiles?: number;
conversionFailedFiles?: number;
otherRejectedFiles?: number;
}
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
+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>
+20 -1
View File
@@ -29,7 +29,22 @@
<el-divider />
<h4 class="preview-title">派生目录预览</h4>
<h4 class="preview-title">派生目录预览简化模型</h4>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="Input">
<span class="path-text">{{ preview.input || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Library">
<span class="path-text">{{ preview.library || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Rejected">
<span class="path-text">{{ preview.rejected || '未配置' }}</span>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h4 class="preview-title">派生目录预览旧版向后兼容</h4>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="Input (SRC_ACC_DIR)">
<span class="path-text">{{ preview.input || '未配置' }}</span>
@@ -91,6 +106,8 @@ const preview = computed(() => {
return {
input: '',
aggregated: '',
library: '',
rejected: '',
formatIssues: '',
duplicates: '',
zhOutput: '',
@@ -101,6 +118,8 @@ const preview = computed(() => {
return {
input: `${root}/Input`,
aggregated: `${root}/Staging_Aggregated`,
library: `${root}/Library`,
rejected: `${root}/Rejected`,
formatIssues: `${root}/Staging_Format_Issues`,
duplicates: `${root}/Staging_Duplicates`,
zhOutput: `${root}/Staging_T2S_Output`,
+42 -22
View File
@@ -1,4 +1,4 @@
import { ref } from 'vue';
import { ref, type Ref, unref } from 'vue';
import SockJS from 'sockjs-client';
import Stomp from 'stompjs';
@@ -22,15 +22,31 @@ export interface ProgressMessage {
tracksMerged?: number;
upgradedFiles?: number;
skippedFiles?: number;
// ingest 任务字段
ingestedFiles?: number;
duplicateFiles?: number;
missingMetadataFiles?: number;
unreadableFiles?: number;
conversionFailedFiles?: number;
otherRejectedFiles?: number;
}
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {
/**
* WebSocket 连接组合式函数。
*
* @param taskIdRef 响应式任务 IDRef<string | null>),每次 connect 时取 .value
* @param onMessage 收到进度消息时的回调
*/
export function useWebSocket(taskIdRef: Ref<string | null>, onMessage: (msg: ProgressMessage) => void) {
const connected = ref(false);
const error = ref<string | null>(null);
let stompClient: Stomp.Client | null = null;
// 记住订阅了哪个 taskId,以便断线重连时仍使用正确值
let currentSubscribedId: string | null = null;
const connect = () => {
if (!taskId) {
const rawId = unref(taskIdRef);
if (!rawId) {
return;
}
@@ -39,6 +55,8 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
disconnect();
}
currentSubscribedId = rawId;
try {
const wsBase =
import.meta.env.VITE_WS_BASE_URL !== undefined && import.meta.env.VITE_WS_BASE_URL !== ''
@@ -46,35 +64,37 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
: (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080');
const socket = new SockJS(`${wsBase}/ws`);
stompClient = Stomp.over(socket);
// 禁用调试日志
stompClient.debug = () => {};
stompClient.connect(
{},
() => {
// 连接成功
connected.value = true;
error.value = null;
// 订阅任务进度
if (stompClient) {
stompClient.subscribe(`/topic/progress/${taskId}`, (message) => {
try {
const progress: ProgressMessage = JSON.parse(message.body);
onMessage(progress);
} catch (e) {
console.error('Failed to parse progress message:', e);
// 订阅当前 taskId 的进度主题
const topic = `/topic/progress/${currentSubscribedId}`;
stompClient!.subscribe(topic, (frame) => {
try {
const body = JSON.parse(frame.body) as ProgressMessage;
onMessage(body);
// 任务完成时自动断开
if (body.completed) {
disconnect();
}
});
}
} catch (e) {
console.error('解析 WebSocket 消息失败:', e);
}
});
},
(errorFrame) => {
// 连接失败
const errorMsg = errorFrame.headers?.['message'] || errorFrame.toString() || 'WebSocket 连接错误';
error.value = errorMsg;
(err: unknown) => {
connected.value = false;
console.error('WebSocket 连接失败:', errorMsg, errorFrame);
const errMsg = err instanceof Error ? err.message : 'WebSocket 连接失败';
error.value = errMsg;
console.error('WebSocket 连接失败:', err);
}
);
} catch (e) {
@@ -93,7 +113,6 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
connected.value = false;
});
} else {
// 如果连接未成功,直接清理
connected.value = false;
}
} catch (e) {
@@ -102,6 +121,7 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
}
stompClient = null;
}
currentSubscribedId = null;
error.value = null;
};