feat: add metadata validation and scan acceptance support
This commit is contained in:
32
pom.xml
32
pom.xml
@@ -47,6 +47,18 @@
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.jthink</groupId>
|
||||
<artifactId>jaudiotagger</artifactId>
|
||||
<version>2.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.albfernandez</groupId>
|
||||
<artifactId>juniversalchardet</artifactId>
|
||||
<version>2.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -61,6 +73,26 @@
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
|
||||
@@ -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<String, List<String>> 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<String, List<String>> getTagFields() {
|
||||
return tagFields;
|
||||
}
|
||||
|
||||
public void setTagFields(Map<String, List<String>> tagFields) {
|
||||
this.tagFields = tagFields;
|
||||
}
|
||||
|
||||
public void putTagField(String key, String value) {
|
||||
tagFields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ValidationFailure> failures;
|
||||
private final AudioMetadata cleanedMetadata;
|
||||
|
||||
private MetadataValidationResult(boolean passed, List<ValidationFailure> 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<ValidationFailure> failures = new ArrayList<>();
|
||||
failures.add(failure);
|
||||
return new MetadataValidationResult(false, failures, cleanedMetadata);
|
||||
}
|
||||
|
||||
public boolean isPassed() {
|
||||
return passed;
|
||||
}
|
||||
|
||||
public List<ValidationFailure> getFailures() {
|
||||
return failures;
|
||||
}
|
||||
|
||||
public AudioMetadata getCleanedMetadata() {
|
||||
return cleanedMetadata;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum DuplicateType {
|
||||
HASH_DUPLICATE,
|
||||
LEVEL2_SUSPECT
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum ScanItemStatus {
|
||||
DISCOVERED,
|
||||
PROCESSING,
|
||||
DONE,
|
||||
SKIPPED,
|
||||
ERROR
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum ScanProcessingOutcome {
|
||||
SUCCESS,
|
||||
HASH_DUPLICATE,
|
||||
LEVEL2_SUSPECT,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -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<String, List<String>> 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<String, List<String>> getFields() {
|
||||
return fields;
|
||||
}
|
||||
|
||||
public void setFields(Map<String, List<String>> fields) {
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
public void addField(String key, String value) {
|
||||
fields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String> 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<TagField> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<DuplicateFileEntity> {
|
||||
}
|
||||
@@ -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<MetadataSnapshotEntity> {
|
||||
}
|
||||
@@ -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<ScanItemEntity> {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public interface AudioDurationService {
|
||||
|
||||
Integer getDurationSeconds(File file);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
public interface DirectoryScanService {
|
||||
|
||||
void startOrResume(Long taskId, String sourcePath);
|
||||
}
|
||||
@@ -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<DuplicateFileEntity> {
|
||||
|
||||
boolean create(DuplicateFileEntity entity);
|
||||
|
||||
List<DuplicateFileEntity> findAll();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public interface FileHashService {
|
||||
|
||||
String sha256(File file);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<MetadataSnapshotEntity> {
|
||||
|
||||
boolean create(MetadataSnapshotEntity entity);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ScanItemEntity> {
|
||||
|
||||
boolean create(ScanItemEntity entity);
|
||||
|
||||
boolean update(ScanItemEntity entity);
|
||||
|
||||
List<ScanItemEntity> findAll();
|
||||
}
|
||||
@@ -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<FileProcessEntity>()
|
||||
.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<FileProcessEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<ScanItemEntity> pendingItems = scanItemService.list(new LambdaQueryWrapper<ScanItemEntity>()
|
||||
.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<Path> 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<ScanItemEntity>()
|
||||
.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<ScanItemEntity>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -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<DuplicateFileMapper, DuplicateFileEntity>
|
||||
implements DuplicateFileService {
|
||||
|
||||
@Override
|
||||
public boolean create(DuplicateFileEntity entity) {
|
||||
return this.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DuplicateFileEntity> findAll() {
|
||||
return this.list();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<String>> copyFields(Map<String, List<String>> sourceFields) {
|
||||
Map<String, List<String>> 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<String> 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<String, List<String>> repairTagFields(Map<String, List<String>> tagFields, String encoding) {
|
||||
Map<String, List<String>> repaired = new LinkedHashMap<>();
|
||||
tagFields.forEach((key, values) -> {
|
||||
List<String> 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<String> 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("<EFBFBD>")) {
|
||||
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("<EFBFBD>") || 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 == '<27>') {
|
||||
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("<EFBFBD>") || 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<String, Object> 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<String, Object> 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<String, Object> buildCoverSnapshot(CoverMetadata coverMetadata) {
|
||||
if (coverMetadata == null) {
|
||||
return Map.of("present", false);
|
||||
}
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<MetadataSnapshotMapper, MetadataSnapshotEntity>
|
||||
implements MetadataSnapshotService {
|
||||
|
||||
@Override
|
||||
public boolean create(MetadataSnapshotEntity entity) {
|
||||
return this.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String, List<String>> normalized = new LinkedHashMap<>();
|
||||
metadata.getTagFields().forEach((key, values) -> {
|
||||
java.util.ArrayList<String> 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("<EFBFBD>") || 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;
|
||||
}
|
||||
}
|
||||
@@ -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<FileProcessEntity>()
|
||||
.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<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ScanItemMapper, ScanItemEntity> 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<ScanItemEntity> findAll() {
|
||||
return this.list();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> success = ApiResponse.success("ok");
|
||||
assertThat(success.getCode()).isEqualTo(200);
|
||||
assertThat(success.getMessage()).isEqualTo("操作成功");
|
||||
assertThat(success.getData()).isEqualTo("ok");
|
||||
assertThat(success.getTimestamp()).isNotNull();
|
||||
|
||||
ApiResponse<String> 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<Void> 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<Void> business = handler.handleBusinessException(customException);
|
||||
assertThat(business.getCode()).isEqualTo(422);
|
||||
assertThat(business.getMessage()).isEqualTo("custom");
|
||||
|
||||
ApiResponse<Void> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<DuplicateFileEntity> duplicates = duplicateFileService.list(new LambdaQueryWrapper<DuplicateFileEntity>()
|
||||
.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<DuplicateFileEntity> duplicates = duplicateFileService.list(new LambdaQueryWrapper<DuplicateFileEntity>()
|
||||
.eq(DuplicateFileEntity::getTaskId, taskId));
|
||||
assertThat(duplicates).hasSize(1);
|
||||
assertThat(duplicates.get(0).getDuplicateType()).isEqualTo("LEVEL2_SUSPECT");
|
||||
|
||||
List<FileProcessEntity> 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<ScanItemEntity>()
|
||||
.eq(ScanItemEntity::getTaskId, taskId)
|
||||
.eq(ScanItemEntity::getStatus, ScanItemStatus.ERROR.name()));
|
||||
long doneCountAfterFirstRun = scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
|
||||
.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<ScanItemEntity>()
|
||||
.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<ScanItemEntity>()
|
||||
.eq(ScanItemEntity::getTaskId, secondTaskId)
|
||||
.last("LIMIT 1"));
|
||||
assertThat(scanItem.getStatus()).isEqualTo(ScanItemStatus.SKIPPED.name());
|
||||
|
||||
DuplicateFileEntity duplicate = duplicateFileService.getOne(new LambdaQueryWrapper<DuplicateFileEntity>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<MetadataSnapshotEntity> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<MetadataSnapshotEntity> 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 <20> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <20>");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
18
src/test/resources/application-test.yml
Normal file
18
src/test/resources/application-test.yml
Normal file
@@ -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
|
||||
31
src/test/resources/audio-fixtures/README.md
Normal file
31
src/test/resources/audio-fixtures/README.md
Normal file
@@ -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 <20> 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.
|
||||
Reference in New Issue
Block a user