diff --git a/README.md b/README.md index 28f9ee6..a6332b2 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,7 @@ cd docker && docker compose up -d --build - `MANGTOOL_MB_BASE_URL` (default: https://musicbrainz.org/ws/2) - `MANGTOOL_CAA_BASE_URL` (default: https://coverartarchive.org) - `MANGTOOL_MB_USER_AGENT` (default: MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)) - User-Agent for MusicBrainz API requests + + +### 环境变量说明 +- `MANGTOOL_UPLOAD_TEMP`: 前端文件上传的临时存放目录,默认值为 `/home/mangtool/MusicWork/.upload-tmp`。 \ No newline at end of file diff --git a/backend/src/main/java/com/music/common/Constants.java b/backend/src/main/java/com/music/common/Constants.java new file mode 100644 index 0000000..5e3cb35 --- /dev/null +++ b/backend/src/main/java/com/music/common/Constants.java @@ -0,0 +1,14 @@ +package com.music.common; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public class Constants { + public static final Set SUPPORTED_AUDIO_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "flac", "mp3", "m4a", "aac", "ogg", "opus", "wma", "wav", "ape", "aiff", "aif", "wv", "tta" + ))); + + public static final String EXTENSION_LRC = "lrc"; +} diff --git a/backend/src/main/java/com/music/controller/IngestController.java b/backend/src/main/java/com/music/controller/IngestController.java index 294890c..d36f07c 100644 --- a/backend/src/main/java/com/music/controller/IngestController.java +++ b/backend/src/main/java/com/music/controller/IngestController.java @@ -11,9 +11,21 @@ import com.music.service.IngestTaskStore; import com.music.service.ProgressStore; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Value; +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; /** * 一键导入控制器 @@ -28,20 +40,51 @@ public class IngestController { private final ConfigService configService; private final IngestTaskStore taskStore; + // Removed ALLOWED_EXTENSIONS, use com.music.common.Constants + + private final Path tempDir; + /** 防止并发执行的生命周期锁,由 IngestService 在 finally 中释放 */ private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicInteger activeUploads = new AtomicInteger(0); + /** 当前/最近一次任务的 taskId,用于 GET /api/ingest/status 恢复 */ private volatile String currentTaskId; + private final Object lifecycleLock = new Object(); + public IngestController(IngestService ingestService, ProgressStore progressStore, ConfigService configService, - IngestTaskStore taskStore) { + IngestTaskStore taskStore, + @Value("${spring.servlet.multipart.location:/tmp/mangtool-upload-tmp}") String tempLocation) { this.ingestService = ingestService; this.progressStore = progressStore; this.configService = configService; this.taskStore = taskStore; + + this.tempDir = Paths.get(tempLocation); + } + + @PostConstruct + public void init() { + try { + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + // Clean stale .part files + try (java.util.stream.Stream stream = Files.list(tempDir)) { + stream.filter(p -> p.toString().endsWith(".part")) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) {} + }); + } + } catch (IOException e) { + throw new RuntimeException("Failed to initialize temp directory for uploads", e); + } } /** @@ -54,8 +97,14 @@ public class IngestController { throw new BusinessException(400, "请先配置工作根目录"); } - if (!running.compareAndSet(false, true)) { - throw new BusinessException(409, "导入任务已在运行中,请等待完成"); + synchronized (lifecycleLock) { + if (activeUploads.get() > 0) { + throw new BusinessException(409, "有文件正在上传,请等待完成后再试"); + } + + if (!running.compareAndSet(false, true)) { + throw new BusinessException(409, "导入任务已在运行中,请等待完成"); + } } String taskId = UUID.randomUUID().toString(); @@ -190,6 +239,127 @@ public class IngestController { throw new BusinessException(404, "未找到该任务的报告"); } + @PostMapping("/upload") + public Result upload(@RequestParam("file") MultipartFile file, + @RequestParam("relativePath") String relativePath) { + synchronized (lifecycleLock) { + if (running.get()) { + throw new BusinessException(409, "导入任务正在运行中,无法上传文件"); + } + activeUploads.incrementAndGet(); + } + + try { + String inputDir = configService.getInputDir(); + if (inputDir == null || inputDir.trim().isEmpty()) { + throw new BusinessException(400, "请先配置工作根目录"); + } + + if (file.isEmpty()) { + throw new BusinessException(400, "文件为空"); + } + + if (relativePath == null || relativePath.trim().isEmpty()) { + throw new BusinessException(400, "相对路径不能为空"); + } + + if (relativePath.contains("\\")) { + throw new BusinessException(400, "路径中不能包含反斜杠"); + } + + // Path Security + if (relativePath.startsWith("/") || relativePath.contains(":") || + relativePath.contains("..") || relativePath.contains("./") || + relativePath.endsWith("/.")) { + throw new BusinessException(400, "非法的相对路径"); + } + + String ext = ""; + int dotIdx = relativePath.lastIndexOf('.'); + int slashIdx = relativePath.lastIndexOf('/'); + if (dotIdx > slashIdx && dotIdx < relativePath.length() - 1) { + ext = relativePath.substring(dotIdx + 1).toLowerCase(); + } + if (!com.music.common.Constants.SUPPORTED_AUDIO_EXTENSIONS.contains(ext) && !com.music.common.Constants.EXTENSION_LRC.equals(ext)) { + throw new BusinessException(400, "不支持的文件类型: " + ext); + } + + String normalizedRelativePath = relativePath; + + Path targetBase = Paths.get(inputDir).normalize(); + Path targetPath = targetBase.resolve(normalizedRelativePath).normalize(); + + // 检查是否逃出 inputDir,或者发生路径遍历 + if (!targetPath.startsWith(targetBase)) { + throw new BusinessException(400, "非法的相对路径,禁止目录遍历"); + } + + try { + Path current = targetBase; + String[] parts = relativePath.split("/"); + for (String part : parts) { + if (part.isEmpty()) continue; + current = current.resolve(part); + if (Files.exists(current, java.nio.file.LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(current)) { + throw new BusinessException(400, "路径中不允许存在符号链接"); + } + } + } catch (SecurityException e) { + throw new BusinessException(500, "安全检查失败: " + e.getMessage()); + } + + try { + Files.createDirectories(targetPath.getParent()); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + } catch (IOException e) { + throw new BusinessException(500, "无法创建目录: " + e.getMessage()); + } + + Path partFile = null; + try { + try { + if (Files.getFileStore(tempDir).equals(Files.getFileStore(targetPath.getParent()))) { + partFile = tempDir.resolve(UUID.randomUUID().toString() + ".part"); + } else { + partFile = targetPath.getParent().resolve(UUID.randomUUID().toString() + ".part"); + } + } catch (IOException e) { + partFile = targetPath.getParent().resolve(UUID.randomUUID().toString() + ".part"); + } + + if (partFile.getParent().toFile().getUsableSpace() < file.getSize()) { + throw new BusinessException(507, "磁盘空间不足"); + } + + file.transferTo(partFile.toFile()); + + synchronized (lifecycleLock) { + if (Files.exists(targetPath)) { + return Result.success(new com.music.dto.UploadFileResponse("skipped", normalizedRelativePath, targetPath.toFile().length())); + } + try { + Files.move(partFile, targetPath, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(partFile, targetPath); + } + } + return Result.success(new com.music.dto.UploadFileResponse("uploaded", normalizedRelativePath, targetPath.toFile().length())); + } catch (IOException e) { + throw new BusinessException(500, "文件上传失败: " + e.getMessage()); + } finally { + if (partFile != null) { + try { + Files.deleteIfExists(partFile); + } catch (IOException ignored) {} + } + } + } finally { + activeUploads.decrementAndGet(); + } + } + private long lastModified(java.nio.file.Path p) { try { return java.nio.file.Files.getLastModifiedTime(p).toMillis(); diff --git a/backend/src/main/java/com/music/dto/UploadFileResponse.java b/backend/src/main/java/com/music/dto/UploadFileResponse.java new file mode 100644 index 0000000..3d0b3ef --- /dev/null +++ b/backend/src/main/java/com/music/dto/UploadFileResponse.java @@ -0,0 +1,40 @@ +package com.music.dto; + +public class UploadFileResponse { + private String status; // "uploaded" or "skipped" + private String relativePath; + private long size; + + public UploadFileResponse() { + } + + public UploadFileResponse(String status, String relativePath, long size) { + this.status = status; + this.relativePath = relativePath; + this.size = size; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRelativePath() { + return relativePath; + } + + public void setRelativePath(String relativePath) { + this.relativePath = relativePath; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } +} diff --git a/backend/src/main/java/com/music/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/music/exception/GlobalExceptionHandler.java index 8e1532d..d45f098 100644 --- a/backend/src/main/java/com/music/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/music/exception/GlobalExceptionHandler.java @@ -23,6 +23,10 @@ public class GlobalExceptionHandler { public Result handleValidationException(Exception ex) { return Result.failure(400, "请求参数错误:" + ex.getMessage()); } + @ExceptionHandler(org.springframework.web.multipart.MaxUploadSizeExceededException.class) + public Result handleMaxUploadSizeExceededException(org.springframework.web.multipart.MaxUploadSizeExceededException ex) { + return Result.failure(413, "文件大小超出限制"); + } @ExceptionHandler(Exception.class) public Result handleOther(Exception ex) { diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java index f93cf4e..efd634b 100644 --- a/backend/src/main/java/com/music/service/IngestService.java +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -57,19 +57,6 @@ public class IngestService { "wav", "ape", "aiff", "aif", "wv", "tta" )); - /** 可直接保留的格式 */ - private static final Set COMPATIBLE_EXTENSIONS = new HashSet<>(Arrays.asList( - "flac", "mp3", "m4a", "aac", "ogg", "opus", "wma" - )); - - /** 所有支持的音频格式 */ - private static final Set ALL_AUDIO_EXTENSIONS = new HashSet<>(); - - static { - ALL_AUDIO_EXTENSIONS.addAll(LOSSLESS_EXTENSIONS); - ALL_AUDIO_EXTENSIONS.addAll(COMPATIBLE_EXTENSIONS); - } - /** 需要繁简转换的标签字段 */ private static final FieldKey[] TEXT_FIELDS = { FieldKey.TITLE, FieldKey.ARTIST, FieldKey.ALBUM, FieldKey.ALBUM_ARTIST @@ -1501,7 +1488,7 @@ public class IngestService { private boolean isAudioFile(Path file) { String ext = getExtension(file.getFileName().toString()); - return ext != null && ALL_AUDIO_EXTENSIONS.contains(ext); + return ext != null && com.music.common.Constants.SUPPORTED_AUDIO_EXTENSIONS.contains(ext); } private boolean isLosslessFormat(Path file) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 67107c1..ffbdf7a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -9,6 +9,12 @@ spring: serialization: write-dates-as-timestamps: false + servlet: + multipart: + max-file-size: 4096MB + max-request-size: 4096MB + location: ${MANGTOOL_UPLOAD_TEMP:/tmp/mangtool-upload-tmp} + logging: level: root: INFO diff --git a/backend/src/test/java/com/music/IngestUploadTest.java b/backend/src/test/java/com/music/IngestUploadTest.java new file mode 100644 index 0000000..e97cfa3 --- /dev/null +++ b/backend/src/test/java/com/music/IngestUploadTest.java @@ -0,0 +1,366 @@ +package com.music; + +import com.music.common.Constants; +import com.music.controller.IngestController; +import com.music.exception.BusinessException; +import com.music.dto.IngestRequest; +import com.music.service.ConfigService; +import com.music.service.IngestService; +import com.music.service.IngestTaskStore; +import com.music.service.ProgressStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; + +public class IngestUploadTest { + + @Mock + private IngestService ingestService; + + @Mock + private ProgressStore progressStore; + + @Mock + private ConfigService configService; + + @Mock + private IngestTaskStore taskStore; + + private IngestController ingestController; + + private Path tempDir; + private Path inputDir; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + tempDir = Files.createTempDirectory("upload-tmp"); + inputDir = Files.createTempDirectory("music-input"); + + ingestController = new IngestController(ingestService, progressStore, configService, taskStore, tempDir.toString()); + + when(configService.getInputDir()).thenReturn(inputDir.toString()); + when(configService.getBasePath()).thenReturn(inputDir.toString()); + } + + @AfterEach + public void tearDown() throws Exception { + if (Files.exists(tempDir)) { + try (java.util.stream.Stream s = Files.walk(tempDir)) { + s.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { Files.delete(p); } catch (IOException ignored) {} + }); + } + } + if (Files.exists(inputDir)) { + try (java.util.stream.Stream s = Files.walk(inputDir)) { + s.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { Files.delete(p); } catch (IOException ignored) {} + }); + } + } + } + + @Test + public void testInitCleansStalePartFiles() throws Exception { + Path part1 = tempDir.resolve("test1.part"); + Path part2 = tempDir.resolve("test2.txt"); + Files.write(part1, "test".getBytes()); + Files.write(part2, "test".getBytes()); + + ingestController.init(); + + assertFalse(Files.exists(part1), "Stale .part file should be cleaned"); + assertTrue(Files.exists(part2), "Non-.part file should not be cleaned"); + } + + @Test + public void testUploadSuccess() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes()); + com.music.common.Result result = ingestController.upload(file, "album/test.mp3"); + + assertEquals(0, result.getCode()); + assertEquals("uploaded", result.getData().getStatus()); + assertEquals("album/test.mp3", result.getData().getRelativePath()); + assertTrue(result.getData().getSize() > 0); + + Path targetPath = inputDir.resolve("album/test.mp3"); + assertTrue(Files.exists(targetPath)); + assertEquals("testdata", new String(Files.readAllBytes(targetPath))); + } + + @Test + public void testUploadExtensions() throws Exception { + for (String ext : Constants.SUPPORTED_AUDIO_EXTENSIONS) { + MockMultipartFile file = new MockMultipartFile("file", "test." + ext, "audio/mpeg", "testdata".getBytes()); + com.music.common.Result result = ingestController.upload(file, "album/test." + ext); + assertEquals(0, result.getCode()); + } + MockMultipartFile file = new MockMultipartFile("file", "test.lrc", "text/plain", "testdata".getBytes()); + com.music.common.Result result = ingestController.upload(file, "album/test.lrc"); + assertEquals(0, result.getCode()); + } + + @Test + public void testUploadSkipExisting() throws Exception { + Path targetPath = inputDir.resolve("album/test.mp3"); + Files.createDirectories(targetPath.getParent()); + Files.write(targetPath, "existing".getBytes()); + + MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes()); + com.music.common.Result result = ingestController.upload(file, "album/test.mp3"); + + assertEquals(0, result.getCode()); + assertEquals("skipped", result.getData().getStatus()); + + // Assert content not overwritten + assertEquals("existing", new String(Files.readAllBytes(targetPath))); + } + + @Test + public void testUploadInvalidPaths() { + MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes()); + String[] badPaths = { "../test.mp3", "/etc/passwd", "C:\\test.mp3", "album\\test.mp3", "./test.mp3", "album/.", "C:/test.mp3" }; + for (String path : badPaths) { + BusinessException ex = assertThrows(BusinessException.class, () -> { + ingestController.upload(file, path); + }); + assertEquals(400, ex.getCode()); + } + } + + @Test + public void testUploadEmptyFile() { + MockMultipartFile file = new MockMultipartFile("file", "empty.mp3", "audio/mpeg", new byte[0]); + BusinessException ex = assertThrows(BusinessException.class, () -> { + ingestController.upload(file, "album/empty.mp3"); + }); + assertEquals(400, ex.getCode()); + assertTrue(ex.getMessage().contains("文件为空")); + } + + @Test + public void testUploadUnsupportedExtension() { + MockMultipartFile file = new MockMultipartFile("file", "test.exe", "application/octet-stream", "testdata".getBytes()); + + BusinessException ex = assertThrows(BusinessException.class, () -> { + ingestController.upload(file, "test.exe"); + }); + assertEquals(400, ex.getCode()); + assertTrue(ex.getMessage().contains("不支持")); + } + + @Test + public void testUploadSymlinkRejected() throws Exception { + Path targetDir = inputDir.resolve("album"); + Files.createDirectories(targetDir); + Path realFile = tempDir.resolve("real.txt"); + Files.write(realFile, "test".getBytes()); + Path symlink = targetDir.resolve("link"); + try { + Files.createSymbolicLink(symlink, realFile); + } catch (UnsupportedOperationException | IOException e) { + // Ignore on platforms without symlink support + return; + } + + MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes()); + BusinessException ex = assertThrows(BusinessException.class, () -> { + ingestController.upload(file, "album/link/test.mp3"); + }); + assertEquals(400, ex.getCode()); + assertTrue(ex.getMessage().contains("符号链接")); + } + + @Test + public void testUploadExtensionFromRelativePath() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "test.txt", "audio/mpeg", "testdata".getBytes()); + com.music.common.Result result = ingestController.upload(file, "album/test.mp3"); + + assertEquals(0, result.getCode()); + assertEquals("uploaded", result.getData().getStatus()); + } + + static class BlockingMultipartFile extends MockMultipartFile { + private final CountDownLatch startLatch; + private final CountDownLatch endLatch; + + public BlockingMultipartFile(String name, String originalFilename, String contentType, byte[] content, CountDownLatch startLatch, CountDownLatch endLatch) { + super(name, originalFilename, contentType, content); + this.startLatch = startLatch; + this.endLatch = endLatch; + } + + @Override + public byte[] getBytes() throws IOException { + block(); + return super.getBytes(); + } + + @Override + public java.io.InputStream getInputStream() throws IOException { + block(); + return super.getInputStream(); + } + + @Override + public void transferTo(java.io.File dest) throws IOException, IllegalStateException { + block(); + super.transferTo(dest); + } + + @Override + public void transferTo(Path dest) throws IOException, IllegalStateException { + block(); + super.transferTo(dest); + } + + private void block() { + startLatch.countDown(); + try { + endLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Test + public void testConcurrencyStartUpload() throws InterruptedException, Exception { + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(1); + + ExecutorService executor = Executors.newFixedThreadPool(1); + + BlockingMultipartFile blockingFile = new BlockingMultipartFile( + "file", "blocking.mp3", "audio/mpeg", "data".getBytes(), startLatch, endLatch); + + executor.submit(() -> { + try { + ingestController.upload(blockingFile, "blocking.mp3"); + } catch (Exception ignored) { + } + }); + + assertTrue(startLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + + BusinessException ex = assertThrows(BusinessException.class, () -> { + ingestController.start(new IngestRequest()); + }); + assertEquals(409, ex.getCode()); + + endLatch.countDown(); + + executor.shutdown(); + executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS); + + com.music.common.Result res = ingestController.start(new IngestRequest()); + assertEquals(0, res.getCode()); + } + + @Test + public void testEarlyFailureDoesNotBlockStart() throws Exception { + // missing config + when(configService.getInputDir()).thenReturn(null); + MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes()); + assertThrows(BusinessException.class, () -> ingestController.upload(file, "album/test.mp3")); + when(configService.getInputDir()).thenReturn(inputDir.toString()); + + // empty file + MockMultipartFile emptyFile = new MockMultipartFile("file", "test.mp3", "audio/mpeg", new byte[0]); + assertThrows(BusinessException.class, () -> ingestController.upload(emptyFile, "album/test.mp3")); + + // invalid path + assertThrows(BusinessException.class, () -> ingestController.upload(file, "../test.mp3")); + + // symlink rejected + Path targetDir = inputDir.resolve("album"); + Files.createDirectories(targetDir); + Path realFile = tempDir.resolve("real.txt"); + Files.write(realFile, "test".getBytes()); + Path symlink = targetDir.resolve("link"); + try { + Files.createSymbolicLink(symlink, realFile); + assertThrows(BusinessException.class, () -> ingestController.upload(file, "album/link/test.mp3")); + } catch (UnsupportedOperationException | IOException ignored) { + } + + // skipped existing + Path targetPath = inputDir.resolve("album/test.mp3"); + Files.createDirectories(targetPath.getParent()); + Files.write(targetPath, "existing".getBytes()); + ingestController.upload(file, "album/test.mp3"); + + // Verify start is not blocked + com.music.common.Result res = ingestController.start(new IngestRequest()); + assertEquals(0, res.getCode()); + } + + @Test + public void testConcurrentUploadNoOverwrite() throws Exception { + CountDownLatch transferLatch = new CountDownLatch(2); + CountDownLatch releaseLatch = new CountDownLatch(1); + + class SyncFile extends MockMultipartFile { + public SyncFile(String name, String originalFilename, String contentType, byte[] content) { + super(name, originalFilename, contentType, content); + } + @Override + public void transferTo(java.io.File dest) throws IOException, IllegalStateException { + super.transferTo(dest); + transferLatch.countDown(); + try { releaseLatch.await(); } catch (InterruptedException e) {} + } + @Override + public void transferTo(Path dest) throws IOException, IllegalStateException { + super.transferTo(dest); + transferLatch.countDown(); + try { releaseLatch.await(); } catch (InterruptedException e) {} + } + } + + SyncFile file1 = new SyncFile("file", "test.mp3", "audio/mpeg", "content_A".getBytes()); + SyncFile file2 = new SyncFile("file", "test.mp3", "audio/mpeg", "content_B".getBytes()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + java.util.concurrent.Future> f1 = executor.submit(() -> ingestController.upload(file1, "concurrent/test.mp3")); + java.util.concurrent.Future> f2 = executor.submit(() -> ingestController.upload(file2, "concurrent/test.mp3")); + + assertTrue(transferLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + releaseLatch.countDown(); + + com.music.common.Result res1 = f1.get(); + com.music.common.Result res2 = f2.get(); + executor.shutdown(); + + boolean r1Uploaded = "uploaded".equals(res1.getData().getStatus()); + boolean r2Uploaded = "uploaded".equals(res2.getData().getStatus()); + assertTrue(r1Uploaded ^ r2Uploaded, "Exactly one upload should succeed"); + + Path targetPath = inputDir.resolve("concurrent/test.mp3"); + String content = new String(Files.readAllBytes(targetPath)); + if (r1Uploaded) assertEquals("content_A", content); + else assertEquals("content_B", content); + } +} diff --git a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java index 659788e..0ecef08 100644 --- a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java +++ b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java @@ -114,7 +114,8 @@ class ProgressMessageMappingTest { mock(IngestService.class), new ProgressStore(), cfgService, - mock(com.music.service.IngestTaskStore.class)); + mock(com.music.service.IngestTaskStore.class), + "/tmp"); // 模拟配置已设置 com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse(); @@ -148,7 +149,8 @@ class ProgressMessageMappingTest { mock(IngestService.class), store, cfgService, - mock(com.music.service.IngestTaskStore.class)); + mock(com.music.service.IngestTaskStore.class), + "/tmp"); java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running"); runningField.setAccessible(true); @@ -169,7 +171,7 @@ class ProgressMessageMappingTest { ProgressStore store = new ProgressStore(); com.music.controller.IngestController controller = new com.music.controller.IngestController( mock(IngestService.class), store, cfgService, - mock(com.music.service.IngestTaskStore.class)); + mock(com.music.service.IngestTaskStore.class), "/tmp"); com.music.common.Result result = controller.statusById("expired-task"); @@ -203,8 +205,7 @@ class ProgressMessageMappingTest { // 新进程的控制器:ProgressStore 为空(内存进度已丢失),currentTaskId 为 null ProgressStore store = new ProgressStore(); - com.music.controller.IngestController controller = new com.music.controller.IngestController( - mock(IngestService.class), store, cfgService, fresh); + com.music.controller.IngestController controller = new com.music.controller.IngestController(mock(IngestService.class), store, cfgService, fresh, "/tmp"); com.music.common.Result result = controller.statusCurrent(); com.music.dto.IngestStatusResponse resp = result.getData(); diff --git a/docker/README.md b/docker/README.md index 5be20f2..6ef2336 100644 --- a/docker/README.md +++ b/docker/README.md @@ -186,3 +186,7 @@ docker compose logs -f mangtool - `MANGTOOL_MB_BASE_URL` (default: https://musicbrainz.org/ws/2) - `MANGTOOL_CAA_BASE_URL` (default: https://coverartarchive.org) - `MANGTOOL_MB_USER_AGENT` (default: MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)) - User-Agent for MusicBrainz API requests + + +### 环境变量说明 +- `MANGTOOL_UPLOAD_TEMP`: 前端文件上传的临时存放目录,默认值为 `/home/mangtool/MusicWork/.upload-tmp`。 \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7cbe524..031a4e4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,6 +13,7 @@ services: volumes: - ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork environment: + - MANGTOOL_UPLOAD_TEMP=${MANGTOOL_UPLOAD_TEMP:-/home/mangtool/MusicWork/.upload-tmp} - MANGTOOL_COVER_ENABLED=${MANGTOOL_COVER_ENABLED:-false} - MANGTOOL_MB_BASE_URL=${MANGTOOL_MB_BASE_URL:-https://musicbrainz.org/ws/2} - MANGTOOL_CAA_BASE_URL=${MANGTOOL_CAA_BASE_URL:-https://coverartarchive.org} diff --git a/frontend/src/api/ingest.ts b/frontend/src/api/ingest.ts index dff3ecb..2e0f26a 100644 --- a/frontend/src/api/ingest.ts +++ b/frontend/src/api/ingest.ts @@ -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 { export function getIngestStatus(taskId: string): Promise { 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 { + const formData = new FormData(); + formData.append('file', file); + formData.append('relativePath', relativePath); + return request.post('/api/ingest/upload', formData, config); +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 994419e..f6fc130 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -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 格式 @@ -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 格式,直接返回 diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue index 5280316..dd857f5 100644 --- a/frontend/src/components/IngestTab.vue +++ b/frontend/src/components/IngestTab.vue @@ -108,6 +108,66 @@
+
+ + + +
+
+ 上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }}) + +
+
+
+
+
+ 当前: {{ uploadState.currentFile }} +
+ 音频: {{ uploadState.totalAudio }} + 歌词: {{ uploadState.totalLrc }} + 忽略: {{ uploadState.ignored }} + 大小: {{ uploadState.formattedSize }} +
+
+ 上传: {{ uploadState.uploaded }} + 跳过: {{ uploadState.skipped }} + 失败: {{ uploadState.failed }} +
+
+
+ + +
+
+ 上传失败 ({{ failedUploads.length }}) + +
+
+
+ {{ fail.relPath }} +
+
+
+
+