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