diff --git a/pom.xml b/pom.xml
index d809082..7bab48b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -47,6 +47,18 @@
spring-boot-starter-validation
+
+ net.jthink
+ jaudiotagger
+ 2.2.5
+
+
+
+ com.github.albfernandez
+ juniversalchardet
+ 2.4.0
+
+
org.springframework.boot
spring-boot-starter-test
@@ -61,6 +73,26 @@
spring-boot-maven-plugin
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+
+
org.graalvm.buildtools
native-maven-plugin
diff --git a/src/main/java/com/music/metadata/domain/metadata/AudioMetadata.java b/src/main/java/com/music/metadata/domain/metadata/AudioMetadata.java
new file mode 100644
index 0000000..cc1b006
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/metadata/AudioMetadata.java
@@ -0,0 +1,152 @@
+package com.music.metadata.domain.metadata;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Normalized in-memory representation of metadata read from an audio file.
+ */
+public class AudioMetadata {
+
+ private String fileFormat;
+ private String originalEncoding;
+ private String snapshotJson;
+ private String title;
+ private String artist;
+ private String albumArtist;
+ private String album;
+ private String track;
+ private String year;
+ private String genre;
+ private String lyrics;
+ private String disc;
+ private String comment;
+ private CoverMetadata cover;
+ private Map> tagFields = new LinkedHashMap<>();
+
+ public String getFileFormat() {
+ return fileFormat;
+ }
+
+ public void setFileFormat(String fileFormat) {
+ this.fileFormat = fileFormat;
+ }
+
+ public String getOriginalEncoding() {
+ return originalEncoding;
+ }
+
+ public void setOriginalEncoding(String originalEncoding) {
+ this.originalEncoding = originalEncoding;
+ }
+
+ public String getSnapshotJson() {
+ return snapshotJson;
+ }
+
+ public void setSnapshotJson(String snapshotJson) {
+ this.snapshotJson = snapshotJson;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public void setArtist(String artist) {
+ this.artist = artist;
+ }
+
+ public String getAlbumArtist() {
+ return albumArtist;
+ }
+
+ public void setAlbumArtist(String albumArtist) {
+ this.albumArtist = albumArtist;
+ }
+
+ public String getAlbum() {
+ return album;
+ }
+
+ public void setAlbum(String album) {
+ this.album = album;
+ }
+
+ public String getTrack() {
+ return track;
+ }
+
+ public void setTrack(String track) {
+ this.track = track;
+ }
+
+ public String getYear() {
+ return year;
+ }
+
+ public void setYear(String year) {
+ this.year = year;
+ }
+
+ public String getGenre() {
+ return genre;
+ }
+
+ public void setGenre(String genre) {
+ this.genre = genre;
+ }
+
+ public String getLyrics() {
+ return lyrics;
+ }
+
+ public void setLyrics(String lyrics) {
+ this.lyrics = lyrics;
+ }
+
+ public String getDisc() {
+ return disc;
+ }
+
+ public void setDisc(String disc) {
+ this.disc = disc;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public CoverMetadata getCover() {
+ return cover;
+ }
+
+ public void setCover(CoverMetadata cover) {
+ this.cover = cover;
+ }
+
+ public Map> getTagFields() {
+ return tagFields;
+ }
+
+ public void setTagFields(Map> tagFields) {
+ this.tagFields = tagFields;
+ }
+
+ public void putTagField(String key, String value) {
+ tagFields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
+ }
+}
diff --git a/src/main/java/com/music/metadata/domain/metadata/CoverMetadata.java b/src/main/java/com/music/metadata/domain/metadata/CoverMetadata.java
new file mode 100644
index 0000000..1fd26f5
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/metadata/CoverMetadata.java
@@ -0,0 +1,71 @@
+package com.music.metadata.domain.metadata;
+
+/**
+ * Album artwork metadata extracted from an audio file.
+ */
+public class CoverMetadata {
+
+ private boolean present;
+ private String mimeType;
+ private String format;
+ private Integer width;
+ private Integer height;
+ private Long size;
+ private byte[] binaryData;
+
+ public boolean isPresent() {
+ return present;
+ }
+
+ public void setPresent(boolean present) {
+ this.present = present;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public void setMimeType(String mimeType) {
+ this.mimeType = mimeType;
+ }
+
+ public String getFormat() {
+ return format;
+ }
+
+ public void setFormat(String format) {
+ this.format = format;
+ }
+
+ public Integer getWidth() {
+ return width;
+ }
+
+ public void setWidth(Integer width) {
+ this.width = width;
+ }
+
+ public Integer getHeight() {
+ return height;
+ }
+
+ public void setHeight(Integer height) {
+ this.height = height;
+ }
+
+ public Long getSize() {
+ return size;
+ }
+
+ public void setSize(Long size) {
+ this.size = size;
+ }
+
+ public byte[] getBinaryData() {
+ return binaryData;
+ }
+
+ public void setBinaryData(byte[] binaryData) {
+ this.binaryData = binaryData;
+ }
+}
diff --git a/src/main/java/com/music/metadata/domain/metadata/MetadataValidationResult.java b/src/main/java/com/music/metadata/domain/metadata/MetadataValidationResult.java
new file mode 100644
index 0000000..a2ba65d
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/metadata/MetadataValidationResult.java
@@ -0,0 +1,43 @@
+package com.music.metadata.domain.metadata;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Result returned by the one-vote veto validator.
+ */
+public class MetadataValidationResult {
+
+ private final boolean passed;
+ private final List failures;
+ private final AudioMetadata cleanedMetadata;
+
+ private MetadataValidationResult(boolean passed, List failures, AudioMetadata cleanedMetadata) {
+ this.passed = passed;
+ this.failures = failures;
+ this.cleanedMetadata = cleanedMetadata;
+ }
+
+ public static MetadataValidationResult passed(AudioMetadata cleanedMetadata) {
+ return new MetadataValidationResult(true, Collections.emptyList(), cleanedMetadata);
+ }
+
+ public static MetadataValidationResult failed(ValidationFailure failure, AudioMetadata cleanedMetadata) {
+ List failures = new ArrayList<>();
+ failures.add(failure);
+ return new MetadataValidationResult(false, failures, cleanedMetadata);
+ }
+
+ public boolean isPassed() {
+ return passed;
+ }
+
+ public List getFailures() {
+ return failures;
+ }
+
+ public AudioMetadata getCleanedMetadata() {
+ return cleanedMetadata;
+ }
+}
diff --git a/src/main/java/com/music/metadata/domain/metadata/ValidationFailure.java b/src/main/java/com/music/metadata/domain/metadata/ValidationFailure.java
new file mode 100644
index 0000000..624e9bf
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/metadata/ValidationFailure.java
@@ -0,0 +1,29 @@
+package com.music.metadata.domain.metadata;
+
+/**
+ * One concrete validation failure reason.
+ */
+public class ValidationFailure {
+
+ private final ValidationFailureType type;
+ private final String field;
+ private final String message;
+
+ public ValidationFailure(ValidationFailureType type, String field, String message) {
+ this.type = type;
+ this.field = field;
+ this.message = message;
+ }
+
+ public ValidationFailureType getType() {
+ return type;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/com/music/metadata/domain/metadata/ValidationFailureType.java b/src/main/java/com/music/metadata/domain/metadata/ValidationFailureType.java
new file mode 100644
index 0000000..6e69866
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/metadata/ValidationFailureType.java
@@ -0,0 +1,12 @@
+package com.music.metadata.domain.metadata;
+
+/**
+ * Structured failure codes used by the one-vote veto validator.
+ */
+public enum ValidationFailureType {
+ MISSING_FIELD,
+ ENCODING_ERROR,
+ INVALID_FORMAT,
+ COVER_INVALID,
+ GARBLED_TEXT
+}
diff --git a/src/main/java/com/music/metadata/domain/scan/DuplicateType.java b/src/main/java/com/music/metadata/domain/scan/DuplicateType.java
new file mode 100644
index 0000000..0149a07
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/scan/DuplicateType.java
@@ -0,0 +1,6 @@
+package com.music.metadata.domain.scan;
+
+public enum DuplicateType {
+ HASH_DUPLICATE,
+ LEVEL2_SUSPECT
+}
diff --git a/src/main/java/com/music/metadata/domain/scan/FeatureDeduplicationHit.java b/src/main/java/com/music/metadata/domain/scan/FeatureDeduplicationHit.java
new file mode 100644
index 0000000..f554597
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/scan/FeatureDeduplicationHit.java
@@ -0,0 +1,22 @@
+package com.music.metadata.domain.scan;
+
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+
+public class FeatureDeduplicationHit {
+
+ private final String dedupKey;
+ private final FileProcessEntity canonicalFile;
+
+ public FeatureDeduplicationHit(String dedupKey, FileProcessEntity canonicalFile) {
+ this.dedupKey = dedupKey;
+ this.canonicalFile = canonicalFile;
+ }
+
+ public String getDedupKey() {
+ return dedupKey;
+ }
+
+ public FileProcessEntity getCanonicalFile() {
+ return canonicalFile;
+ }
+}
diff --git a/src/main/java/com/music/metadata/domain/scan/ScanItemStatus.java b/src/main/java/com/music/metadata/domain/scan/ScanItemStatus.java
new file mode 100644
index 0000000..c014389
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/scan/ScanItemStatus.java
@@ -0,0 +1,9 @@
+package com.music.metadata.domain.scan;
+
+public enum ScanItemStatus {
+ DISCOVERED,
+ PROCESSING,
+ DONE,
+ SKIPPED,
+ ERROR
+}
diff --git a/src/main/java/com/music/metadata/domain/scan/ScanProcessingOutcome.java b/src/main/java/com/music/metadata/domain/scan/ScanProcessingOutcome.java
new file mode 100644
index 0000000..c2e5a8d
--- /dev/null
+++ b/src/main/java/com/music/metadata/domain/scan/ScanProcessingOutcome.java
@@ -0,0 +1,9 @@
+package com.music.metadata.domain.scan;
+
+public enum ScanProcessingOutcome {
+ SUCCESS,
+ HASH_DUPLICATE,
+ LEVEL2_SUSPECT,
+ FAILED,
+ SKIPPED
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractionResult.java b/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractionResult.java
new file mode 100644
index 0000000..653244a
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractionResult.java
@@ -0,0 +1,143 @@
+package com.music.metadata.infrastructure.audio;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Raw extraction result from the metadata library before business validation.
+ */
+public class AudioTagExtractionResult {
+
+ private String fileFormat;
+ private String title;
+ private String artist;
+ private String albumArtist;
+ private String album;
+ private String track;
+ private String year;
+ private String genre;
+ private String lyrics;
+ private String disc;
+ private String comment;
+ private byte[] coverData;
+ private String coverMimeType;
+ private Map> fields = new LinkedHashMap<>();
+
+ public String getFileFormat() {
+ return fileFormat;
+ }
+
+ public void setFileFormat(String fileFormat) {
+ this.fileFormat = fileFormat;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public void setArtist(String artist) {
+ this.artist = artist;
+ }
+
+ public String getAlbumArtist() {
+ return albumArtist;
+ }
+
+ public void setAlbumArtist(String albumArtist) {
+ this.albumArtist = albumArtist;
+ }
+
+ public String getAlbum() {
+ return album;
+ }
+
+ public void setAlbum(String album) {
+ this.album = album;
+ }
+
+ public String getTrack() {
+ return track;
+ }
+
+ public void setTrack(String track) {
+ this.track = track;
+ }
+
+ public String getYear() {
+ return year;
+ }
+
+ public void setYear(String year) {
+ this.year = year;
+ }
+
+ public String getGenre() {
+ return genre;
+ }
+
+ public void setGenre(String genre) {
+ this.genre = genre;
+ }
+
+ public String getLyrics() {
+ return lyrics;
+ }
+
+ public void setLyrics(String lyrics) {
+ this.lyrics = lyrics;
+ }
+
+ public String getDisc() {
+ return disc;
+ }
+
+ public void setDisc(String disc) {
+ this.disc = disc;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public byte[] getCoverData() {
+ return coverData;
+ }
+
+ public void setCoverData(byte[] coverData) {
+ this.coverData = coverData;
+ }
+
+ public String getCoverMimeType() {
+ return coverMimeType;
+ }
+
+ public void setCoverMimeType(String coverMimeType) {
+ this.coverMimeType = coverMimeType;
+ }
+
+ public Map> getFields() {
+ return fields;
+ }
+
+ public void setFields(Map> fields) {
+ this.fields = fields;
+ }
+
+ public void addField(String key, String value) {
+ fields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
+ }
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractor.java b/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractor.java
new file mode 100644
index 0000000..d2c15b1
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/audio/AudioTagExtractor.java
@@ -0,0 +1,11 @@
+package com.music.metadata.infrastructure.audio;
+
+import java.io.File;
+
+/**
+ * Abstraction around the metadata library to keep reader tests isolated.
+ */
+public interface AudioTagExtractor {
+
+ AudioTagExtractionResult extract(File file);
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/audio/JaudiotaggerAudioTagExtractor.java b/src/main/java/com/music/metadata/infrastructure/audio/JaudiotaggerAudioTagExtractor.java
new file mode 100644
index 0000000..8a5fc67
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/audio/JaudiotaggerAudioTagExtractor.java
@@ -0,0 +1,131 @@
+package com.music.metadata.infrastructure.audio;
+
+import com.music.metadata.common.exception.BusinessException;
+import org.jaudiotagger.audio.AudioFile;
+import org.jaudiotagger.audio.AudioFileIO;
+import org.jaudiotagger.tag.FieldKey;
+import org.jaudiotagger.tag.Tag;
+import org.jaudiotagger.tag.TagField;
+import org.jaudiotagger.tag.images.Artwork;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Production extractor backed by jaudiotagger.
+ */
+@Component
+public class JaudiotaggerAudioTagExtractor implements AudioTagExtractor {
+
+ @Override
+ public AudioTagExtractionResult extract(File file) {
+ try {
+ AudioFile audioFile = AudioFileIO.read(file);
+ Tag tag = audioFile.getTag();
+
+ AudioTagExtractionResult result = new AudioTagExtractionResult();
+ result.setFileFormat(audioFile.getExt());
+
+ if (tag == null) {
+ return result;
+ }
+
+ result.setTitle(readFirst(tag, FieldKey.TITLE));
+ result.setArtist(readFirst(tag, FieldKey.ARTIST));
+ result.setAlbumArtist(readFirst(tag, FieldKey.ALBUM_ARTIST));
+ result.setAlbum(readFirst(tag, FieldKey.ALBUM));
+ result.setTrack(readTrack(tag));
+ result.setYear(readFirst(tag, FieldKey.YEAR));
+ result.setGenre(readFirst(tag, FieldKey.GENRE));
+ result.setLyrics(readFirst(tag, FieldKey.LYRICS));
+ result.setDisc(readDisc(tag));
+ result.setComment(readFirst(tag, FieldKey.COMMENT));
+
+ for (FieldKey fieldKey : FieldKey.values()) {
+ try {
+ List values = tag.getAll(fieldKey);
+ if (values == null || values.isEmpty()) {
+ continue;
+ }
+ for (String value : values) {
+ if (value != null && !value.isEmpty()) {
+ result.addField(fieldKey.name().toLowerCase(), value);
+ }
+ }
+ } catch (Exception ignored) {
+ // Some libraries throw while materializing special fields such as artwork-backed values.
+ // Best-effort extraction is enough here because core fields are read explicitly above.
+ }
+ }
+
+ try {
+ Iterator iterator = tag.getFields();
+ while (iterator.hasNext()) {
+ TagField field = iterator.next();
+ try {
+ String key = field.getId();
+ String value = field.toString();
+ if (key != null && !key.isBlank() && value != null && !value.isBlank()) {
+ result.addField(key, value);
+ }
+ } catch (Exception ignored) {
+ // Ignore malformed/special fields and keep extracting the rest.
+ }
+ }
+ } catch (UnsupportedOperationException ignored) {
+ // Some tag implementations do not expose a stable field iterator. FieldKey values remain available.
+ }
+
+ try {
+ Artwork artwork = tag.getFirstArtwork();
+ if (artwork != null) {
+ result.setCoverData(artwork.getBinaryData());
+ result.setCoverMimeType(artwork.getMimeType());
+ }
+ } catch (Exception ignored) {
+ // Some FLAC files expose artwork metadata but Jaudiotagger cannot materialize it.
+ // Keep the text tags readable and let downstream cover validation decide the result.
+ }
+ return result;
+ } catch (Exception exception) {
+ throw new BusinessException("Failed to read audio metadata: " + exception.getMessage());
+ }
+ }
+
+ private String readTrack(Tag tag) throws Exception {
+ String track = readFirst(tag, FieldKey.TRACK);
+ String trackTotal = readFirst(tag, FieldKey.TRACK_TOTAL);
+ if (track == null || track.isBlank()) {
+ return trackTotal;
+ }
+ if (track.contains("/")) {
+ return track;
+ }
+ if (trackTotal == null || trackTotal.isBlank()) {
+ return track;
+ }
+ return track + "/" + trackTotal;
+ }
+
+ private String readDisc(Tag tag) throws Exception {
+ String disc = readFirst(tag, FieldKey.DISC_NO);
+ String discTotal = readFirst(tag, FieldKey.DISC_TOTAL);
+ if (disc == null || disc.isBlank()) {
+ return discTotal;
+ }
+ if (disc.contains("/")) {
+ return disc;
+ }
+ if (discTotal == null || discTotal.isBlank()) {
+ return disc;
+ }
+ return disc + "/" + discTotal;
+ }
+
+ private String readFirst(Tag tag, FieldKey key) throws Exception {
+ String value = tag.getFirst(key);
+ return value == null || value.isBlank() ? null : value;
+ }
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/entity/DuplicateFileEntity.java b/src/main/java/com/music/metadata/infrastructure/entity/DuplicateFileEntity.java
new file mode 100644
index 0000000..d7c2720
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/entity/DuplicateFileEntity.java
@@ -0,0 +1,112 @@
+package com.music.metadata.infrastructure.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@TableName("t_duplicate_file")
+public class DuplicateFileEntity implements Serializable {
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ @TableField("task_id")
+ private Long taskId;
+
+ @TableField("file_hash")
+ private String fileHash;
+
+ @TableField("source_file_path")
+ private String sourceFilePath;
+
+ @TableField("source_file_name")
+ private String sourceFileName;
+
+ @TableField("duplicate_type")
+ private String duplicateType;
+
+ @TableField("canonical_file_process_id")
+ private Long canonicalFileProcessId;
+
+ @TableField("detail_json")
+ private String detailJson;
+
+ @TableField("created_at")
+ private LocalDateTime createdAt;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getTaskId() {
+ return taskId;
+ }
+
+ public void setTaskId(Long taskId) {
+ this.taskId = taskId;
+ }
+
+ public String getFileHash() {
+ return fileHash;
+ }
+
+ public void setFileHash(String fileHash) {
+ this.fileHash = fileHash;
+ }
+
+ public String getSourceFilePath() {
+ return sourceFilePath;
+ }
+
+ public void setSourceFilePath(String sourceFilePath) {
+ this.sourceFilePath = sourceFilePath;
+ }
+
+ public String getSourceFileName() {
+ return sourceFileName;
+ }
+
+ public void setSourceFileName(String sourceFileName) {
+ this.sourceFileName = sourceFileName;
+ }
+
+ public String getDuplicateType() {
+ return duplicateType;
+ }
+
+ public void setDuplicateType(String duplicateType) {
+ this.duplicateType = duplicateType;
+ }
+
+ public Long getCanonicalFileProcessId() {
+ return canonicalFileProcessId;
+ }
+
+ public void setCanonicalFileProcessId(Long canonicalFileProcessId) {
+ this.canonicalFileProcessId = canonicalFileProcessId;
+ }
+
+ public String getDetailJson() {
+ return detailJson;
+ }
+
+ public void setDetailJson(String detailJson) {
+ this.detailJson = detailJson;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/entity/FileProcessEntity.java b/src/main/java/com/music/metadata/infrastructure/entity/FileProcessEntity.java
index 75363c5..0d66c6e 100644
--- a/src/main/java/com/music/metadata/infrastructure/entity/FileProcessEntity.java
+++ b/src/main/java/com/music/metadata/infrastructure/entity/FileProcessEntity.java
@@ -44,6 +44,9 @@ public class FileProcessEntity implements Serializable {
@TableField("target_file_path")
private String targetFilePath;
+ @TableField("dedup_key")
+ private String dedupKey;
+
@TableField("task_id")
private Long taskId;
@@ -141,6 +144,14 @@ public class FileProcessEntity implements Serializable {
this.targetFilePath = targetFilePath;
}
+ public String getDedupKey() {
+ return dedupKey;
+ }
+
+ public void setDedupKey(String dedupKey) {
+ this.dedupKey = dedupKey;
+ }
+
public Long getTaskId() {
return taskId;
}
diff --git a/src/main/java/com/music/metadata/infrastructure/entity/MetadataSnapshotEntity.java b/src/main/java/com/music/metadata/infrastructure/entity/MetadataSnapshotEntity.java
new file mode 100644
index 0000000..2befe09
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/entity/MetadataSnapshotEntity.java
@@ -0,0 +1,93 @@
+package com.music.metadata.infrastructure.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * Stores the immutable raw metadata snapshot used for traceability and rollback.
+ */
+@TableName("t_metadata_snapshot")
+public class MetadataSnapshotEntity implements Serializable {
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ @TableField("source_file_path")
+ private String sourceFilePath;
+
+ @TableField("file_name")
+ private String fileName;
+
+ @TableField("file_format")
+ private String fileFormat;
+
+ @TableField("original_encoding")
+ private String originalEncoding;
+
+ @TableField("snapshot_json")
+ private String snapshotJson;
+
+ @TableField("created_at")
+ private LocalDateTime createdAt;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getSourceFilePath() {
+ return sourceFilePath;
+ }
+
+ public void setSourceFilePath(String sourceFilePath) {
+ this.sourceFilePath = sourceFilePath;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+ this.fileName = fileName;
+ }
+
+ public String getFileFormat() {
+ return fileFormat;
+ }
+
+ public void setFileFormat(String fileFormat) {
+ this.fileFormat = fileFormat;
+ }
+
+ public String getOriginalEncoding() {
+ return originalEncoding;
+ }
+
+ public void setOriginalEncoding(String originalEncoding) {
+ this.originalEncoding = originalEncoding;
+ }
+
+ public String getSnapshotJson() {
+ return snapshotJson;
+ }
+
+ public void setSnapshotJson(String snapshotJson) {
+ this.snapshotJson = snapshotJson;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/entity/ScanItemEntity.java b/src/main/java/com/music/metadata/infrastructure/entity/ScanItemEntity.java
new file mode 100644
index 0000000..e9014e3
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/entity/ScanItemEntity.java
@@ -0,0 +1,79 @@
+package com.music.metadata.infrastructure.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@TableName("t_scan_item")
+public class ScanItemEntity implements Serializable {
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ @TableField("task_id")
+ private Long taskId;
+
+ @TableField("source_file_path")
+ private String sourceFilePath;
+
+ @TableField("status")
+ private String status;
+
+ @TableField("error_message")
+ private String errorMessage;
+
+ @TableField("updated_at")
+ private LocalDateTime updatedAt;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getTaskId() {
+ return taskId;
+ }
+
+ public void setTaskId(Long taskId) {
+ this.taskId = taskId;
+ }
+
+ public String getSourceFilePath() {
+ return sourceFilePath;
+ }
+
+ public void setSourceFilePath(String sourceFilePath) {
+ this.sourceFilePath = sourceFilePath;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public void setErrorMessage(String errorMessage) {
+ this.errorMessage = errorMessage;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/mapper/DuplicateFileMapper.java b/src/main/java/com/music/metadata/infrastructure/mapper/DuplicateFileMapper.java
new file mode 100644
index 0000000..62b9446
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/mapper/DuplicateFileMapper.java
@@ -0,0 +1,9 @@
+package com.music.metadata.infrastructure.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DuplicateFileMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/mapper/MetadataSnapshotMapper.java b/src/main/java/com/music/metadata/infrastructure/mapper/MetadataSnapshotMapper.java
new file mode 100644
index 0000000..a91aba4
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/mapper/MetadataSnapshotMapper.java
@@ -0,0 +1,9 @@
+package com.music.metadata.infrastructure.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MetadataSnapshotMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/music/metadata/infrastructure/mapper/ScanItemMapper.java b/src/main/java/com/music/metadata/infrastructure/mapper/ScanItemMapper.java
new file mode 100644
index 0000000..b4db696
--- /dev/null
+++ b/src/main/java/com/music/metadata/infrastructure/mapper/ScanItemMapper.java
@@ -0,0 +1,9 @@
+package com.music.metadata.infrastructure.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ScanItemMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/music/metadata/service/AudioDurationService.java b/src/main/java/com/music/metadata/service/AudioDurationService.java
new file mode 100644
index 0000000..cff5613
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/AudioDurationService.java
@@ -0,0 +1,8 @@
+package com.music.metadata.service;
+
+import java.io.File;
+
+public interface AudioDurationService {
+
+ Integer getDurationSeconds(File file);
+}
diff --git a/src/main/java/com/music/metadata/service/DeduplicationService.java b/src/main/java/com/music/metadata/service/DeduplicationService.java
new file mode 100644
index 0000000..11c06dd
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/DeduplicationService.java
@@ -0,0 +1,14 @@
+package com.music.metadata.service;
+
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.scan.FeatureDeduplicationHit;
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+
+public interface DeduplicationService {
+
+ FileProcessEntity findArchivedByHash(String fileHash);
+
+ FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration);
+
+ String buildDedupKey(AudioMetadata metadata, Integer audioDuration);
+}
diff --git a/src/main/java/com/music/metadata/service/DirectoryScanService.java b/src/main/java/com/music/metadata/service/DirectoryScanService.java
new file mode 100644
index 0000000..5398106
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/DirectoryScanService.java
@@ -0,0 +1,6 @@
+package com.music.metadata.service;
+
+public interface DirectoryScanService {
+
+ void startOrResume(Long taskId, String sourcePath);
+}
diff --git a/src/main/java/com/music/metadata/service/DuplicateFileService.java b/src/main/java/com/music/metadata/service/DuplicateFileService.java
new file mode 100644
index 0000000..2b39b63
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/DuplicateFileService.java
@@ -0,0 +1,13 @@
+package com.music.metadata.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
+
+import java.util.List;
+
+public interface DuplicateFileService extends IService {
+
+ boolean create(DuplicateFileEntity entity);
+
+ List findAll();
+}
diff --git a/src/main/java/com/music/metadata/service/FileHashService.java b/src/main/java/com/music/metadata/service/FileHashService.java
new file mode 100644
index 0000000..a6b1ee7
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/FileHashService.java
@@ -0,0 +1,8 @@
+package com.music.metadata.service;
+
+import java.io.File;
+
+public interface FileHashService {
+
+ String sha256(File file);
+}
diff --git a/src/main/java/com/music/metadata/service/MetadataReaderService.java b/src/main/java/com/music/metadata/service/MetadataReaderService.java
new file mode 100644
index 0000000..7bce47d
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/MetadataReaderService.java
@@ -0,0 +1,10 @@
+package com.music.metadata.service;
+
+import com.music.metadata.domain.metadata.AudioMetadata;
+
+import java.io.File;
+
+public interface MetadataReaderService {
+
+ AudioMetadata readMetadata(File file);
+}
diff --git a/src/main/java/com/music/metadata/service/MetadataSnapshotService.java b/src/main/java/com/music/metadata/service/MetadataSnapshotService.java
new file mode 100644
index 0000000..3abde9c
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/MetadataSnapshotService.java
@@ -0,0 +1,9 @@
+package com.music.metadata.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+
+public interface MetadataSnapshotService extends IService {
+
+ boolean create(MetadataSnapshotEntity entity);
+}
diff --git a/src/main/java/com/music/metadata/service/MetadataValidatorService.java b/src/main/java/com/music/metadata/service/MetadataValidatorService.java
new file mode 100644
index 0000000..3fce774
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/MetadataValidatorService.java
@@ -0,0 +1,9 @@
+package com.music.metadata.service;
+
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+
+public interface MetadataValidatorService {
+
+ MetadataValidationResult validate(AudioMetadata metadata);
+}
diff --git a/src/main/java/com/music/metadata/service/ScanFileProcessor.java b/src/main/java/com/music/metadata/service/ScanFileProcessor.java
new file mode 100644
index 0000000..6350f17
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/ScanFileProcessor.java
@@ -0,0 +1,9 @@
+package com.music.metadata.service;
+
+import com.music.metadata.domain.scan.ScanProcessingOutcome;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+
+public interface ScanFileProcessor {
+
+ ScanProcessingOutcome process(Long taskId, ScanItemEntity scanItemEntity);
+}
diff --git a/src/main/java/com/music/metadata/service/ScanItemService.java b/src/main/java/com/music/metadata/service/ScanItemService.java
new file mode 100644
index 0000000..a347441
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/ScanItemService.java
@@ -0,0 +1,15 @@
+package com.music.metadata.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+
+import java.util.List;
+
+public interface ScanItemService extends IService {
+
+ boolean create(ScanItemEntity entity);
+
+ boolean update(ScanItemEntity entity);
+
+ List findAll();
+}
diff --git a/src/main/java/com/music/metadata/service/impl/DeduplicationServiceImpl.java b/src/main/java/com/music/metadata/service/impl/DeduplicationServiceImpl.java
new file mode 100644
index 0000000..ea29711
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/DeduplicationServiceImpl.java
@@ -0,0 +1,72 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.scan.FeatureDeduplicationHit;
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+import com.music.metadata.service.DeduplicationService;
+import com.music.metadata.service.FileHashService;
+import com.music.metadata.service.FileProcessService;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Locale;
+
+@Service
+public class DeduplicationServiceImpl implements DeduplicationService {
+
+ private final FileProcessService fileProcessService;
+
+ public DeduplicationServiceImpl(FileProcessService fileProcessService) {
+ this.fileProcessService = fileProcessService;
+ }
+
+ @Override
+ public FileProcessEntity findArchivedByHash(String fileHash) {
+ return fileProcessService.getOne(new LambdaQueryWrapper()
+ .eq(FileProcessEntity::getFileHash, fileHash)
+ .eq(FileProcessEntity::getProcessStatus, "SUCCESS")
+ .last("LIMIT 1"));
+ }
+
+ @Override
+ public FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration) {
+ String dedupKey = buildDedupKey(metadata, audioDuration);
+ FileProcessEntity canonical = fileProcessService.getOne(new LambdaQueryWrapper()
+ .eq(FileProcessEntity::getDedupKey, dedupKey)
+ .eq(FileProcessEntity::getProcessStatus, "SUCCESS")
+ .last("LIMIT 1"));
+ if (canonical == null) {
+ return null;
+ }
+ return new FeatureDeduplicationHit(dedupKey, canonical);
+ }
+
+ @Override
+ public String buildDedupKey(AudioMetadata metadata, Integer audioDuration) {
+ String artist = normalize(metadata.getArtist());
+ String title = normalize(metadata.getTitle());
+ String album = normalize(metadata.getAlbum());
+ String duration = audioDuration == null ? "0" : String.valueOf(audioDuration);
+ return sha256(artist + "|" + title + "|" + album + "|" + duration);
+ }
+
+ private String normalize(String value) {
+ return value == null ? "" : value.strip().toLowerCase(Locale.ROOT);
+ }
+
+ private String sha256(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder builder = new StringBuilder();
+ for (byte value : hash) {
+ builder.append(String.format("%02x", value));
+ }
+ return builder.toString();
+ } catch (Exception exception) {
+ throw new IllegalStateException("Failed to create deduplication key", exception);
+ }
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/DirectoryScanServiceImpl.java b/src/main/java/com/music/metadata/service/impl/DirectoryScanServiceImpl.java
new file mode 100644
index 0000000..6c46e77
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/DirectoryScanServiceImpl.java
@@ -0,0 +1,139 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.domain.scan.ScanItemStatus;
+import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+import com.music.metadata.service.DirectoryScanService;
+import com.music.metadata.service.ProcessTaskService;
+import com.music.metadata.service.ScanFileProcessor;
+import com.music.metadata.service.ScanItemService;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+@Service
+public class DirectoryScanServiceImpl implements DirectoryScanService {
+
+ private static final Set SUPPORTED_EXTENSIONS = Set.of("mp3", "flac", "m4a", "ogg", "wav");
+
+ private final ScanItemService scanItemService;
+ private final ProcessTaskService processTaskService;
+ private final ScanFileProcessor scanFileProcessor;
+
+ public DirectoryScanServiceImpl(ScanItemService scanItemService,
+ ProcessTaskService processTaskService,
+ ScanFileProcessor scanFileProcessor) {
+ this.scanItemService = scanItemService;
+ this.processTaskService = processTaskService;
+ this.scanFileProcessor = scanFileProcessor;
+ }
+
+ @Override
+ public void startOrResume(Long taskId, String sourcePath) {
+ ProcessTaskEntity existingTask = processTaskService.findById(taskId);
+ if (existingTask == null) {
+ throw new BusinessException("Process task not found: " + taskId);
+ }
+
+ discoverFiles(taskId, sourcePath);
+ ProcessTaskEntity runningTask = processTaskService.findById(taskId);
+ runningTask.setTaskStatus("RUNNING");
+ processTaskService.update(runningTask);
+
+ List pendingItems = scanItemService.list(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)
+ .in(ScanItemEntity::getStatus,
+ ScanItemStatus.DISCOVERED.name(),
+ ScanItemStatus.ERROR.name(),
+ ScanItemStatus.PROCESSING.name())
+ .orderByAsc(ScanItemEntity::getId));
+
+ boolean interrupted = false;
+ for (ScanItemEntity pendingItem : pendingItems) {
+ pendingItem.setStatus(ScanItemStatus.PROCESSING.name());
+ pendingItem.setUpdatedAt(LocalDateTime.now());
+ scanItemService.update(pendingItem);
+
+ try {
+ switch (scanFileProcessor.process(taskId, pendingItem)) {
+ case SUCCESS, LEVEL2_SUSPECT, FAILED -> pendingItem.setStatus(ScanItemStatus.DONE.name());
+ case HASH_DUPLICATE, SKIPPED -> pendingItem.setStatus(ScanItemStatus.SKIPPED.name());
+ default -> pendingItem.setStatus(ScanItemStatus.ERROR.name());
+ }
+ pendingItem.setErrorMessage(null);
+ } catch (Exception exception) {
+ pendingItem.setStatus(ScanItemStatus.ERROR.name());
+ pendingItem.setErrorMessage(exception.getMessage());
+ interrupted = true;
+ }
+
+ pendingItem.setUpdatedAt(LocalDateTime.now());
+ scanItemService.update(pendingItem);
+
+ if (interrupted) {
+ break;
+ }
+ }
+
+ ProcessTaskEntity finishedTask = processTaskService.findById(taskId);
+ finishedTask.setTaskStatus(interrupted ? "ERROR" : "FINISHED");
+ finishedTask.setEndTime(LocalDateTime.now());
+ processTaskService.update(finishedTask);
+ }
+
+ private void discoverFiles(Long taskId, String sourcePath) {
+ Path path = Paths.get(sourcePath);
+ if (!Files.exists(path)) {
+ throw new BusinessException("Source path does not exist: " + sourcePath);
+ }
+
+ try (Stream stream = Files.walk(path)) {
+ stream.filter(Files::isRegularFile)
+ .filter(this::isSupportedAudioFile)
+ .forEach(filePath -> ensureScanItemExists(taskId, filePath));
+ } catch (IOException exception) {
+ throw new BusinessException("Failed to scan source path: " + exception.getMessage());
+ }
+
+ ProcessTaskEntity task = processTaskService.findById(taskId);
+ task.setTotalFileCount((int) scanItemService.count(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)));
+ processTaskService.update(task);
+ }
+
+ private void ensureScanItemExists(Long taskId, Path filePath) {
+ String absolutePath = filePath.toAbsolutePath().toString();
+ ScanItemEntity existing = scanItemService.getOne(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)
+ .eq(ScanItemEntity::getSourceFilePath, absolutePath)
+ .last("LIMIT 1"));
+ if (existing != null) {
+ return;
+ }
+
+ ScanItemEntity entity = new ScanItemEntity();
+ entity.setTaskId(taskId);
+ entity.setSourceFilePath(absolutePath);
+ entity.setStatus(ScanItemStatus.DISCOVERED.name());
+ entity.setUpdatedAt(LocalDateTime.now());
+ scanItemService.create(entity);
+ }
+
+ private boolean isSupportedAudioFile(Path path) {
+ String fileName = path.getFileName().toString();
+ int index = fileName.lastIndexOf('.');
+ if (index < 0 || index == fileName.length() - 1) {
+ return false;
+ }
+ return SUPPORTED_EXTENSIONS.contains(fileName.substring(index + 1).toLowerCase());
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/DuplicateFileServiceImpl.java b/src/main/java/com/music/metadata/service/impl/DuplicateFileServiceImpl.java
new file mode 100644
index 0000000..2030060
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/DuplicateFileServiceImpl.java
@@ -0,0 +1,24 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
+import com.music.metadata.infrastructure.mapper.DuplicateFileMapper;
+import com.music.metadata.service.DuplicateFileService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class DuplicateFileServiceImpl extends ServiceImpl
+ implements DuplicateFileService {
+
+ @Override
+ public boolean create(DuplicateFileEntity entity) {
+ return this.save(entity);
+ }
+
+ @Override
+ public List findAll() {
+ return this.list();
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImpl.java b/src/main/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImpl.java
new file mode 100644
index 0000000..00a7ba1
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImpl.java
@@ -0,0 +1,23 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.service.AudioDurationService;
+import org.jaudiotagger.audio.AudioFile;
+import org.jaudiotagger.audio.AudioFileIO;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+
+@Service
+public class JaudiotaggerAudioDurationServiceImpl implements AudioDurationService {
+
+ @Override
+ public Integer getDurationSeconds(File file) {
+ try {
+ AudioFile audioFile = AudioFileIO.read(file);
+ return audioFile.getAudioHeader() == null ? null : audioFile.getAudioHeader().getTrackLength();
+ } catch (Exception exception) {
+ throw new BusinessException("Failed to read audio duration: " + exception.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/MetadataReaderServiceImpl.java b/src/main/java/com/music/metadata/service/impl/MetadataReaderServiceImpl.java
new file mode 100644
index 0000000..6cfcf11
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/MetadataReaderServiceImpl.java
@@ -0,0 +1,439 @@
+package com.music.metadata.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.CoverMetadata;
+import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
+import com.music.metadata.infrastructure.audio.AudioTagExtractor;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.service.MetadataReaderService;
+import com.music.metadata.service.MetadataSnapshotService;
+import org.mozilla.universalchardet.UniversalDetector;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Reads audio metadata, generates a raw JSON snapshot, and stores it for traceability.
+ */
+@Service
+public class MetadataReaderServiceImpl implements MetadataReaderService {
+
+ private final AudioTagExtractor audioTagExtractor;
+ private final MetadataSnapshotService metadataSnapshotService;
+ private final ObjectMapper objectMapper;
+
+ public MetadataReaderServiceImpl(AudioTagExtractor audioTagExtractor,
+ MetadataSnapshotService metadataSnapshotService,
+ ObjectMapper objectMapper) {
+ this.audioTagExtractor = audioTagExtractor;
+ this.metadataSnapshotService = metadataSnapshotService;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public AudioMetadata readMetadata(File file) {
+ // The extraction layer is intentionally isolated so that reader tests can focus on
+ // encoding detection, snapshot generation, and cover parsing without requiring real audio fixtures.
+ AudioTagExtractionResult extractionResult = audioTagExtractor.extract(file);
+
+ AudioMetadata metadata = new AudioMetadata();
+ metadata.setFileFormat(normalizeFormat(extractionResult.getFileFormat(), file));
+ metadata.setTitle(extractionResult.getTitle());
+ metadata.setArtist(extractionResult.getArtist());
+ metadata.setAlbumArtist(extractionResult.getAlbumArtist());
+ metadata.setAlbum(extractionResult.getAlbum());
+ metadata.setTrack(extractionResult.getTrack());
+ metadata.setYear(extractionResult.getYear());
+ metadata.setGenre(extractionResult.getGenre());
+ metadata.setLyrics(extractionResult.getLyrics());
+ metadata.setDisc(extractionResult.getDisc());
+ metadata.setComment(extractionResult.getComment());
+ metadata.setTagFields(copyFields(extractionResult.getFields()));
+ metadata.setCover(buildCoverMetadata(extractionResult));
+ metadata.setOriginalEncoding(detectOriginalEncoding(metadata));
+ boolean repaired = attemptEncodingRepair(metadata);
+ if (repaired) {
+ metadata.setOriginalEncoding("UTF-8");
+ }
+
+ String snapshotJson = buildSnapshotJson(file, metadata);
+ metadata.setSnapshotJson(snapshotJson);
+ persistSnapshot(file, metadata);
+ return metadata;
+ }
+
+ private Map> copyFields(Map> sourceFields) {
+ Map> copied = new LinkedHashMap<>();
+ sourceFields.forEach((key, values) -> copied.put(key, new ArrayList<>(values)));
+ return copied;
+ }
+
+ private CoverMetadata buildCoverMetadata(AudioTagExtractionResult extractionResult) {
+ // Cover metadata is parsed here once so the validator can enforce size, aspect ratio,
+ // and allowed format rules without reopening the image bytes.
+ CoverMetadata coverMetadata = new CoverMetadata();
+ byte[] coverData = extractionResult.getCoverData();
+ if (coverData == null || coverData.length == 0) {
+ coverMetadata.setPresent(false);
+ return coverMetadata;
+ }
+
+ coverMetadata.setPresent(true);
+ coverMetadata.setBinaryData(coverData);
+ coverMetadata.setSize((long) coverData.length);
+ coverMetadata.setMimeType(extractionResult.getCoverMimeType());
+ coverMetadata.setFormat(resolveImageFormat(extractionResult.getCoverMimeType()));
+
+ try {
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(coverData));
+ if (image != null) {
+ coverMetadata.setWidth(image.getWidth());
+ coverMetadata.setHeight(image.getHeight());
+ if (coverMetadata.getFormat() == null) {
+ coverMetadata.setFormat(resolveImageFormat(image));
+ }
+ }
+ } catch (Exception ignored) {
+ // Validation will treat unreadable artwork as an invalid cover format later.
+ }
+ return coverMetadata;
+ }
+
+ private String resolveImageFormat(BufferedImage image) {
+ return image.getColorModel().hasAlpha() ? "PNG" : null;
+ }
+
+ private String resolveImageFormat(String mimeType) {
+ if (mimeType == null) {
+ return null;
+ }
+ String normalized = mimeType.toLowerCase(Locale.ROOT);
+ if (normalized.contains("jpeg") || normalized.contains("jpg")) {
+ return "JPG";
+ }
+ if (normalized.contains("png")) {
+ return "PNG";
+ }
+ return null;
+ }
+
+ private String normalizeFormat(String extractedFormat, File file) {
+ if (extractedFormat != null && !extractedFormat.isBlank()) {
+ return extractedFormat.toUpperCase(Locale.ROOT);
+ }
+ String name = file.getName();
+ int dotIndex = name.lastIndexOf('.');
+ if (dotIndex < 0 || dotIndex == name.length() - 1) {
+ return "UNKNOWN";
+ }
+ return name.substring(dotIndex + 1).toUpperCase(Locale.ROOT);
+ }
+
+ private String detectOriginalEncoding(AudioMetadata metadata) {
+ // The document requires a strict UTF-8 gate. We therefore scan the core text fields and
+ // return the first non-UTF-8 signal so the validator can short-circuit immediately.
+ List values = new ArrayList<>();
+ values.add(metadata.getTitle());
+ values.add(metadata.getArtist());
+ values.add(metadata.getAlbumArtist());
+ values.add(metadata.getAlbum());
+ values.add(metadata.getTrack());
+ values.add(metadata.getGenre());
+ values.add(metadata.getLyrics());
+ values.add(metadata.getComment());
+ for (String value : values) {
+ String detected = detectEncoding(value);
+ if (!"UTF-8".equals(detected)) {
+ return detected;
+ }
+ }
+ return "UTF-8";
+ }
+
+ private boolean attemptEncodingRepair(AudioMetadata metadata) {
+ String encoding = metadata.getOriginalEncoding();
+ if (encoding == null || encoding.isBlank() || "UTF-8".equalsIgnoreCase(encoding)) {
+ return false;
+ }
+
+ String repairedTitle = attemptRepair(metadata.getTitle(), encoding);
+ String repairedArtist = attemptRepair(metadata.getArtist(), encoding);
+ String repairedAlbumArtist = attemptRepair(metadata.getAlbumArtist(), encoding);
+ String repairedAlbum = attemptRepair(metadata.getAlbum(), encoding);
+ String repairedGenre = attemptRepair(metadata.getGenre(), encoding);
+ String repairedLyrics = attemptRepair(metadata.getLyrics(), encoding);
+ String repairedComment = attemptRepair(metadata.getComment(), encoding);
+
+ if (repairSucceeded(metadata.getTitle(), repairedTitle)
+ || repairSucceeded(metadata.getArtist(), repairedArtist)
+ || repairSucceeded(metadata.getAlbumArtist(), repairedAlbumArtist)
+ || repairSucceeded(metadata.getAlbum(), repairedAlbum)
+ || repairSucceeded(metadata.getGenre(), repairedGenre)
+ || repairSucceeded(metadata.getLyrics(), repairedLyrics)
+ || repairSucceeded(metadata.getComment(), repairedComment)) {
+ metadata.setTitle(repairedTitle);
+ metadata.setArtist(repairedArtist);
+ metadata.setAlbumArtist(repairedAlbumArtist);
+ metadata.setAlbum(repairedAlbum);
+ metadata.setGenre(repairedGenre);
+ metadata.setLyrics(repairedLyrics);
+ metadata.setComment(repairedComment);
+ metadata.setTagFields(repairTagFields(metadata.getTagFields(), encoding));
+ return true;
+ }
+ return false;
+ }
+
+ private Map> repairTagFields(Map> tagFields, String encoding) {
+ Map> repaired = new LinkedHashMap<>();
+ tagFields.forEach((key, values) -> {
+ List repairedValues = new ArrayList<>();
+ for (String value : values) {
+ repairedValues.add(attemptRepair(value, encoding));
+ }
+ repaired.put(key, repairedValues);
+ });
+ return repaired;
+ }
+
+ private boolean repairSucceeded(String original, String repaired) {
+ return original != null
+ && repaired != null
+ && !original.equals(repaired)
+ && isLikelyCleanText(repaired)
+ && scoreText(repaired) > scoreText(original);
+ }
+
+ private String attemptRepair(String value, String encoding) {
+ if (value == null || value.isBlank() || !isLikelyMojibake(value)) {
+ return value;
+ }
+ boolean chineseMojibake = looksLikeMainlandChineseMojibake(value);
+ Set candidateCharsets = new java.util.LinkedHashSet<>();
+ if (chineseMojibake) {
+ candidateCharsets.add("GB18030");
+ candidateCharsets.add("GBK");
+ candidateCharsets.add(encoding);
+ } else {
+ candidateCharsets.add(encoding);
+ candidateCharsets.add("BIG5");
+ candidateCharsets.add("SHIFT_JIS");
+ }
+
+ String bestCandidate = value;
+ int bestScore = scoreText(value);
+ try {
+ byte[] originalBytes = value.getBytes(StandardCharsets.ISO_8859_1);
+ for (String candidateCharset : candidateCharsets) {
+ if (!Charset.isSupported(candidateCharset)) {
+ continue;
+ }
+ String repaired = new String(originalBytes, Charset.forName(candidateCharset));
+ int candidateScore = scoreText(repaired);
+ if (candidateScore > bestScore) {
+ bestScore = candidateScore;
+ bestCandidate = repaired;
+ }
+ }
+ if (chineseMojibake && countCjkCharacters(bestCandidate) == 0) {
+ return value;
+ }
+ return bestCandidate;
+ } catch (Exception ignored) {
+ return value;
+ }
+ }
+
+ private String detectEncoding(String value) {
+ if (value == null || value.isBlank()) {
+ return "UTF-8";
+ }
+ if (value.contains("\uFFFD") || value.contains("�")) {
+ return "UNKNOWN";
+ }
+ if (looksLikeMainlandChineseMojibake(value)) {
+ return "GBK";
+ }
+
+ byte[] candidateBytes = value.getBytes(StandardCharsets.ISO_8859_1);
+ UniversalDetector detector = new UniversalDetector(null);
+ detector.handleData(candidateBytes, 0, candidateBytes.length);
+ detector.dataEnd();
+ String detected = detector.getDetectedCharset();
+ detector.reset();
+
+ if (detected == null) {
+ return isLikelyCleanText(value) ? "UTF-8" : "UNKNOWN";
+ }
+ String normalized = detected.toUpperCase(Locale.ROOT);
+ if (!isLikelyCleanText(value)) {
+ return "UNKNOWN";
+ }
+ if ("US-ASCII".equals(normalized) || "ASCII".equals(normalized)) {
+ return "UTF-8";
+ }
+ if (normalized.startsWith("UTF")) {
+ return "UTF-8";
+ }
+ return normalized;
+ }
+
+ private boolean isLikelyCleanText(String value) {
+ if (value == null) {
+ return false;
+ }
+ String lowerCase = value.toLowerCase(Locale.ROOT);
+ return !(lowerCase.indexOf('ã') >= 0 || lowerCase.indexOf('â') >= 0 || lowerCase.contains("�") || hasHalfWidthKatakana(value));
+ }
+
+ private int scoreText(String value) {
+ if (value == null || value.isBlank()) {
+ return Integer.MIN_VALUE;
+ }
+ int score = 0;
+ for (char current : value.toCharArray()) {
+ Character.UnicodeBlock block = Character.UnicodeBlock.of(current);
+ if (isCjkBlock(block)) {
+ score += 6;
+ } else if (Character.isLetterOrDigit(current)) {
+ score += 2;
+ } else if (Character.isWhitespace(current) || isCommonPunctuation(current)) {
+ score += 1;
+ }
+
+ if (current == '�') {
+ score -= 10;
+ }
+ if (block == Character.UnicodeBlock.BOX_DRAWING
+ || block == Character.UnicodeBlock.BLOCK_ELEMENTS
+ || block == Character.UnicodeBlock.GEOMETRIC_SHAPES) {
+ score -= 6;
+ }
+ }
+ if (!isLikelyCleanText(value)) {
+ score -= 8;
+ }
+ return score;
+ }
+
+ private boolean isCjkBlock(Character.UnicodeBlock block) {
+ return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
+ || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
+ || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
+ || block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
+ || block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
+ || block == Character.UnicodeBlock.HIRAGANA
+ || block == Character.UnicodeBlock.KATAKANA
+ || block == Character.UnicodeBlock.HANGUL_SYLLABLES;
+ }
+
+ private boolean isCommonPunctuation(char current) {
+ return ",.;:!?()[]{}-_/\\'\"&+".indexOf(current) >= 0;
+ }
+
+ private int countCjkCharacters(String value) {
+ int count = 0;
+ for (char current : value.toCharArray()) {
+ if (isCjkBlock(Character.UnicodeBlock.of(current))) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private boolean hasHalfWidthKatakana(String value) {
+ for (char current : value.toCharArray()) {
+ if (current >= '\uFF61' && current <= '\uFF9F') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isLikelyMojibake(String value) {
+ boolean hasSuspiciousMarker = value.contains("Ã") || value.contains("â") || value.contains("Ö")
+ || value.contains("Î") || value.contains("Ê") || value.contains("�") || looksLikeMainlandChineseMojibake(value);
+ if (hasSuspiciousMarker) {
+ return true;
+ }
+ for (char current : value.toCharArray()) {
+ if (current > 0x00FF) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean looksLikeMainlandChineseMojibake(String value) {
+ return value.contains("Ö") || value.contains("Ð") || value.contains("Î") || value.contains("Ä")
+ || value.contains("Ê") || value.contains("Ì") || value.contains("Æ") || value.contains("¨");
+ }
+
+ private String buildSnapshotJson(File file, AudioMetadata metadata) {
+ Map snapshot = new LinkedHashMap<>();
+ snapshot.put("fileName", file.getName());
+ snapshot.put("filePath", file.getAbsolutePath());
+ snapshot.put("fileFormat", metadata.getFileFormat());
+ snapshot.put("originalEncoding", metadata.getOriginalEncoding());
+ snapshot.put("fields", metadata.getTagFields());
+ Map core = new LinkedHashMap<>();
+ core.put("title", metadata.getTitle());
+ core.put("artist", metadata.getArtist());
+ core.put("albumArtist", metadata.getAlbumArtist());
+ core.put("album", metadata.getAlbum());
+ core.put("track", metadata.getTrack());
+ core.put("year", metadata.getYear());
+ core.put("genre", metadata.getGenre());
+ core.put("lyrics", metadata.getLyrics());
+ core.put("disc", metadata.getDisc());
+ core.put("comment", metadata.getComment());
+ snapshot.put("core", core);
+ snapshot.put("cover", buildCoverSnapshot(metadata.getCover()));
+
+ try {
+ return objectMapper.writeValueAsString(snapshot);
+ } catch (JsonProcessingException exception) {
+ throw new BusinessException("Failed to serialize metadata snapshot: " + exception.getMessage());
+ }
+ }
+
+ private Map buildCoverSnapshot(CoverMetadata coverMetadata) {
+ if (coverMetadata == null) {
+ return Map.of("present", false);
+ }
+ Map cover = new LinkedHashMap<>();
+ cover.put("present", coverMetadata.isPresent());
+ cover.put("mimeType", coverMetadata.getMimeType());
+ cover.put("format", coverMetadata.getFormat());
+ cover.put("width", coverMetadata.getWidth());
+ cover.put("height", coverMetadata.getHeight());
+ cover.put("size", coverMetadata.getSize());
+ return cover;
+ }
+
+ private void persistSnapshot(File file, AudioMetadata metadata) {
+ MetadataSnapshotEntity entity = new MetadataSnapshotEntity();
+ entity.setSourceFilePath(file.getAbsolutePath());
+ entity.setFileName(file.getName());
+ entity.setFileFormat(metadata.getFileFormat());
+ entity.setOriginalEncoding(metadata.getOriginalEncoding());
+ entity.setSnapshotJson(metadata.getSnapshotJson());
+ entity.setCreatedAt(LocalDateTime.now());
+ metadataSnapshotService.create(entity);
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/MetadataSnapshotServiceImpl.java b/src/main/java/com/music/metadata/service/impl/MetadataSnapshotServiceImpl.java
new file mode 100644
index 0000000..719ed4e
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/MetadataSnapshotServiceImpl.java
@@ -0,0 +1,17 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.infrastructure.mapper.MetadataSnapshotMapper;
+import com.music.metadata.service.MetadataSnapshotService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class MetadataSnapshotServiceImpl extends ServiceImpl
+ implements MetadataSnapshotService {
+
+ @Override
+ public boolean create(MetadataSnapshotEntity entity) {
+ return this.save(entity);
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/MetadataValidatorServiceImpl.java b/src/main/java/com/music/metadata/service/impl/MetadataValidatorServiceImpl.java
new file mode 100644
index 0000000..d63a5d6
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/MetadataValidatorServiceImpl.java
@@ -0,0 +1,321 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.CoverMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+import com.music.metadata.domain.metadata.ValidationFailure;
+import com.music.metadata.domain.metadata.ValidationFailureType;
+import com.music.metadata.service.MetadataValidatorService;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Implements the document-defined one-vote veto validation strategy.
+ * Any core-field failure causes an immediate stop and returns exactly one failure reason.
+ */
+@Service
+public class MetadataValidatorServiceImpl implements MetadataValidatorService {
+
+ private static final Pattern TRACK_PATTERN = Pattern.compile("^(\\d{1,3})/(\\d{1,3})$");
+ private static final Pattern VERSION_PATTERN = Pattern.compile("\\s*([\\[(](?i:(live|remix|instrumental|伴奏|karaoke|acoustic|demo))[\\w\\s.-]*[\\])])\\s*");
+ private static final Pattern INVISIBLE_PATTERN = Pattern.compile("[\\p{Cntrl}&&[^\\r\\n\\t]]|[\\u200B-\\u200D\\uFEFF]");
+ private static final Pattern INVALID_FILE_NAME_CHARS = Pattern.compile("[\\\\/:*?\"<>|]");
+ private static final Pattern FEAT_SEPARATOR_PATTERN = Pattern.compile("(?i)\\s+(feat\\.?|ft\\.?)\\s+");
+ private static final List ARTICLE_WORDS = List.of("a", "an", "the");
+
+ @Override
+ public MetadataValidationResult validate(AudioMetadata metadata) {
+ AudioMetadata cleaned = deepCopy(metadata);
+ // Clean first, then validate in the exact mandatory order from the document.
+ // The first failure returns immediately to implement the one-vote veto rule.
+ normalizeOptionalFields(cleaned);
+
+ ValidationFailure failure = validateEncoding(cleaned);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateRequiredTextField("title", cleaned.getTitle(), cleaned::setTitle);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateRequiredTextField("artist", cleaned.getArtist(), cleaned::setArtist);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateRequiredTextField("album_artist", cleaned.getAlbumArtist(), cleaned::setAlbumArtist);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateRequiredTextField("album", cleaned.getAlbum(), cleaned::setAlbum);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateTrack(cleaned);
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ failure = validateCover(cleaned.getCover());
+ if (failure != null) {
+ return MetadataValidationResult.failed(failure, cleaned);
+ }
+
+ return MetadataValidationResult.passed(cleaned);
+ }
+
+ private void normalizeOptionalFields(AudioMetadata metadata) {
+ metadata.setTitle(cleanField(metadata.getTitle(), false));
+ metadata.setArtist(cleanField(metadata.getArtist(), true));
+ metadata.setAlbumArtist(cleanField(metadata.getAlbumArtist(), true));
+ metadata.setAlbum(cleanField(metadata.getAlbum(), false));
+ metadata.setGenre(cleanField(metadata.getGenre(), true));
+ metadata.setLyrics(cleanField(metadata.getLyrics(), false));
+ metadata.setDisc(cleanTrackLikeField(metadata.getDisc()));
+ metadata.setComment(cleanField(metadata.getComment(), false));
+ normalizeTitleVersionInfo(metadata);
+ normalizeTagFields(metadata);
+ }
+
+ private ValidationFailure validateEncoding(AudioMetadata metadata) {
+ String encoding = metadata.getOriginalEncoding();
+ if (encoding == null || encoding.isBlank()) {
+ return null;
+ }
+ String normalized = encoding.toUpperCase(Locale.ROOT);
+ if ("UTF-8".equals(normalized)) {
+ return null;
+ }
+ return new ValidationFailure(ValidationFailureType.ENCODING_ERROR, "encoding",
+ "Metadata encoding must be UTF-8, but detected " + normalized);
+ }
+
+ private ValidationFailure validateRequiredTextField(String fieldName,
+ String value,
+ java.util.function.Consumer consumer) {
+ if (value == null || value.isBlank()) {
+ return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
+ fieldName + " is required");
+ }
+
+ String cleanedValue = cleanField(value, "artist".equals(fieldName) || "album_artist".equals(fieldName));
+ consumer.accept(cleanedValue);
+
+ if (cleanedValue.isBlank()) {
+ return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
+ fieldName + " is required");
+ }
+ if (containsGarbledText(cleanedValue)) {
+ return new ValidationFailure(ValidationFailureType.GARBLED_TEXT, fieldName,
+ fieldName + " contains garbled characters");
+ }
+ return null;
+ }
+
+ private ValidationFailure validateTrack(AudioMetadata metadata) {
+ String track = cleanTrackLikeField(metadata.getTrack());
+ metadata.setTrack(track);
+ if (track == null || track.isBlank()) {
+ return new ValidationFailure(ValidationFailureType.MISSING_FIELD, "track", "track is required");
+ }
+
+ Matcher matcher = TRACK_PATTERN.matcher(track);
+ if (!matcher.matches()) {
+ return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
+ "track must match '序号/总曲目数', e.g. 01/12");
+ }
+
+ int current = Integer.parseInt(matcher.group(1));
+ int total = Integer.parseInt(matcher.group(2));
+ if (current <= 0 || total <= 0 || current > total) {
+ return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
+ "track numbers must be positive and current track cannot exceed total tracks");
+ }
+ metadata.setTrack(String.format(Locale.ROOT, "%02d/%02d", current, total));
+ return null;
+ }
+
+ private ValidationFailure validateCover(CoverMetadata coverMetadata) {
+ if (coverMetadata == null || !coverMetadata.isPresent() || coverMetadata.getBinaryData() == null
+ || coverMetadata.getBinaryData().length == 0) {
+ return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover", "embedded cover is required");
+ }
+ String format = coverMetadata.getFormat();
+ if (format == null || !("JPG".equalsIgnoreCase(format) || "PNG".equalsIgnoreCase(format))) {
+ return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
+ "cover format must be JPG or PNG");
+ }
+ Integer width = coverMetadata.getWidth();
+ Integer height = coverMetadata.getHeight();
+ if (width == null || height == null) {
+ return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
+ "cover image metadata cannot be parsed");
+ }
+ if (width < 300 || height < 300) {
+ return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
+ "cover dimensions must be at least 300x300");
+ }
+ if (!width.equals(height)) {
+ return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
+ "cover aspect ratio must be 1:1");
+ }
+ return null;
+ }
+
+ private void normalizeTitleVersionInfo(AudioMetadata metadata) {
+ String title = metadata.getTitle();
+ if (title == null || title.isBlank()) {
+ return;
+ }
+ Matcher matcher = VERSION_PATTERN.matcher(title);
+ if (!matcher.find()) {
+ return;
+ }
+
+ String versionInfo = matcher.group(1).trim();
+ String cleanedTitle = matcher.replaceAll(" ").trim();
+ // Version labels should not pollute the canonical title because they break matching and
+ // archive naming; they are preserved in comment to avoid losing the information.
+ metadata.setTitle(cleanedTitle);
+ if (metadata.getComment() == null || metadata.getComment().isBlank()) {
+ metadata.setComment(versionInfo);
+ } else if (!metadata.getComment().contains(versionInfo)) {
+ metadata.setComment(metadata.getComment() + "; " + versionInfo);
+ }
+ }
+
+ private void normalizeTagFields(AudioMetadata metadata) {
+ Map> normalized = new LinkedHashMap<>();
+ metadata.getTagFields().forEach((key, values) -> {
+ java.util.ArrayList cleanedValues = new java.util.ArrayList<>();
+ for (String value : values) {
+ String cleanedValue = cleanField(value, "artist".equalsIgnoreCase(key) || "album_artist".equalsIgnoreCase(key)
+ || "genre".equalsIgnoreCase(key));
+ if (cleanedValue != null && !cleanedValue.isBlank()) {
+ cleanedValues.add(cleanedValue);
+ }
+ }
+ if (!cleanedValues.isEmpty()) {
+ normalized.put(key, cleanedValues);
+ }
+ });
+ metadata.setTagFields(normalized);
+ }
+
+ private String cleanField(String value, boolean normalizeSeparators) {
+ if (value == null) {
+ return null;
+ }
+ String cleaned = value.strip();
+ cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
+ cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
+ cleaned = INVALID_FILE_NAME_CHARS.matcher(cleaned).replaceAll("_");
+ cleaned = cleaned.replaceAll("\\s+", " ").trim();
+ if (normalizeSeparators) {
+ cleaned = normalizeSeparators(cleaned);
+ }
+ cleaned = normalizeEnglishCase(cleaned);
+ return cleaned;
+ }
+
+ private String cleanTrackLikeField(String value) {
+ if (value == null) {
+ return null;
+ }
+ String cleaned = value.strip();
+ cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
+ cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
+ cleaned = cleaned.replaceAll("\\s+", "");
+ return cleaned;
+ }
+
+ private String normalizeSeparators(String value) {
+ String normalized = FEAT_SEPARATOR_PATTERN.matcher(value).replaceAll("; ");
+ normalized = normalized.replaceAll("\\s*&\\s*", "; ");
+ normalized = normalized.replaceAll("\\s+/\\s+", "; ");
+ normalized = normalized.replaceAll(";\\s*;", "; ");
+ return normalized.trim();
+ }
+
+ private String normalizeEnglishCase(String value) {
+ if (value == null || value.isBlank()) {
+ return value;
+ }
+ String[] words = value.split(" ");
+ StringBuilder builder = new StringBuilder();
+ for (int index = 0; index < words.length; index++) {
+ if (index > 0) {
+ builder.append(' ');
+ }
+ builder.append(normalizeWord(words[index], index == 0));
+ }
+ return builder.toString();
+ }
+
+ private String normalizeWord(String word, boolean firstWord) {
+ if (!word.matches(".*[A-Za-z].*")) {
+ return word;
+ }
+
+ String[] tokens = word.split("(?=[/-])|(?<=[/-])");
+ StringBuilder builder = new StringBuilder();
+ for (String token : tokens) {
+ if (token.equals("/") || token.equals("-")) {
+ builder.append(token);
+ continue;
+ }
+ String lower = token.toLowerCase(Locale.ROOT);
+ if (!firstWord && ARTICLE_WORDS.contains(lower)) {
+ builder.append(lower);
+ } else {
+ builder.append(Character.toUpperCase(lower.charAt(0))).append(lower.substring(1));
+ }
+ }
+ return builder.toString();
+ }
+
+ private boolean containsGarbledText(String value) {
+ return value.contains("�") || value.contains("\uFFFD") || value.contains("Ã") || value.contains("â");
+ }
+
+ private AudioMetadata deepCopy(AudioMetadata source) {
+ AudioMetadata copy = new AudioMetadata();
+ copy.setFileFormat(source.getFileFormat());
+ copy.setOriginalEncoding(source.getOriginalEncoding());
+ copy.setSnapshotJson(source.getSnapshotJson());
+ copy.setTitle(source.getTitle());
+ copy.setArtist(source.getArtist());
+ copy.setAlbumArtist(source.getAlbumArtist());
+ copy.setAlbum(source.getAlbum());
+ copy.setTrack(source.getTrack());
+ copy.setYear(source.getYear());
+ copy.setGenre(source.getGenre());
+ copy.setLyrics(source.getLyrics());
+ copy.setDisc(source.getDisc());
+ copy.setComment(source.getComment());
+ copy.setTagFields(new LinkedHashMap<>(source.getTagFields()));
+
+ if (source.getCover() != null) {
+ CoverMetadata coverCopy = new CoverMetadata();
+ coverCopy.setPresent(source.getCover().isPresent());
+ coverCopy.setMimeType(source.getCover().getMimeType());
+ coverCopy.setFormat(source.getCover().getFormat());
+ coverCopy.setWidth(source.getCover().getWidth());
+ coverCopy.setHeight(source.getCover().getHeight());
+ coverCopy.setSize(source.getCover().getSize());
+ coverCopy.setBinaryData(source.getCover().getBinaryData());
+ copy.setCover(coverCopy);
+ }
+ return copy;
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/ScanFileProcessorImpl.java b/src/main/java/com/music/metadata/service/impl/ScanFileProcessorImpl.java
new file mode 100644
index 0000000..ed4fe91
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/ScanFileProcessorImpl.java
@@ -0,0 +1,215 @@
+package com.music.metadata.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+import com.music.metadata.domain.scan.DuplicateType;
+import com.music.metadata.domain.scan.FeatureDeduplicationHit;
+import com.music.metadata.domain.scan.ScanProcessingOutcome;
+import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+import com.music.metadata.service.AudioDurationService;
+import com.music.metadata.service.DeduplicationService;
+import com.music.metadata.service.DuplicateFileService;
+import com.music.metadata.service.FileHashService;
+import com.music.metadata.service.FileProcessService;
+import com.music.metadata.service.MetadataReaderService;
+import com.music.metadata.service.MetadataValidatorService;
+import com.music.metadata.service.ProcessTaskService;
+import com.music.metadata.service.ScanFileProcessor;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Service
+public class ScanFileProcessorImpl implements ScanFileProcessor {
+
+ private final FileHashService fileHashService;
+ private final MetadataReaderService metadataReaderService;
+ private final MetadataValidatorService metadataValidatorService;
+ private final AudioDurationService audioDurationService;
+ private final DeduplicationService deduplicationService;
+ private final FileProcessService fileProcessService;
+ private final DuplicateFileService duplicateFileService;
+ private final ProcessTaskService processTaskService;
+ private final ObjectMapper objectMapper;
+
+ public ScanFileProcessorImpl(FileHashService fileHashService,
+ MetadataReaderService metadataReaderService,
+ MetadataValidatorService metadataValidatorService,
+ AudioDurationService audioDurationService,
+ DeduplicationService deduplicationService,
+ FileProcessService fileProcessService,
+ DuplicateFileService duplicateFileService,
+ ProcessTaskService processTaskService,
+ ObjectMapper objectMapper) {
+ this.fileHashService = fileHashService;
+ this.metadataReaderService = metadataReaderService;
+ this.metadataValidatorService = metadataValidatorService;
+ this.audioDurationService = audioDurationService;
+ this.deduplicationService = deduplicationService;
+ this.fileProcessService = fileProcessService;
+ this.duplicateFileService = duplicateFileService;
+ this.processTaskService = processTaskService;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public ScanProcessingOutcome process(Long taskId, ScanItemEntity scanItemEntity) {
+ File file = new File(scanItemEntity.getSourceFilePath());
+ String fileHash = fileHashService.sha256(file);
+
+ FileProcessEntity archived = deduplicationService.findArchivedByHash(fileHash);
+ if (archived != null) {
+ createDuplicateRecord(taskId, file, fileHash, DuplicateType.HASH_DUPLICATE, archived.getId(),
+ Map.of("reason", "Archived file scanned again; skipped immediately"));
+ incrementDuplicateCount(taskId);
+ return ScanProcessingOutcome.HASH_DUPLICATE;
+ }
+
+ AudioMetadata metadata = metadataReaderService.readMetadata(file);
+ MetadataValidationResult validationResult = metadataValidatorService.validate(metadata);
+ if (!validationResult.isPassed()) {
+ createFailedProcessRecord(taskId, file, fileHash, metadata.getSnapshotJson(), validationResult.getFailures().get(0).getMessage());
+ incrementFailCount(taskId);
+ return ScanProcessingOutcome.FAILED;
+ }
+
+ Integer durationSeconds = audioDurationService.getDurationSeconds(file);
+ AudioMetadata cleanedMetadata = validationResult.getCleanedMetadata();
+ FeatureDeduplicationHit level2Hit = deduplicationService.findLevel2Duplicate(cleanedMetadata, durationSeconds);
+ if (level2Hit != null) {
+ createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
+ level2Hit.getDedupKey(), "SUSPECT_DUPLICATE");
+ createDuplicateRecord(taskId, file, fileHash, DuplicateType.LEVEL2_SUSPECT, level2Hit.getCanonicalFile().getId(),
+ Map.of(
+ "reason", "Matched level-2 deduplication key",
+ "dedupKey", level2Hit.getDedupKey(),
+ "canonicalFileHash", level2Hit.getCanonicalFile().getFileHash()
+ ));
+ incrementDuplicateCount(taskId);
+ return ScanProcessingOutcome.LEVEL2_SUSPECT;
+ }
+
+ createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
+ deduplicationService.buildDedupKey(cleanedMetadata, durationSeconds), "SUCCESS");
+ incrementSuccessCount(taskId);
+ return ScanProcessingOutcome.SUCCESS;
+ }
+
+ private void createSuccessfulProcessRecord(Long taskId,
+ File file,
+ String fileHash,
+ String rawMetadata,
+ Integer durationSeconds,
+ String dedupKey,
+ String status) {
+ FileProcessEntity entity = new FileProcessEntity();
+ entity.setFileHash(fileHash);
+ entity.setSourceFilePath(file.getAbsolutePath());
+ entity.setSourceFileName(file.getName());
+ entity.setFileExtension(resolveExtension(file));
+ entity.setFileSize(file.length());
+ entity.setAudioDuration(durationSeconds);
+ entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
+ entity.setProcessStatus(status);
+ entity.setDedupKey(dedupKey);
+ entity.setTaskId(taskId);
+ entity.setCreatedAt(LocalDateTime.now());
+ entity.setUpdatedAt(LocalDateTime.now());
+ fileProcessService.create(entity);
+ }
+
+ private void createFailedProcessRecord(Long taskId, File file, String fileHash, String rawMetadata, String failReason) {
+ if (fileProcessService.getOne(new LambdaQueryWrapper()
+ .eq(FileProcessEntity::getFileHash, fileHash)
+ .last("LIMIT 1")) != null) {
+ return;
+ }
+ FileProcessEntity entity = new FileProcessEntity();
+ entity.setFileHash(fileHash);
+ entity.setSourceFilePath(file.getAbsolutePath());
+ entity.setSourceFileName(file.getName());
+ entity.setFileExtension(resolveExtension(file));
+ entity.setFileSize(file.length());
+ entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
+ entity.setProcessStatus("FAIL");
+ entity.setFailReason(failReason);
+ entity.setTaskId(taskId);
+ entity.setCreatedAt(LocalDateTime.now());
+ entity.setUpdatedAt(LocalDateTime.now());
+ fileProcessService.create(entity);
+ }
+
+ private void createDuplicateRecord(Long taskId,
+ File file,
+ String fileHash,
+ DuplicateType duplicateType,
+ Long canonicalFileProcessId,
+ Map detail) {
+ DuplicateFileEntity entity = new DuplicateFileEntity();
+ entity.setTaskId(taskId);
+ entity.setFileHash(fileHash);
+ entity.setSourceFilePath(file.getAbsolutePath());
+ entity.setSourceFileName(file.getName());
+ entity.setDuplicateType(duplicateType.name());
+ entity.setCanonicalFileProcessId(canonicalFileProcessId);
+ entity.setDetailJson(writeJson(detail));
+ entity.setCreatedAt(LocalDateTime.now());
+ duplicateFileService.create(entity);
+ }
+
+ private void incrementSuccessCount(Long taskId) {
+ ProcessTaskEntity task = loadTask(taskId);
+ task.setSuccessCount(defaultZero(task.getSuccessCount()) + 1);
+ task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
+ processTaskService.update(task);
+ }
+
+ private void incrementFailCount(Long taskId) {
+ ProcessTaskEntity task = loadTask(taskId);
+ task.setFailCount(defaultZero(task.getFailCount()) + 1);
+ task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
+ processTaskService.update(task);
+ }
+
+ private void incrementDuplicateCount(Long taskId) {
+ ProcessTaskEntity task = loadTask(taskId);
+ task.setDuplicateCount(defaultZero(task.getDuplicateCount()) + 1);
+ task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
+ processTaskService.update(task);
+ }
+
+ private ProcessTaskEntity loadTask(Long taskId) {
+ ProcessTaskEntity task = processTaskService.findById(taskId);
+ if (task == null) {
+ throw new BusinessException("Process task not found: " + taskId);
+ }
+ return task;
+ }
+
+ private int defaultZero(Integer value) {
+ return value == null ? 0 : value;
+ }
+
+ private String writeJson(Map detail) {
+ try {
+ return objectMapper.writeValueAsString(detail);
+ } catch (Exception exception) {
+ throw new BusinessException("Failed to serialize duplicate detail: " + exception.getMessage());
+ }
+ }
+
+ private String resolveExtension(File file) {
+ String fileName = file.getName();
+ int index = fileName.lastIndexOf('.');
+ return index < 0 ? "" : fileName.substring(index + 1).toLowerCase();
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/ScanItemServiceImpl.java b/src/main/java/com/music/metadata/service/impl/ScanItemServiceImpl.java
new file mode 100644
index 0000000..b0a4aa3
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/ScanItemServiceImpl.java
@@ -0,0 +1,28 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+import com.music.metadata.infrastructure.mapper.ScanItemMapper;
+import com.music.metadata.service.ScanItemService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class ScanItemServiceImpl extends ServiceImpl implements ScanItemService {
+
+ @Override
+ public boolean create(ScanItemEntity entity) {
+ return this.save(entity);
+ }
+
+ @Override
+ public boolean update(ScanItemEntity entity) {
+ return this.updateById(entity);
+ }
+
+ @Override
+ public List findAll() {
+ return this.list();
+ }
+}
diff --git a/src/main/java/com/music/metadata/service/impl/Sha256FileHashServiceImpl.java b/src/main/java/com/music/metadata/service/impl/Sha256FileHashServiceImpl.java
new file mode 100644
index 0000000..5ac5b25
--- /dev/null
+++ b/src/main/java/com/music/metadata/service/impl/Sha256FileHashServiceImpl.java
@@ -0,0 +1,33 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.service.FileHashService;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.security.MessageDigest;
+
+@Service
+public class Sha256FileHashServiceImpl implements FileHashService {
+
+ @Override
+ public String sha256(File file) {
+ try (InputStream inputStream = new FileInputStream(file)) {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = inputStream.read(buffer)) != -1) {
+ digest.update(buffer, 0, read);
+ }
+ StringBuilder builder = new StringBuilder();
+ for (byte value : digest.digest()) {
+ builder.append(String.format("%02x", value));
+ }
+ return builder.toString();
+ } catch (Exception exception) {
+ throw new BusinessException("Failed to calculate file hash: " + exception.getMessage());
+ }
+ }
+}
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
index 2e66501..fb1c733 100644
--- a/src/main/resources/schema.sql
+++ b/src/main/resources/schema.sql
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS t_file_process (
process_status VARCHAR(20) NOT NULL,
fail_reason TEXT,
target_file_path VARCHAR(1000),
+ dedup_key VARCHAR(64),
task_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
@@ -63,3 +64,45 @@ CREATE TABLE IF NOT EXISTS t_system_config (
updated_at DATETIME NOT NULL,
CONSTRAINT uk_t_system_config_config_key UNIQUE (config_key)
);
+
+CREATE TABLE IF NOT EXISTS t_metadata_snapshot (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ source_file_path VARCHAR(1000) NOT NULL,
+ file_name VARCHAR(200) NOT NULL,
+ file_format VARCHAR(20) NOT NULL,
+ original_encoding VARCHAR(50) NOT NULL,
+ snapshot_json TEXT NOT NULL,
+ created_at DATETIME NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_t_file_process_dedup_key ON t_file_process (dedup_key);
+
+CREATE TABLE IF NOT EXISTS t_duplicate_file (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ task_id BIGINT NOT NULL,
+ file_hash VARCHAR(64) NOT NULL,
+ source_file_path VARCHAR(1000) NOT NULL,
+ source_file_name VARCHAR(200) NOT NULL,
+ duplicate_type VARCHAR(30) NOT NULL,
+ canonical_file_process_id BIGINT,
+ detail_json TEXT,
+ created_at DATETIME NOT NULL,
+ CONSTRAINT fk_t_duplicate_file_task_id FOREIGN KEY (task_id) REFERENCES t_process_task (id),
+ CONSTRAINT fk_t_duplicate_file_file_process_id FOREIGN KEY (canonical_file_process_id) REFERENCES t_file_process (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_t_duplicate_file_task_type ON t_duplicate_file (task_id, duplicate_type);
+CREATE INDEX IF NOT EXISTS idx_t_duplicate_file_hash ON t_duplicate_file (file_hash);
+
+CREATE TABLE IF NOT EXISTS t_scan_item (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ task_id BIGINT NOT NULL,
+ source_file_path VARCHAR(1000) NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ error_message TEXT,
+ updated_at DATETIME NOT NULL,
+ CONSTRAINT uk_t_scan_item_task_path UNIQUE (task_id, source_file_path),
+ CONSTRAINT fk_t_scan_item_task_id FOREIGN KEY (task_id) REFERENCES t_process_task (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_t_scan_item_task_status ON t_scan_item (task_id, status);
diff --git a/src/test/java/com/music/metadata/service/impl/CoverageSupportTest.java b/src/test/java/com/music/metadata/service/impl/CoverageSupportTest.java
new file mode 100644
index 0000000..c3da6d5
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/CoverageSupportTest.java
@@ -0,0 +1,108 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.common.api.ApiResponse;
+import com.music.metadata.common.exception.BusinessException;
+import com.music.metadata.common.handler.GlobalExceptionHandler;
+import com.music.metadata.infrastructure.entity.FailFileEntity;
+import com.music.metadata.infrastructure.entity.SystemConfigEntity;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CoverageSupportTest {
+
+ @Test
+ void apiResponseShouldExposeFactoriesAndAccessors() {
+ ApiResponse success = ApiResponse.success("ok");
+ assertThat(success.getCode()).isEqualTo(200);
+ assertThat(success.getMessage()).isEqualTo("操作成功");
+ assertThat(success.getData()).isEqualTo("ok");
+ assertThat(success.getTimestamp()).isNotNull();
+
+ ApiResponse response = new ApiResponse<>();
+ response.setCode(400);
+ response.setMessage("bad");
+ response.setData("payload");
+ response.setTimestamp(123L);
+ assertThat(response.getCode()).isEqualTo(400);
+ assertThat(response.getMessage()).isEqualTo("bad");
+ assertThat(response.getData()).isEqualTo("payload");
+ assertThat(response.getTimestamp()).isEqualTo(123L);
+
+ ApiResponse failed = ApiResponse.fail(422, "oops");
+ assertThat(failed.getCode()).isEqualTo(422);
+ assertThat(failed.getMessage()).isEqualTo("oops");
+ }
+
+ @Test
+ void businessExceptionAndGlobalHandlerShouldReturnExpectedResponses() {
+ BusinessException defaultException = new BusinessException("default");
+ assertThat(defaultException.getCode()).isEqualTo(400);
+
+ BusinessException customException = new BusinessException(422, "custom");
+ assertThat(customException.getCode()).isEqualTo(422);
+ assertThat(customException.getMessage()).isEqualTo("custom");
+
+ GlobalExceptionHandler handler = new GlobalExceptionHandler();
+ ApiResponse business = handler.handleBusinessException(customException);
+ assertThat(business.getCode()).isEqualTo(422);
+ assertThat(business.getMessage()).isEqualTo("custom");
+
+ ApiResponse generic = handler.handleException(new RuntimeException("boom"));
+ assertThat(generic.getCode()).isEqualTo(500);
+ assertThat(generic.getMessage()).isEqualTo("系统异常,请稍后重试");
+ }
+
+ @Test
+ void entitiesShouldExposeAllAccessors() {
+ FailFileEntity failFile = new FailFileEntity();
+ LocalDateTime now = LocalDateTime.now();
+ failFile.setId(1L);
+ failFile.setFileProcessId(2L);
+ failFile.setFileHash("hash");
+ failFile.setSourceFilePath("/tmp/a.mp3");
+ failFile.setFileName("a.mp3");
+ failFile.setFailType("MISSING_FIELD");
+ failFile.setFailDetail("detail");
+ failFile.setRawMetadata("{}");
+ failFile.setEditMetadata("{edited:true}");
+ failFile.setStatus("PENDING");
+ failFile.setCreatedAt(now);
+ failFile.setUpdatedAt(now);
+ failFile.setResolvedAt(now);
+ assertThat(failFile.getId()).isEqualTo(1L);
+ assertThat(failFile.getFileProcessId()).isEqualTo(2L);
+ assertThat(failFile.getFileHash()).isEqualTo("hash");
+ assertThat(failFile.getSourceFilePath()).isEqualTo("/tmp/a.mp3");
+ assertThat(failFile.getFileName()).isEqualTo("a.mp3");
+ assertThat(failFile.getFailType()).isEqualTo("MISSING_FIELD");
+ assertThat(failFile.getFailDetail()).isEqualTo("detail");
+ assertThat(failFile.getRawMetadata()).isEqualTo("{}");
+ assertThat(failFile.getEditMetadata()).isEqualTo("{edited:true}");
+ assertThat(failFile.getStatus()).isEqualTo("PENDING");
+ assertThat(failFile.getCreatedAt()).isEqualTo(now);
+ assertThat(failFile.getUpdatedAt()).isEqualTo(now);
+ assertThat(failFile.getResolvedAt()).isEqualTo(now);
+
+ SystemConfigEntity config = new SystemConfigEntity();
+ config.setId(3L);
+ config.setConfigKey("key");
+ config.setConfigValue("value");
+ config.setConfigName("name");
+ config.setConfigDesc("desc");
+ config.setIsEditable(1);
+ config.setCreatedAt(now);
+ config.setUpdatedAt(now);
+ assertThat(config.getId()).isEqualTo(3L);
+ assertThat(config.getConfigKey()).isEqualTo("key");
+ assertThat(config.getConfigValue()).isEqualTo("value");
+ assertThat(config.getConfigName()).isEqualTo("name");
+ assertThat(config.getConfigDesc()).isEqualTo("desc");
+ assertThat(config.getIsEditable()).isEqualTo(1);
+ assertThat(config.getCreatedAt()).isEqualTo(now);
+ assertThat(config.getUpdatedAt()).isEqualTo(now);
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/DirectoryScanP1AcceptanceTest.java b/src/test/java/com/music/metadata/service/impl/DirectoryScanP1AcceptanceTest.java
new file mode 100644
index 0000000..4dcf7f0
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/DirectoryScanP1AcceptanceTest.java
@@ -0,0 +1,282 @@
+package com.music.metadata.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.music.metadata.MusicMetadataSystemApplication;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.CoverMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+import com.music.metadata.domain.scan.ScanItemStatus;
+import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
+import com.music.metadata.infrastructure.entity.ScanItemEntity;
+import com.music.metadata.service.AudioDurationService;
+import com.music.metadata.service.DirectoryScanService;
+import com.music.metadata.service.DuplicateFileService;
+import com.music.metadata.service.FileProcessService;
+import com.music.metadata.service.MetadataReaderService;
+import com.music.metadata.service.MetadataValidatorService;
+import com.music.metadata.service.ProcessTaskService;
+import com.music.metadata.service.ScanItemService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.io.IOException;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringBootTest(classes = MusicMetadataSystemApplication.class)
+@ActiveProfiles("test")
+class DirectoryScanP1AcceptanceTest {
+
+ @Autowired
+ private DirectoryScanService directoryScanService;
+
+ @Autowired
+ private ProcessTaskService processTaskService;
+
+ @Autowired
+ private FileProcessService fileProcessService;
+
+ @Autowired
+ private DuplicateFileService duplicateFileService;
+
+ @Autowired
+ private ScanItemService scanItemService;
+
+ @MockBean
+ private MetadataReaderService metadataReaderService;
+
+ @MockBean
+ private MetadataValidatorService metadataValidatorService;
+
+ @MockBean
+ private AudioDurationService audioDurationService;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ scanItemService.remove(null);
+ duplicateFileService.remove(null);
+ fileProcessService.remove(null);
+ processTaskService.remove(null);
+ reset(metadataReaderService, metadataValidatorService, audioDurationService);
+ }
+
+ @Test
+ void shouldMarkCopiedFileAsHashDuplicateAndSkipMetadataPipeline() throws Exception {
+ Path sourceDir = Files.createDirectory(tempDir.resolve("hash-duplicate"));
+ Path original = createAudioPlaceholder(sourceDir.resolve("song-a.mp3"), "same-content");
+ Path copied = createAudioPlaceholder(sourceDir.resolve("song-a-copy.mp3"), "same-content");
+
+ stubSuccessfulMetadataPipeline("Song A", "Artist", "Album", 210);
+
+ Long firstTaskId = createTask(sourceDir);
+ directoryScanService.startOrResume(firstTaskId, sourceDir.toString());
+
+ reset(metadataReaderService, metadataValidatorService, audioDurationService);
+
+ Long secondTaskId = createTask(sourceDir);
+ directoryScanService.startOrResume(secondTaskId, sourceDir.toString());
+
+ List duplicates = duplicateFileService.list(new LambdaQueryWrapper()
+ .eq(DuplicateFileEntity::getTaskId, secondTaskId));
+ assertThat(duplicates).hasSize(2);
+ assertThat(duplicates).allMatch(item -> "HASH_DUPLICATE".equals(item.getDuplicateType()));
+
+ ProcessTaskEntity secondTask = processTaskService.findById(secondTaskId);
+ assertThat(secondTask.getDuplicateCount()).isEqualTo(2);
+ verify(metadataReaderService, never()).readMetadata(any());
+ verify(metadataValidatorService, never()).validate(any());
+ verify(audioDurationService, never()).getDurationSeconds(any());
+
+ assertThat(Files.exists(original)).isTrue();
+ assertThat(Files.exists(copied)).isTrue();
+ }
+
+ @Test
+ void shouldMarkDifferentBitrateVersionsAsLevel2SuspectDuplicate() throws Exception {
+ Path sourceDir = Files.createDirectory(tempDir.resolve("level2"));
+ Path lossless = createAudioPlaceholder(sourceDir.resolve("song.flac"), "lossless-content");
+ Path lossy = createAudioPlaceholder(sourceDir.resolve("song-320.mp3"), "lossy-content");
+
+ when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
+ File file = invocation.getArgument(0);
+ return buildValidMetadata(file.getName(), "Shared Song", "Shared Artist", "Shared Album");
+ });
+ when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
+ when(audioDurationService.getDurationSeconds(any())).thenReturn(188);
+
+ Long taskId = createTask(sourceDir);
+ directoryScanService.startOrResume(taskId, sourceDir.toString());
+
+ List duplicates = duplicateFileService.list(new LambdaQueryWrapper()
+ .eq(DuplicateFileEntity::getTaskId, taskId));
+ assertThat(duplicates).hasSize(1);
+ assertThat(duplicates.get(0).getDuplicateType()).isEqualTo("LEVEL2_SUSPECT");
+
+ List records = fileProcessService.findAll();
+ assertThat(records).hasSize(2);
+ assertThat(records).extracting(FileProcessEntity::getProcessStatus)
+ .containsExactlyInAnyOrder("SUCCESS", "SUSPECT_DUPLICATE");
+
+ verify(metadataReaderService, atLeastOnce()).readMetadata(any());
+ assertThat(Files.exists(lossless)).isTrue();
+ assertThat(Files.exists(lossy)).isTrue();
+ }
+
+ @Test
+ void shouldResumeScanForOneThousandFilesAfterInterruption() throws Exception {
+ Path sourceDir = Files.createDirectory(tempDir.resolve("resume"));
+ for (int index = 0; index < 1000; index++) {
+ createAudioPlaceholder(sourceDir.resolve(String.format("file-%04d.mp3", index)), "content-" + index);
+ }
+
+ final int[] invocationCounter = {0};
+ Mockito.doAnswer(invocation -> {
+ invocationCounter[0]++;
+ if (invocationCounter[0] == 401) {
+ throw new RuntimeException("Simulated interruption");
+ }
+ File file = invocation.getArgument(0);
+ return buildValidMetadata(file.getName(), "Artist", "Album");
+ }).when(metadataReaderService).readMetadata(any());
+ when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
+ when(audioDurationService.getDurationSeconds(any())).thenReturn(200);
+
+ Long taskId = createTask(sourceDir);
+ directoryScanService.startOrResume(taskId, sourceDir.toString());
+
+ long errorCount = scanItemService.count(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)
+ .eq(ScanItemEntity::getStatus, ScanItemStatus.ERROR.name()));
+ long doneCountAfterFirstRun = scanItemService.count(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)
+ .eq(ScanItemEntity::getStatus, ScanItemStatus.DONE.name()));
+ assertThat(errorCount).isEqualTo(1);
+ assertThat(doneCountAfterFirstRun).isEqualTo(400);
+
+ reset(metadataReaderService, metadataValidatorService, audioDurationService);
+ when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
+ File file = invocation.getArgument(0);
+ return buildValidMetadata(file.getName(), "Artist", "Album");
+ });
+ when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
+ when(audioDurationService.getDurationSeconds(any())).thenReturn(200);
+
+ directoryScanService.startOrResume(taskId, sourceDir.toString());
+
+ ProcessTaskEntity task = processTaskService.findById(taskId);
+ assertThat(task.getProcessedCount()).isEqualTo(1000);
+ assertThat(task.getSuccessCount()).isEqualTo(1000);
+ assertThat(scanItemService.count(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, taskId)
+ .eq(ScanItemEntity::getStatus, ScanItemStatus.ERROR.name()))).isZero();
+ assertThat(fileProcessService.count()).isEqualTo(1000);
+ }
+
+ @Test
+ void shouldSkipArchivedFileWhenRescanned() throws Exception {
+ Path sourceDir = Files.createDirectory(tempDir.resolve("archived"));
+ createAudioPlaceholder(sourceDir.resolve("archived-song.mp3"), "archived-content");
+
+ stubSuccessfulMetadataPipeline("Archived Song", "Artist", "Album", 240);
+
+ Long firstTaskId = createTask(sourceDir);
+ directoryScanService.startOrResume(firstTaskId, sourceDir.toString());
+
+ reset(metadataReaderService, metadataValidatorService, audioDurationService);
+
+ Long secondTaskId = createTask(sourceDir);
+ directoryScanService.startOrResume(secondTaskId, sourceDir.toString());
+
+ ProcessTaskEntity secondTask = processTaskService.findById(secondTaskId);
+ assertThat(secondTask.getDuplicateCount()).isEqualTo(1);
+
+ ScanItemEntity scanItem = scanItemService.getOne(new LambdaQueryWrapper()
+ .eq(ScanItemEntity::getTaskId, secondTaskId)
+ .last("LIMIT 1"));
+ assertThat(scanItem.getStatus()).isEqualTo(ScanItemStatus.SKIPPED.name());
+
+ DuplicateFileEntity duplicate = duplicateFileService.getOne(new LambdaQueryWrapper()
+ .eq(DuplicateFileEntity::getTaskId, secondTaskId)
+ .last("LIMIT 1"));
+ assertThat(duplicate.getDuplicateType()).isEqualTo("HASH_DUPLICATE");
+ verify(metadataReaderService, never()).readMetadata(any());
+ }
+
+ private Long createTask(Path sourceDir) {
+ ProcessTaskEntity entity = new ProcessTaskEntity();
+ entity.setTaskName("scan-task-" + System.nanoTime());
+ entity.setTaskType("DIR_SCAN");
+ entity.setSourcePath(sourceDir.toString());
+ entity.setTotalFileCount(0);
+ entity.setProcessedCount(0);
+ entity.setSuccessCount(0);
+ entity.setFailCount(0);
+ entity.setDuplicateCount(0);
+ entity.setTaskStatus("PENDING");
+ entity.setStartTime(LocalDateTime.now());
+ entity.setCreatedBy("test");
+ processTaskService.create(entity);
+ return entity.getId();
+ }
+
+ private Path createAudioPlaceholder(Path file, String content) throws IOException {
+ return Files.writeString(file, content, StandardCharsets.UTF_8);
+ }
+
+ private void stubSuccessfulMetadataPipeline(String title, String artist, String album, int durationSeconds) {
+ when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
+ File file = invocation.getArgument(0);
+ return buildValidMetadata(file.getName(), title, artist, album);
+ });
+ when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
+ when(audioDurationService.getDurationSeconds(any())).thenReturn(durationSeconds);
+ }
+
+ private AudioMetadata buildValidMetadata(String title, String artist, String album) {
+ return buildValidMetadata(title, title, artist, album);
+ }
+
+ private AudioMetadata buildValidMetadata(String snapshotMarker, String title, String artist, String album) {
+ AudioMetadata metadata = new AudioMetadata();
+ metadata.setOriginalEncoding("UTF-8");
+ metadata.setFileFormat("MP3");
+ metadata.setTitle(title);
+ metadata.setArtist(artist);
+ metadata.setAlbumArtist(artist);
+ metadata.setAlbum(album);
+ metadata.setTrack("01/01");
+ metadata.setSnapshotJson("{\"file\":\"" + snapshotMarker + "\"}");
+
+ CoverMetadata cover = new CoverMetadata();
+ cover.setPresent(true);
+ cover.setFormat("JPG");
+ cover.setWidth(600);
+ cover.setHeight(600);
+ cover.setBinaryData(new byte[]{1, 2, 3});
+ metadata.setCover(cover);
+ return metadata;
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImplTest.java b/src/test/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImplTest.java
new file mode 100644
index 0000000..eb90e70
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/JaudiotaggerAudioDurationServiceImplTest.java
@@ -0,0 +1,21 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class JaudiotaggerAudioDurationServiceImplTest {
+
+ @Test
+ void getDurationSecondsShouldThrowBusinessExceptionForInvalidFile() {
+ JaudiotaggerAudioDurationServiceImpl service = new JaudiotaggerAudioDurationServiceImpl();
+ File invalid = new File("/tmp/not-a-real-audio-file.bin");
+
+ assertThatThrownBy(() -> service.getDurationSeconds(invalid))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Failed to read audio duration");
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/MetadataAcceptanceTest.java b/src/test/java/com/music/metadata/service/impl/MetadataAcceptanceTest.java
new file mode 100644
index 0000000..97ef53a
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/MetadataAcceptanceTest.java
@@ -0,0 +1,168 @@
+package com.music.metadata.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+import com.music.metadata.domain.metadata.ValidationFailureType;
+import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
+import com.music.metadata.infrastructure.audio.AudioTagExtractor;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.service.MetadataSnapshotService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.nio.charset.Charset;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MetadataAcceptanceTest {
+
+ private final MetadataValidatorServiceImpl validator = new MetadataValidatorServiceImpl();
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldReadMetadataFromTestAudioFile() throws Exception {
+ AudioTagExtractor extractor = mock(AudioTagExtractor.class);
+ MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
+ when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
+
+ AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
+ extractionResult.setFileFormat("mp3");
+ extractionResult.setTitle("Acceptance Song");
+ extractionResult.setArtist("Acceptance Artist");
+ extractionResult.setAlbumArtist("Acceptance Artist");
+ extractionResult.setAlbum("Acceptance Album");
+ extractionResult.setTrack("01/10");
+ extractionResult.setCoverMimeType("image/png");
+ extractionResult.setCoverData(createSquarePng(320));
+ extractionResult.setFields(Map.of(
+ "title", List.of("Acceptance Song"),
+ "artist", List.of("Acceptance Artist")
+ ));
+
+ File file = tempDir.resolve("acceptance.mp3").toFile();
+ assertThat(file.createNewFile()).isTrue();
+ when(extractor.extract(file)).thenReturn(extractionResult);
+
+ MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
+
+ AudioMetadata metadata = reader.readMetadata(file);
+
+ assertThat(metadata.getTitle()).isEqualTo("Acceptance Song");
+ assertThat(metadata.getArtist()).isEqualTo("Acceptance Artist");
+ assertThat(metadata.getAlbumArtist()).isEqualTo("Acceptance Artist");
+ assertThat(metadata.getAlbum()).isEqualTo("Acceptance Album");
+ assertThat(metadata.getTrack()).isEqualTo("01/10");
+ assertThat(metadata.getCover()).isNotNull();
+ assertThat(metadata.getCover().isPresent()).isTrue();
+ assertThat(metadata.getSnapshotJson()).contains("Acceptance Song");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(MetadataSnapshotEntity.class);
+ verify(snapshotService).create(captor.capture());
+ assertThat(captor.getValue().getSnapshotJson()).contains("Acceptance Song");
+ }
+
+ @Test
+ void missingTitleShouldReturnMissingFieldFailure() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setTitle(null);
+
+ MetadataValidationResult result = validator.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures()).hasSize(1);
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.MISSING_FIELD);
+ assertThat(result.getFailures().get(0).getField()).isEqualTo("title");
+ }
+
+ @Test
+ void gbkEncodedChineseLabelsShouldRepairOrFailEncodingError() throws Exception {
+ AudioTagExtractor extractor = mock(AudioTagExtractor.class);
+ MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
+ when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
+
+ AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
+ extractionResult.setFileFormat("flac");
+ extractionResult.setTitle(mojibakeFromGbk("中文标题"));
+ extractionResult.setArtist(mojibakeFromGbk("中文歌手"));
+ extractionResult.setAlbumArtist(mojibakeFromGbk("中文歌手"));
+ extractionResult.setAlbum(mojibakeFromGbk("中文专辑"));
+ extractionResult.setTrack("01/01");
+ extractionResult.setCoverMimeType("image/png");
+ extractionResult.setCoverData(createSquarePng(400));
+
+ File file = tempDir.resolve("gbk.flac").toFile();
+ assertThat(file.createNewFile()).isTrue();
+ when(extractor.extract(file)).thenReturn(extractionResult);
+
+ MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
+ AudioMetadata metadata = reader.readMetadata(file);
+
+ if ("UTF-8".equals(metadata.getOriginalEncoding())) {
+ assertThat(metadata.getTitle()).isEqualTo("中文标题");
+ assertThat(metadata.getArtist()).isEqualTo("中文歌手");
+ assertThat(metadata.getAlbum()).isEqualTo("中文专辑");
+ assertThat(validator.validate(metadata).isPassed()).isTrue();
+ } else {
+ MetadataValidationResult result = validator.validate(metadata);
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.ENCODING_ERROR);
+ }
+ }
+
+ @Test
+ void fileWithoutCoverShouldReturnCoverInvalidFailure() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setCover(null);
+
+ MetadataValidationResult result = validator.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
+ }
+
+ private AudioMetadata validMetadata() {
+ AudioMetadata metadata = new AudioMetadata();
+ metadata.setOriginalEncoding("UTF-8");
+ metadata.setFileFormat("FLAC");
+ metadata.setTitle("Song");
+ metadata.setArtist("Artist");
+ metadata.setAlbumArtist("Artist");
+ metadata.setAlbum("Album");
+ metadata.setTrack("01/01");
+
+ com.music.metadata.domain.metadata.CoverMetadata cover = new com.music.metadata.domain.metadata.CoverMetadata();
+ cover.setPresent(true);
+ cover.setFormat("PNG");
+ cover.setWidth(400);
+ cover.setHeight(400);
+ cover.setBinaryData(new byte[]{1, 2, 3});
+ metadata.setCover(cover);
+ return metadata;
+ }
+
+ private String mojibakeFromGbk(String value) {
+ return new String(value.getBytes(Charset.forName("GBK")), Charset.forName("ISO-8859-1"));
+ }
+
+ private byte[] createSquarePng(int side) throws Exception {
+ BufferedImage image = new BufferedImage(side, side, BufferedImage.TYPE_INT_ARGB);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", outputStream);
+ return outputStream.toByteArray();
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/MetadataReaderServiceImplTest.java b/src/test/java/com/music/metadata/service/impl/MetadataReaderServiceImplTest.java
new file mode 100644
index 0000000..600c1ba
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/MetadataReaderServiceImplTest.java
@@ -0,0 +1,108 @@
+package com.music.metadata.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
+import com.music.metadata.infrastructure.audio.AudioTagExtractor;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.service.MetadataSnapshotService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MetadataReaderServiceImplTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void readMetadata_shouldBuildSnapshotPersistAndParseCover() throws Exception {
+ AudioTagExtractor extractor = mock(AudioTagExtractor.class);
+ MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
+ when(snapshotService.create(org.mockito.ArgumentMatchers.any(MetadataSnapshotEntity.class))).thenReturn(true);
+
+ AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
+ extractionResult.setFileFormat("flac");
+ extractionResult.setTitle("song title");
+ extractionResult.setArtist("artist one & artist two");
+ extractionResult.setAlbumArtist("album artist");
+ extractionResult.setAlbum("album name");
+ extractionResult.setTrack("1/12");
+ extractionResult.setComment("demo");
+ extractionResult.setCoverMimeType("image/png");
+ extractionResult.setCoverData(createSquarePng(320));
+ extractionResult.setFields(Map.of("title", List.of("song title"), "artist", List.of("artist one & artist two")));
+
+ File file = tempDir.resolve("sample.flac").toFile();
+ assertThat(file.createNewFile()).isTrue();
+ when(extractor.extract(file)).thenReturn(extractionResult);
+
+ MetadataReaderServiceImpl service = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
+
+ AudioMetadata metadata = service.readMetadata(file);
+
+ assertThat(metadata.getFileFormat()).isEqualTo("FLAC");
+ assertThat(metadata.getOriginalEncoding()).isEqualTo("UTF-8");
+ assertThat(metadata.getCover()).isNotNull();
+ assertThat(metadata.getCover().isPresent()).isTrue();
+ assertThat(metadata.getCover().getFormat()).isEqualTo("PNG");
+ assertThat(metadata.getCover().getWidth()).isEqualTo(320);
+ assertThat(metadata.getCover().getHeight()).isEqualTo(320);
+ assertThat(metadata.getSnapshotJson()).contains("\"fileFormat\":\"FLAC\"");
+ assertThat(metadata.getSnapshotJson()).contains("\"title\":\"song title\"");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(MetadataSnapshotEntity.class);
+ verify(snapshotService).create(captor.capture());
+ assertThat(captor.getValue().getFileFormat()).isEqualTo("FLAC");
+ assertThat(captor.getValue().getOriginalEncoding()).isEqualTo("UTF-8");
+ assertThat(captor.getValue().getSnapshotJson()).isEqualTo(metadata.getSnapshotJson());
+ }
+
+ @Test
+ void readMetadata_shouldFlagUnknownEncodingForGarbledText() throws Exception {
+ AudioTagExtractor extractor = mock(AudioTagExtractor.class);
+ MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
+ when(snapshotService.create(org.mockito.ArgumentMatchers.any(MetadataSnapshotEntity.class))).thenReturn(true);
+
+ AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
+ extractionResult.setFileFormat("mp3");
+ extractionResult.setTitle("Bad � Title");
+ extractionResult.setArtist("Artist");
+ extractionResult.setAlbumArtist("Artist");
+ extractionResult.setAlbum("Album");
+ extractionResult.setTrack("01/01");
+
+ File file = tempDir.resolve("broken.mp3").toFile();
+ assertThat(file.createNewFile()).isTrue();
+ when(extractor.extract(file)).thenReturn(extractionResult);
+
+ MetadataReaderServiceImpl service = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
+
+ AudioMetadata metadata = service.readMetadata(file);
+
+ assertThat(metadata.getOriginalEncoding()).isIn("UNKNOWN", "UTF-8");
+ if ("UNKNOWN".equals(metadata.getOriginalEncoding())) {
+ assertThat(metadata.getSnapshotJson()).contains("\"originalEncoding\":\"UNKNOWN\"");
+ }
+ }
+
+ private byte[] createSquarePng(int side) throws Exception {
+ BufferedImage image = new BufferedImage(side, side, BufferedImage.TYPE_INT_ARGB);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", outputStream);
+ return outputStream.toByteArray();
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/MetadataValidatorServiceImplTest.java b/src/test/java/com/music/metadata/service/impl/MetadataValidatorServiceImplTest.java
new file mode 100644
index 0000000..b071124
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/MetadataValidatorServiceImplTest.java
@@ -0,0 +1,131 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.domain.metadata.CoverMetadata;
+import com.music.metadata.domain.metadata.MetadataValidationResult;
+import com.music.metadata.domain.metadata.ValidationFailureType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MetadataValidatorServiceImplTest {
+
+ private final MetadataValidatorServiceImpl service = new MetadataValidatorServiceImpl();
+
+ @Test
+ void validate_shouldPassAndCleanMetadata() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setTitle(" my song (Live) ");
+ metadata.setArtist("artist one & artist two");
+ metadata.setAlbumArtist("the album artist");
+ metadata.setAlbum(" best hits: deluxe ");
+ metadata.setTrack("1/12");
+ metadata.setComment("existing");
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isTrue();
+ assertThat(result.getFailures()).isEmpty();
+ assertThat(result.getCleanedMetadata().getTitle()).isEqualTo("My Song");
+ assertThat(result.getCleanedMetadata().getArtist()).isEqualTo("Artist One; Artist Two");
+ assertThat(result.getCleanedMetadata().getAlbumArtist()).isEqualTo("The Album Artist");
+ assertThat(result.getCleanedMetadata().getAlbum()).isEqualTo("Best Hits_ Deluxe");
+ assertThat(result.getCleanedMetadata().getTrack()).isEqualTo("01/12");
+ assertThat(result.getCleanedMetadata().getComment()).contains("Existing").contains("(live)");
+ }
+
+ @Test
+ void validate_shouldFailFastOnEncodingError() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setOriginalEncoding("GB18030");
+ metadata.setTitle(" ");
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures()).hasSize(1);
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.ENCODING_ERROR);
+ assertThat(result.getFailures().get(0).getField()).isEqualTo("encoding");
+ }
+
+ @Test
+ void validate_shouldFailOnFirstMissingRequiredField() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setTitle(" ");
+ metadata.setArtist("Artist �");
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures()).hasSize(1);
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.MISSING_FIELD);
+ assertThat(result.getFailures().get(0).getField()).isEqualTo("title");
+ }
+
+ @Test
+ void validate_shouldFailOnInvalidTrackFormat() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setTrack("1");
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.INVALID_FORMAT);
+ assertThat(result.getFailures().get(0).getField()).isEqualTo("track");
+ }
+
+ @Test
+ void validate_shouldFailWhenCoverIsTooSmall() {
+ AudioMetadata metadata = validMetadata();
+ metadata.getCover().setWidth(299);
+ metadata.getCover().setHeight(299);
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
+ }
+
+ @Test
+ void validate_shouldFailWhenCoverIsNotSquare() {
+ AudioMetadata metadata = validMetadata();
+ metadata.getCover().setWidth(500);
+ metadata.getCover().setHeight(300);
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
+ }
+
+ @Test
+ void validate_shouldFailWhenCoverIsMissing() {
+ AudioMetadata metadata = validMetadata();
+ metadata.setCover(null);
+
+ MetadataValidationResult result = service.validate(metadata);
+
+ assertThat(result.isPassed()).isFalse();
+ assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
+ }
+
+ private AudioMetadata validMetadata() {
+ AudioMetadata metadata = new AudioMetadata();
+ metadata.setOriginalEncoding("UTF-8");
+ metadata.setFileFormat("FLAC");
+ metadata.setTitle("Song");
+ metadata.setArtist("Artist");
+ metadata.setAlbumArtist("Artist");
+ metadata.setAlbum("Album");
+ metadata.setTrack("01/01");
+
+ CoverMetadata cover = new CoverMetadata();
+ cover.setPresent(true);
+ cover.setFormat("JPG");
+ cover.setWidth(600);
+ cover.setHeight(600);
+ cover.setBinaryData(new byte[]{1, 2, 3});
+ metadata.setCover(cover);
+ return metadata;
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/RealAudioMetadataIntegrationTest.java b/src/test/java/com/music/metadata/service/impl/RealAudioMetadataIntegrationTest.java
new file mode 100644
index 0000000..07c9140
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/RealAudioMetadataIntegrationTest.java
@@ -0,0 +1,84 @@
+package com.music.metadata.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.music.metadata.domain.metadata.AudioMetadata;
+import com.music.metadata.infrastructure.audio.JaudiotaggerAudioTagExtractor;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.service.MetadataSnapshotService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class RealAudioMetadataIntegrationTest {
+
+ private static final Path DEFAULT_REAL_AUDIO_DIR = Path.of("/home/liujingjing/下载/解压后2");
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldReadMetadataFromRealAudioFile() throws Exception {
+ Path realAudioDir = resolveRealAudioDir();
+ assumeTrue(Files.isDirectory(realAudioDir), () -> "Real audio directory not found: " + realAudioDir);
+
+ Path sourceFile = findFirstFlac(realAudioDir);
+ assumeTrue(sourceFile != null, () -> "No FLAC file found under " + realAudioDir);
+
+ Path testFile = tempDir.resolve(sourceFile.getFileName().toString());
+ Files.copy(sourceFile, testFile);
+
+ MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
+ when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
+
+ MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(
+ new JaudiotaggerAudioTagExtractor(),
+ snapshotService,
+ new ObjectMapper()
+ );
+
+ AudioMetadata metadata = reader.readMetadata(testFile.toFile());
+
+ assertThat(metadata.getFileFormat()).isEqualTo("FLAC");
+ assertThat(metadata.getTitle()).isNotBlank();
+ assertThat(metadata.getArtist()).isNotBlank();
+ assertThat(metadata.getAlbum()).isNotBlank();
+ assertThat(metadata.getSnapshotJson()).contains(metadata.getTitle());
+ assertThat(metadata.getTagFields()).isNotEmpty();
+ assertThat(metadata.getCover()).isNotNull();
+ assertThat(metadata.getCover().isPresent()).isTrue();
+ assertThat(metadata.getCover().getBinaryData()).isNotNull().isNotEmpty();
+ assertThat(metadata.getCover().getWidth()).isGreaterThanOrEqualTo(300);
+ assertThat(metadata.getCover().getHeight()).isGreaterThanOrEqualTo(300);
+ }
+
+ private Path resolveRealAudioDir() {
+ String override = System.getenv("REAL_AUDIO_DIR");
+ if (override != null && !override.isBlank()) {
+ return Path.of(override);
+ }
+ return DEFAULT_REAL_AUDIO_DIR;
+ }
+
+ private Path findFirstFlac(Path root) throws IOException {
+ try (Stream stream = Files.walk(root)) {
+ return stream
+ .filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().toLowerCase().endsWith(".flac"))
+ .sorted(Comparator.comparing(Path::toString))
+ .findFirst()
+ .orElse(null);
+ }
+ }
+}
diff --git a/src/test/java/com/music/metadata/service/impl/SimpleServiceImplCoverageTest.java b/src/test/java/com/music/metadata/service/impl/SimpleServiceImplCoverageTest.java
new file mode 100644
index 0000000..2479733
--- /dev/null
+++ b/src/test/java/com/music/metadata/service/impl/SimpleServiceImplCoverageTest.java
@@ -0,0 +1,95 @@
+package com.music.metadata.service.impl;
+
+import com.music.metadata.infrastructure.entity.FailFileEntity;
+import com.music.metadata.infrastructure.entity.FileProcessEntity;
+import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
+import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
+import com.music.metadata.infrastructure.entity.SystemConfigEntity;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+
+class SimpleServiceImplCoverageTest {
+
+ @Test
+ void fileProcessServiceImplShouldDelegateCrudMethods() {
+ FileProcessServiceImpl service = spy(new FileProcessServiceImpl());
+ FileProcessEntity entity = new FileProcessEntity();
+ doReturn(true).when(service).save(entity);
+ doReturn(true).when(service).updateById(entity);
+ doReturn(true).when(service).removeById(1L);
+ doReturn(entity).when(service).getById(1L);
+ doReturn(List.of(entity)).when(service).list();
+
+ assertThat(service.create(entity)).isTrue();
+ assertThat(service.update(entity)).isTrue();
+ assertThat(service.deleteById(1L)).isTrue();
+ assertThat(service.findById(1L)).isSameAs(entity);
+ assertThat(service.findAll()).containsExactly(entity);
+ }
+
+ @Test
+ void failFileServiceImplShouldDelegateCrudMethods() {
+ FailFileServiceImpl service = spy(new FailFileServiceImpl());
+ FailFileEntity entity = new FailFileEntity();
+ doReturn(true).when(service).save(entity);
+ doReturn(true).when(service).updateById(entity);
+ doReturn(true).when(service).removeById(1L);
+ doReturn(entity).when(service).getById(1L);
+ doReturn(List.of(entity)).when(service).list();
+
+ assertThat(service.create(entity)).isTrue();
+ assertThat(service.update(entity)).isTrue();
+ assertThat(service.deleteById(1L)).isTrue();
+ assertThat(service.findById(1L)).isSameAs(entity);
+ assertThat(service.findAll()).containsExactly(entity);
+ }
+
+ @Test
+ void processTaskServiceImplShouldDelegateCrudMethods() {
+ ProcessTaskServiceImpl service = spy(new ProcessTaskServiceImpl());
+ ProcessTaskEntity entity = new ProcessTaskEntity();
+ doReturn(true).when(service).save(entity);
+ doReturn(true).when(service).updateById(entity);
+ doReturn(true).when(service).removeById(1L);
+ doReturn(entity).when(service).getById(1L);
+ doReturn(List.of(entity)).when(service).list();
+
+ assertThat(service.create(entity)).isTrue();
+ assertThat(service.update(entity)).isTrue();
+ assertThat(service.deleteById(1L)).isTrue();
+ assertThat(service.findById(1L)).isSameAs(entity);
+ assertThat(service.findAll()).containsExactly(entity);
+ }
+
+ @Test
+ void systemConfigServiceImplShouldDelegateCrudMethods() {
+ SystemConfigServiceImpl service = spy(new SystemConfigServiceImpl());
+ SystemConfigEntity entity = new SystemConfigEntity();
+ doReturn(true).when(service).save(entity);
+ doReturn(true).when(service).updateById(entity);
+ doReturn(true).when(service).removeById(1L);
+ doReturn(entity).when(service).getById(1L);
+ doReturn(List.of(entity)).when(service).list();
+
+ assertThat(service.create(entity)).isTrue();
+ assertThat(service.update(entity)).isTrue();
+ assertThat(service.deleteById(1L)).isTrue();
+ assertThat(service.findById(1L)).isSameAs(entity);
+ assertThat(service.findAll()).containsExactly(entity);
+ }
+
+ @Test
+ void metadataSnapshotServiceImplShouldDelegateCreate() {
+ MetadataSnapshotServiceImpl service = spy(new MetadataSnapshotServiceImpl());
+ MetadataSnapshotEntity entity = new MetadataSnapshotEntity();
+ entity.setCreatedAt(LocalDateTime.now());
+ doReturn(true).when(service).save(entity);
+ assertThat(service.create(entity)).isTrue();
+ }
+}
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 0000000..274a73a
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,18 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:music_metadata_test;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+ sql:
+ init:
+ mode: always
+ schema-locations: classpath:schema.sql
+
+mybatis-plus:
+ configuration:
+ map-underscore-to-camel-case: true
+
+logging:
+ level:
+ root: WARN
diff --git a/src/test/resources/audio-fixtures/README.md b/src/test/resources/audio-fixtures/README.md
new file mode 100644
index 0000000..0ec9e23
--- /dev/null
+++ b/src/test/resources/audio-fixtures/README.md
@@ -0,0 +1,31 @@
+Test audio fixture guidance
+
+The current unit tests avoid committing real audio binaries and instead use `byte[]` plus a mocked `AudioTagExtractor`.
+
+Recommended real-file fixtures if you later want integration tests:
+
+1. `valid-square-cover.flac`
+ - Tags: `title`, `artist`, `album_artist`, `album`, `track=01/12`
+ - Embedded PNG cover `320x320`
+ - UTF-8 encoded text
+
+2. `missing-cover.mp3`
+ - Same core tags as above
+ - No embedded artwork
+ - Expected validator result: `COVER_MISSING`
+
+3. `bad-track.m4a`
+ - Track stored as `1` instead of `01/12`
+ - Expected validator result: `INVALID_FORMAT`
+
+4. `garbled-title.ogg`
+ - Title intentionally stored with broken encoding bytes so it decodes to `Bad � Title`
+ - Expected reader encoding result: `UNKNOWN`
+
+To generate those fixtures quickly, ffmpeg plus a tagging tool is enough:
+
+```bash
+ffmpeg -f lavfi -i sine=frequency=440:duration=1 -c:a flac valid.flac
+```
+
+Then embed tags and artwork with a dedicated tag editor, or continue mocking `AudioTagExtractor` in unit tests for faster coverage.