feat: upload local music directories

This commit is contained in:
2026-07-21 19:25:20 +08:00
parent b21a5d0ed1
commit 59cf2ea67c
15 changed files with 1024 additions and 29 deletions
+17
View File
@@ -1,4 +1,5 @@
import request from './request';
import type { AxiosRequestConfig } from 'axios';
export interface IngestResponse {
taskId: string;
@@ -59,3 +60,19 @@ export function getIngestCurrentStatus(): Promise<IngestStatusResponse> {
export function getIngestStatus(taskId: string): Promise<IngestStatusResponse> {
return request.get(`/api/ingest/status/${taskId}`);
}
export interface UploadFileResponse {
status: 'uploaded' | 'skipped';
relativePath: string;
size: number;
}
/**
* 上传文件到输入目录
*/
export function uploadFile(file: File, relativePath: string, config?: AxiosRequestConfig): Promise<UploadFileResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('relativePath', relativePath);
return request.post('/api/ingest/upload', formData, config);
}
+11 -5
View File
@@ -1,12 +1,18 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
export class BusinessError extends Error {
code: number;
constructor(message: string, code: number) {
super(message);
this.code = code;
this.name = 'BusinessError';
}
}
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
timeout: 30000
});
// 响应拦截器:统一处理 Result<T> 格式
@@ -21,7 +27,7 @@ request.interceptors.response.use(
} else {
// 失败,显示错误信息并 reject
ElMessage.error(result.message || '请求失败');
return Promise.reject(new Error(result.message || '请求失败'));
return Promise.reject(new BusinessError(result.message || '请求失败', result.code));
}
}
// 如果不是 Result 格式,直接返回
+374 -2
View File
@@ -108,6 +108,66 @@
<!-- 操作按钮 -->
<div class="control-actions">
<div class="upload-area">
<input type="file" webkitdirectory multiple ref="uploadInput" style="display: none" @change="onFilesSelected" />
<button
v-if="!uploadState.uploading && !running"
class="btn btn-primary upload-btn"
type="button"
:disabled="!canStart"
@click="openDirectoryPicker"
>
<app-icon name="upload" :size="16" />
<span>选择本地目录上传并入库</span>
</button>
<div v-if="uploadState.uploading" class="upload-progress-box">
<div class="upload-header">
<span>上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }})</span>
<button class="icon-btn cancel-btn" @click="cancelUpload" title="取消上传">
<app-icon name="close" :size="14" />
</button>
</div>
<div class="progress-track">
<div class="progress-fill tone-ok" :style="{ width: uploadState.percentage + '%' }"></div>
</div>
<div class="upload-stats">
<span>当前: {{ uploadState.currentFile }}</span>
<div class="stats-row">
<span>音频: {{ uploadState.totalAudio }}</span>
<span>歌词: {{ uploadState.totalLrc }}</span>
<span>忽略: {{ uploadState.ignored }}</span>
<span>大小: {{ uploadState.formattedSize }}</span>
</div>
<div class="stats-row">
<span class="c-ok">上传: {{ uploadState.uploaded }}</span>
<span class="c-warn">跳过: {{ uploadState.skipped }}</span>
<span class="c-error">失败: {{ uploadState.failed }}</span>
</div>
</div>
</div>
<!-- 失败文件列表及重试 -->
<div v-if="!uploadState.uploading && !running && failedUploads.length > 0" class="failed-uploads-box">
<div class="failed-header">
<span class="c-error">上传失败 ({{ failedUploads.length }})</span>
<button
class="btn btn-ghost btn-sm"
type="button"
@click="retryFailedUploads"
>
<app-icon name="refresh" :size="14" />
<span>重试</span>
</button>
</div>
<div class="failed-list c-scroll">
<div v-for="fail in failedUploads" :key="fail.relPath" class="failed-item" :title="fail.relPath">
{{ fail.relPath }}
</div>
</div>
</div>
</div>
<button
v-if="!running"
class="btn btn-primary"
@@ -332,6 +392,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import axios from 'axios';
import AppIcon from './common/AppIcon.vue';
import {
startIngest as apiStartIngest,
@@ -340,10 +401,15 @@ import {
getIngestStatus,
getIngestCurrentStatus
} from '../api/ingest';
import type { IngestStatusResponse } from '../api/ingest';
import type { IngestStatusResponse, UploadFileResponse } from '../api/ingest';
import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket';
import { useAppState } from '../composables/useAppState';
import { BusinessError } from '../api/request';
import type { AxiosProgressEvent } from 'axios';
import { uploadFile } from '../api/ingest';
interface IngestProgressMessage extends ProgressMessage {
lyricsFound?: number;
@@ -393,7 +459,7 @@ const progress = reactive<ProgressMessage>({
otherRejectedFiles: 0
});
const canStart = computed(() => isConfigured.value && !submitting.value);
const canStart = computed(() => isConfigured.value && !submitting.value && !uploadState.uploading);
/**
* 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。
@@ -696,6 +762,240 @@ function stopPolling() {
}
}
/* ---------------- 上传与自动入库逻辑 ---------------- */
const uploadInput = ref<HTMLInputElement | null>(null);
function openDirectoryPicker() {
if (uploadInput.value) {
uploadInput.value.click();
}
}
const uploadState = reactive({
uploading: false,
totalAudio: 0,
totalLrc: 0,
ignored: 0,
totalBytes: 0,
uploadedBytes: 0,
uploaded: 0,
skipped: 0,
failed: 0,
currentFile: '',
percentage: 0,
formattedSize: '0 B'
});
let uploadAbortController: AbortController | null = null;
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const SUPPORTED_AUDIO_EXTS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.aif', '.wv', '.tta'];
const failedUploads = ref<{ file: File, relPath: string }[]>([]);
async function onFilesSelected(e: Event) {
const target = e.target as HTMLInputElement;
const files = Array.from(target.files || []);
if (!files.length) return;
target.value = ''; // reset
// 1. 过滤和统计
const toUpload: { file: File, relPath: string }[] = [];
let totalAudio = 0;
let totalLrc = 0;
let ignored = 0;
let totalBytes = 0;
for (const file of files) {
const name = file.name.toLowerCase();
const isAudio = SUPPORTED_AUDIO_EXTS.some(ext => name.endsWith(ext));
const isLrc = name.endsWith('.lrc');
if (isAudio || isLrc) {
if (isAudio) totalAudio++;
if (isLrc) totalLrc++;
totalBytes += file.size;
// Strip root directory
const pathParts = file.webkitRelativePath.split('/');
pathParts.shift(); // remove root dir
const relPath = pathParts.join('/');
toUpload.push({ file, relPath });
} else {
ignored++;
}
}
if (totalAudio === 0) {
ElMessage.warning('选中的目录未包含支持的音频文件');
return;
}
// 2. 初始化状态
uploadState.uploading = true;
uploadState.totalAudio = totalAudio;
uploadState.totalLrc = totalLrc;
uploadState.ignored = ignored;
uploadState.totalBytes = totalBytes;
uploadState.uploadedBytes = 0;
uploadState.uploaded = 0;
uploadState.skipped = 0;
uploadState.failed = 0;
uploadState.percentage = 0;
uploadState.formattedSize = formatBytes(totalBytes);
uploadAbortController = new AbortController();
failedUploads.value = []; // Reset previous failures on new full upload
await doUploadLoop(toUpload, totalAudio, totalLrc, totalBytes);
}
async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio: number, totalLrc: number, totalBytes: number) {
let hasAudioSuccess = false;
let isCancelled = false;
let previousFilesBytes = uploadState.uploadedBytes;
// 3. 串行上传
for (const item of items) {
if (uploadAbortController.signal.aborted) {
isCancelled = true;
break;
}
uploadState.currentFile = item.relPath;
let success = false;
let attempts = 0;
while (attempts < 3) {
if (uploadAbortController.signal.aborted) break;
attempts++;
try {
const resp = await uploadFile(item.file, item.relPath, {
signal: uploadAbortController.signal,
timeout: 0, // No timeout
onUploadProgress: (pEvent: AxiosProgressEvent) => {
if (pEvent.loaded) {
uploadState.uploadedBytes = previousFilesBytes + pEvent.loaded;
if (totalBytes > 0) {
uploadState.percentage = Math.floor((uploadState.uploadedBytes / totalBytes) * 100);
}
}
}
});
if (resp && resp.status === 'skipped') {
uploadState.skipped++;
} else {
uploadState.uploaded++;
}
previousFilesBytes += item.file.size;
uploadState.uploadedBytes = previousFilesBytes;
if (totalBytes > 0) {
uploadState.percentage = Math.floor((uploadState.uploadedBytes / totalBytes) * 100);
}
if (SUPPORTED_AUDIO_EXTS.some(ext => item.file.name.toLowerCase().endsWith(ext))) {
hasAudioSuccess = true;
}
success = true;
break; // Success
} catch (err: unknown) {
if (axios.isCancel(err) || uploadAbortController.signal.aborted) {
break;
}
let status = 0;
if (axios.isAxiosError(err)) {
status = err.response?.status || 0;
} else if (err instanceof BusinessError) {
status = err.code;
}
if (status === 409) {
ElMessage.error('存在冲突(409),有文件正在处理中,将跳过当前文件');
break;
}
if (status && ((status >= 400 && status < 500 && status !== 408 && status !== 429) || status === 507)) {
// Deterministic error, do not retry
break;
}
// Transient error, wait and retry
if (attempts === 1) await new Promise(r => setTimeout(r, 1000));
else if (attempts === 2) await new Promise(r => setTimeout(r, 3000));
}
}
if (!success && !uploadAbortController?.signal.aborted) {
uploadState.failed++;
failedUploads.value.push(item);
}
}
if (uploadAbortController?.signal.aborted) {
isCancelled = true;
}
uploadState.uploading = false;
uploadAbortController = null;
if (isCancelled) {
ElMessage.info('上传已取消,部分完成的文件已保留在目录中');
} else {
if (uploadState.failed > 0) {
ElMessage.warning(`上传结束:失败 ${uploadState.failed} 个文件,自动入库已取消`);
} else if (hasAudioSuccess) {
ElMessage.success(`上传成功 (音频:${totalAudio}, 歌词:${totalLrc}),即将开始自动入库`);
await startIngest();
} else {
ElMessage.warning('上传结束,但没有成功的音频文件,未自动触发入库');
}
}
}
async function retryFailedUploads() {
if (failedUploads.value.length === 0) return;
const itemsToRetry = [...failedUploads.value];
failedUploads.value = []; // clear current failures list
uploadState.uploading = true;
uploadState.failed = 0; // reset for this retry session
uploadState.uploadedBytes = 0;
uploadState.percentage = 0;
let totalAudio = 0;
let totalLrc = 0;
let totalBytes = 0;
for (const item of itemsToRetry) {
const isLrc = item.file.name.toLowerCase().endsWith('.lrc');
if (isLrc) totalLrc++; else totalAudio++;
totalBytes += item.file.size;
}
uploadState.totalAudio = totalAudio;
uploadState.totalLrc = totalLrc;
uploadState.totalBytes = totalBytes;
uploadState.formattedSize = formatBytes(totalBytes);
uploadState.uploaded = 0;
uploadState.skipped = 0;
uploadAbortController = new AbortController();
await doUploadLoop(itemsToRetry, totalAudio, totalLrc, totalBytes);
}
function cancelUpload() {
if (uploadAbortController) {
uploadAbortController.abort();
}
}
/* ---------------- 启动导入 ---------------- */
async function startIngest() {
@@ -1680,3 +1980,75 @@ onUnmounted(() => {
}
}
</style>
<style scoped>
.upload-area {
margin-bottom: 10px;
}
.upload-progress-box {
padding: 10px;
border: 1px solid var(--c-border);
border-radius: var(--c-radius-sm);
background: var(--c-bg-inset);
margin-top: 10px;
}
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: bold;
margin-bottom: 8px;
}
.upload-stats {
font-size: 11px;
color: var(--c-text-secondary);
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stats-row {
display: flex;
gap: 12px;
}
.c-ok { color: var(--c-accent-text); }
.c-warn { color: var(--c-warning-text); }
.c-error { color: var(--c-error-text); }
.failed-uploads-box {
margin-top: 10px;
padding: 10px;
border: 1px solid rgba(244, 63, 94, 0.25);
border-radius: var(--c-radius-sm);
background: rgba(244, 63, 94, 0.05);
display: flex;
flex-direction: column;
gap: 8px;
}
.failed-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: bold;
}
.btn-sm {
padding: 4px 8px;
font-size: 11px;
}
.failed-list {
max-height: 120px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.failed-item {
font-size: 11px;
color: var(--c-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -62,6 +62,9 @@ const ICONS: Record<string, IconDef> = {
folder: {
paths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
},
upload: {
paths: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12']
},
login: {
paths: [
'M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1'