feat: add metadata validation and scan acceptance support

This commit is contained in:
2026-03-16 18:01:36 +08:00
parent aa82db6b15
commit 5b0de4f99d
51 changed files with 3493 additions and 0 deletions

32
pom.xml
View File

@@ -47,6 +47,18 @@
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<dependency>
<groupId>net.jthink</groupId>
<artifactId>jaudiotagger</artifactId>
<version>2.2.5</version>
</dependency>
<dependency>
<groupId>com.github.albfernandez</groupId>
<artifactId>juniversalchardet</artifactId>
<version>2.4.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@@ -61,6 +73,26 @@
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>org.graalvm.buildtools</groupId> <groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId> <artifactId>native-maven-plugin</artifactId>

View File

@@ -0,0 +1,152 @@
package com.music.metadata.domain.metadata;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Normalized in-memory representation of metadata read from an audio file.
*/
public class AudioMetadata {
private String fileFormat;
private String originalEncoding;
private String snapshotJson;
private String title;
private String artist;
private String albumArtist;
private String album;
private String track;
private String year;
private String genre;
private String lyrics;
private String disc;
private String comment;
private CoverMetadata cover;
private Map<String, List<String>> tagFields = new LinkedHashMap<>();
public String getFileFormat() {
return fileFormat;
}
public void setFileFormat(String fileFormat) {
this.fileFormat = fileFormat;
}
public String getOriginalEncoding() {
return originalEncoding;
}
public void setOriginalEncoding(String originalEncoding) {
this.originalEncoding = originalEncoding;
}
public String getSnapshotJson() {
return snapshotJson;
}
public void setSnapshotJson(String snapshotJson) {
this.snapshotJson = snapshotJson;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public String getAlbumArtist() {
return albumArtist;
}
public void setAlbumArtist(String albumArtist) {
this.albumArtist = albumArtist;
}
public String getAlbum() {
return album;
}
public void setAlbum(String album) {
this.album = album;
}
public String getTrack() {
return track;
}
public void setTrack(String track) {
this.track = track;
}
public String getYear() {
return year;
}
public void setYear(String year) {
this.year = year;
}
public String getGenre() {
return genre;
}
public void setGenre(String genre) {
this.genre = genre;
}
public String getLyrics() {
return lyrics;
}
public void setLyrics(String lyrics) {
this.lyrics = lyrics;
}
public String getDisc() {
return disc;
}
public void setDisc(String disc) {
this.disc = disc;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public CoverMetadata getCover() {
return cover;
}
public void setCover(CoverMetadata cover) {
this.cover = cover;
}
public Map<String, List<String>> getTagFields() {
return tagFields;
}
public void setTagFields(Map<String, List<String>> tagFields) {
this.tagFields = tagFields;
}
public void putTagField(String key, String value) {
tagFields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,43 @@
package com.music.metadata.domain.metadata;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Result returned by the one-vote veto validator.
*/
public class MetadataValidationResult {
private final boolean passed;
private final List<ValidationFailure> failures;
private final AudioMetadata cleanedMetadata;
private MetadataValidationResult(boolean passed, List<ValidationFailure> failures, AudioMetadata cleanedMetadata) {
this.passed = passed;
this.failures = failures;
this.cleanedMetadata = cleanedMetadata;
}
public static MetadataValidationResult passed(AudioMetadata cleanedMetadata) {
return new MetadataValidationResult(true, Collections.emptyList(), cleanedMetadata);
}
public static MetadataValidationResult failed(ValidationFailure failure, AudioMetadata cleanedMetadata) {
List<ValidationFailure> failures = new ArrayList<>();
failures.add(failure);
return new MetadataValidationResult(false, failures, cleanedMetadata);
}
public boolean isPassed() {
return passed;
}
public List<ValidationFailure> getFailures() {
return failures;
}
public AudioMetadata getCleanedMetadata() {
return cleanedMetadata;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
package com.music.metadata.domain.scan;
public enum DuplicateType {
HASH_DUPLICATE,
LEVEL2_SUSPECT
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.domain.scan;
public enum ScanItemStatus {
DISCOVERED,
PROCESSING,
DONE,
SKIPPED,
ERROR
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.domain.scan;
public enum ScanProcessingOutcome {
SUCCESS,
HASH_DUPLICATE,
LEVEL2_SUSPECT,
FAILED,
SKIPPED
}

View File

@@ -0,0 +1,143 @@
package com.music.metadata.infrastructure.audio;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Raw extraction result from the metadata library before business validation.
*/
public class AudioTagExtractionResult {
private String fileFormat;
private String title;
private String artist;
private String albumArtist;
private String album;
private String track;
private String year;
private String genre;
private String lyrics;
private String disc;
private String comment;
private byte[] coverData;
private String coverMimeType;
private Map<String, List<String>> fields = new LinkedHashMap<>();
public String getFileFormat() {
return fileFormat;
}
public void setFileFormat(String fileFormat) {
this.fileFormat = fileFormat;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public String getAlbumArtist() {
return albumArtist;
}
public void setAlbumArtist(String albumArtist) {
this.albumArtist = albumArtist;
}
public String getAlbum() {
return album;
}
public void setAlbum(String album) {
this.album = album;
}
public String getTrack() {
return track;
}
public void setTrack(String track) {
this.track = track;
}
public String getYear() {
return year;
}
public void setYear(String year) {
this.year = year;
}
public String getGenre() {
return genre;
}
public void setGenre(String genre) {
this.genre = genre;
}
public String getLyrics() {
return lyrics;
}
public void setLyrics(String lyrics) {
this.lyrics = lyrics;
}
public String getDisc() {
return disc;
}
public void setDisc(String disc) {
this.disc = disc;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public byte[] getCoverData() {
return coverData;
}
public void setCoverData(byte[] coverData) {
this.coverData = coverData;
}
public String getCoverMimeType() {
return coverMimeType;
}
public void setCoverMimeType(String coverMimeType) {
this.coverMimeType = coverMimeType;
}
public Map<String, List<String>> getFields() {
return fields;
}
public void setFields(Map<String, List<String>> fields) {
this.fields = fields;
}
public void addField(String key, String value) {
fields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,131 @@
package com.music.metadata.infrastructure.audio;
import com.music.metadata.common.exception.BusinessException;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.jaudiotagger.tag.TagField;
import org.jaudiotagger.tag.images.Artwork;
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.Iterator;
import java.util.List;
/**
* Production extractor backed by jaudiotagger.
*/
@Component
public class JaudiotaggerAudioTagExtractor implements AudioTagExtractor {
@Override
public AudioTagExtractionResult extract(File file) {
try {
AudioFile audioFile = AudioFileIO.read(file);
Tag tag = audioFile.getTag();
AudioTagExtractionResult result = new AudioTagExtractionResult();
result.setFileFormat(audioFile.getExt());
if (tag == null) {
return result;
}
result.setTitle(readFirst(tag, FieldKey.TITLE));
result.setArtist(readFirst(tag, FieldKey.ARTIST));
result.setAlbumArtist(readFirst(tag, FieldKey.ALBUM_ARTIST));
result.setAlbum(readFirst(tag, FieldKey.ALBUM));
result.setTrack(readTrack(tag));
result.setYear(readFirst(tag, FieldKey.YEAR));
result.setGenre(readFirst(tag, FieldKey.GENRE));
result.setLyrics(readFirst(tag, FieldKey.LYRICS));
result.setDisc(readDisc(tag));
result.setComment(readFirst(tag, FieldKey.COMMENT));
for (FieldKey fieldKey : FieldKey.values()) {
try {
List<String> values = tag.getAll(fieldKey);
if (values == null || values.isEmpty()) {
continue;
}
for (String value : values) {
if (value != null && !value.isEmpty()) {
result.addField(fieldKey.name().toLowerCase(), value);
}
}
} catch (Exception ignored) {
// Some libraries throw while materializing special fields such as artwork-backed values.
// Best-effort extraction is enough here because core fields are read explicitly above.
}
}
try {
Iterator<TagField> iterator = tag.getFields();
while (iterator.hasNext()) {
TagField field = iterator.next();
try {
String key = field.getId();
String value = field.toString();
if (key != null && !key.isBlank() && value != null && !value.isBlank()) {
result.addField(key, value);
}
} catch (Exception ignored) {
// Ignore malformed/special fields and keep extracting the rest.
}
}
} catch (UnsupportedOperationException ignored) {
// Some tag implementations do not expose a stable field iterator. FieldKey values remain available.
}
try {
Artwork artwork = tag.getFirstArtwork();
if (artwork != null) {
result.setCoverData(artwork.getBinaryData());
result.setCoverMimeType(artwork.getMimeType());
}
} catch (Exception ignored) {
// Some FLAC files expose artwork metadata but Jaudiotagger cannot materialize it.
// Keep the text tags readable and let downstream cover validation decide the result.
}
return result;
} catch (Exception exception) {
throw new BusinessException("Failed to read audio metadata: " + exception.getMessage());
}
}
private String readTrack(Tag tag) throws Exception {
String track = readFirst(tag, FieldKey.TRACK);
String trackTotal = readFirst(tag, FieldKey.TRACK_TOTAL);
if (track == null || track.isBlank()) {
return trackTotal;
}
if (track.contains("/")) {
return track;
}
if (trackTotal == null || trackTotal.isBlank()) {
return track;
}
return track + "/" + trackTotal;
}
private String readDisc(Tag tag) throws Exception {
String disc = readFirst(tag, FieldKey.DISC_NO);
String discTotal = readFirst(tag, FieldKey.DISC_TOTAL);
if (disc == null || disc.isBlank()) {
return discTotal;
}
if (disc.contains("/")) {
return disc;
}
if (discTotal == null || discTotal.isBlank()) {
return disc;
}
return disc + "/" + discTotal;
}
private String readFirst(Tag tag, FieldKey key) throws Exception {
String value = tag.getFirst(key);
return value == null || value.isBlank() ? null : value;
}
}

View File

@@ -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;
}
}

View File

@@ -44,6 +44,9 @@ public class FileProcessEntity implements Serializable {
@TableField("target_file_path") @TableField("target_file_path")
private String targetFilePath; private String targetFilePath;
@TableField("dedup_key")
private String dedupKey;
@TableField("task_id") @TableField("task_id")
private Long taskId; private Long taskId;
@@ -141,6 +144,14 @@ public class FileProcessEntity implements Serializable {
this.targetFilePath = targetFilePath; this.targetFilePath = targetFilePath;
} }
public String getDedupKey() {
return dedupKey;
}
public void setDedupKey(String dedupKey) {
this.dedupKey = dedupKey;
}
public Long getTaskId() { public Long getTaskId() {
return taskId; return taskId;
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DuplicateFileMapper extends BaseMapper<DuplicateFileEntity> {
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MetadataSnapshotMapper extends BaseMapper<MetadataSnapshotEntity> {
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ScanItemMapper extends BaseMapper<ScanItemEntity> {
}

View File

@@ -0,0 +1,8 @@
package com.music.metadata.service;
import java.io.File;
public interface AudioDurationService {
Integer getDurationSeconds(File file);
}

View File

@@ -0,0 +1,14 @@
package com.music.metadata.service;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
import com.music.metadata.infrastructure.entity.FileProcessEntity;
public interface DeduplicationService {
FileProcessEntity findArchivedByHash(String fileHash);
FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration);
String buildDedupKey(AudioMetadata metadata, Integer audioDuration);
}

View File

@@ -0,0 +1,6 @@
package com.music.metadata.service;
public interface DirectoryScanService {
void startOrResume(Long taskId, String sourcePath);
}

View File

@@ -0,0 +1,13 @@
package com.music.metadata.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
import java.util.List;
public interface DuplicateFileService extends IService<DuplicateFileEntity> {
boolean create(DuplicateFileEntity entity);
List<DuplicateFileEntity> findAll();
}

View File

@@ -0,0 +1,8 @@
package com.music.metadata.service;
import java.io.File;
public interface FileHashService {
String sha256(File file);
}

View File

@@ -0,0 +1,10 @@
package com.music.metadata.service;
import com.music.metadata.domain.metadata.AudioMetadata;
import java.io.File;
public interface MetadataReaderService {
AudioMetadata readMetadata(File file);
}

View File

@@ -0,0 +1,9 @@
package com.music.metadata.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
public interface MetadataSnapshotService extends IService<MetadataSnapshotEntity> {
boolean create(MetadataSnapshotEntity entity);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,15 @@
package com.music.metadata.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import java.util.List;
public interface ScanItemService extends IService<ScanItemEntity> {
boolean create(ScanItemEntity entity);
boolean update(ScanItemEntity entity);
List<ScanItemEntity> findAll();
}

View File

@@ -0,0 +1,72 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
import com.music.metadata.infrastructure.entity.FileProcessEntity;
import com.music.metadata.service.DeduplicationService;
import com.music.metadata.service.FileHashService;
import com.music.metadata.service.FileProcessService;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
@Service
public class DeduplicationServiceImpl implements DeduplicationService {
private final FileProcessService fileProcessService;
public DeduplicationServiceImpl(FileProcessService fileProcessService) {
this.fileProcessService = fileProcessService;
}
@Override
public FileProcessEntity findArchivedByHash(String fileHash) {
return fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
.eq(FileProcessEntity::getFileHash, fileHash)
.eq(FileProcessEntity::getProcessStatus, "SUCCESS")
.last("LIMIT 1"));
}
@Override
public FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration) {
String dedupKey = buildDedupKey(metadata, audioDuration);
FileProcessEntity canonical = fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
.eq(FileProcessEntity::getDedupKey, dedupKey)
.eq(FileProcessEntity::getProcessStatus, "SUCCESS")
.last("LIMIT 1"));
if (canonical == null) {
return null;
}
return new FeatureDeduplicationHit(dedupKey, canonical);
}
@Override
public String buildDedupKey(AudioMetadata metadata, Integer audioDuration) {
String artist = normalize(metadata.getArtist());
String title = normalize(metadata.getTitle());
String album = normalize(metadata.getAlbum());
String duration = audioDuration == null ? "0" : String.valueOf(audioDuration);
return sha256(artist + "|" + title + "|" + album + "|" + duration);
}
private String normalize(String value) {
return value == null ? "" : value.strip().toLowerCase(Locale.ROOT);
}
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (Exception exception) {
throw new IllegalStateException("Failed to create deduplication key", exception);
}
}
}

View File

@@ -0,0 +1,139 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.music.metadata.common.exception.BusinessException;
import com.music.metadata.domain.scan.ScanItemStatus;
import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import com.music.metadata.service.DirectoryScanService;
import com.music.metadata.service.ProcessTaskService;
import com.music.metadata.service.ScanFileProcessor;
import com.music.metadata.service.ScanItemService;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
@Service
public class DirectoryScanServiceImpl implements DirectoryScanService {
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "flac", "m4a", "ogg", "wav");
private final ScanItemService scanItemService;
private final ProcessTaskService processTaskService;
private final ScanFileProcessor scanFileProcessor;
public DirectoryScanServiceImpl(ScanItemService scanItemService,
ProcessTaskService processTaskService,
ScanFileProcessor scanFileProcessor) {
this.scanItemService = scanItemService;
this.processTaskService = processTaskService;
this.scanFileProcessor = scanFileProcessor;
}
@Override
public void startOrResume(Long taskId, String sourcePath) {
ProcessTaskEntity existingTask = processTaskService.findById(taskId);
if (existingTask == null) {
throw new BusinessException("Process task not found: " + taskId);
}
discoverFiles(taskId, sourcePath);
ProcessTaskEntity runningTask = processTaskService.findById(taskId);
runningTask.setTaskStatus("RUNNING");
processTaskService.update(runningTask);
List<ScanItemEntity> pendingItems = scanItemService.list(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)
.in(ScanItemEntity::getStatus,
ScanItemStatus.DISCOVERED.name(),
ScanItemStatus.ERROR.name(),
ScanItemStatus.PROCESSING.name())
.orderByAsc(ScanItemEntity::getId));
boolean interrupted = false;
for (ScanItemEntity pendingItem : pendingItems) {
pendingItem.setStatus(ScanItemStatus.PROCESSING.name());
pendingItem.setUpdatedAt(LocalDateTime.now());
scanItemService.update(pendingItem);
try {
switch (scanFileProcessor.process(taskId, pendingItem)) {
case SUCCESS, LEVEL2_SUSPECT, FAILED -> pendingItem.setStatus(ScanItemStatus.DONE.name());
case HASH_DUPLICATE, SKIPPED -> pendingItem.setStatus(ScanItemStatus.SKIPPED.name());
default -> pendingItem.setStatus(ScanItemStatus.ERROR.name());
}
pendingItem.setErrorMessage(null);
} catch (Exception exception) {
pendingItem.setStatus(ScanItemStatus.ERROR.name());
pendingItem.setErrorMessage(exception.getMessage());
interrupted = true;
}
pendingItem.setUpdatedAt(LocalDateTime.now());
scanItemService.update(pendingItem);
if (interrupted) {
break;
}
}
ProcessTaskEntity finishedTask = processTaskService.findById(taskId);
finishedTask.setTaskStatus(interrupted ? "ERROR" : "FINISHED");
finishedTask.setEndTime(LocalDateTime.now());
processTaskService.update(finishedTask);
}
private void discoverFiles(Long taskId, String sourcePath) {
Path path = Paths.get(sourcePath);
if (!Files.exists(path)) {
throw new BusinessException("Source path does not exist: " + sourcePath);
}
try (Stream<Path> stream = Files.walk(path)) {
stream.filter(Files::isRegularFile)
.filter(this::isSupportedAudioFile)
.forEach(filePath -> ensureScanItemExists(taskId, filePath));
} catch (IOException exception) {
throw new BusinessException("Failed to scan source path: " + exception.getMessage());
}
ProcessTaskEntity task = processTaskService.findById(taskId);
task.setTotalFileCount((int) scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)));
processTaskService.update(task);
}
private void ensureScanItemExists(Long taskId, Path filePath) {
String absolutePath = filePath.toAbsolutePath().toString();
ScanItemEntity existing = scanItemService.getOne(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)
.eq(ScanItemEntity::getSourceFilePath, absolutePath)
.last("LIMIT 1"));
if (existing != null) {
return;
}
ScanItemEntity entity = new ScanItemEntity();
entity.setTaskId(taskId);
entity.setSourceFilePath(absolutePath);
entity.setStatus(ScanItemStatus.DISCOVERED.name());
entity.setUpdatedAt(LocalDateTime.now());
scanItemService.create(entity);
}
private boolean isSupportedAudioFile(Path path) {
String fileName = path.getFileName().toString();
int index = fileName.lastIndexOf('.');
if (index < 0 || index == fileName.length() - 1) {
return false;
}
return SUPPORTED_EXTENSIONS.contains(fileName.substring(index + 1).toLowerCase());
}
}

View File

@@ -0,0 +1,24 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
import com.music.metadata.infrastructure.mapper.DuplicateFileMapper;
import com.music.metadata.service.DuplicateFileService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class DuplicateFileServiceImpl extends ServiceImpl<DuplicateFileMapper, DuplicateFileEntity>
implements DuplicateFileService {
@Override
public boolean create(DuplicateFileEntity entity) {
return this.save(entity);
}
@Override
public List<DuplicateFileEntity> findAll() {
return this.list();
}
}

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,439 @@
package com.music.metadata.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.music.metadata.common.exception.BusinessException;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.CoverMetadata;
import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
import com.music.metadata.infrastructure.audio.AudioTagExtractor;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import com.music.metadata.service.MetadataReaderService;
import com.music.metadata.service.MetadataSnapshotService;
import org.mozilla.universalchardet.UniversalDetector;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Reads audio metadata, generates a raw JSON snapshot, and stores it for traceability.
*/
@Service
public class MetadataReaderServiceImpl implements MetadataReaderService {
private final AudioTagExtractor audioTagExtractor;
private final MetadataSnapshotService metadataSnapshotService;
private final ObjectMapper objectMapper;
public MetadataReaderServiceImpl(AudioTagExtractor audioTagExtractor,
MetadataSnapshotService metadataSnapshotService,
ObjectMapper objectMapper) {
this.audioTagExtractor = audioTagExtractor;
this.metadataSnapshotService = metadataSnapshotService;
this.objectMapper = objectMapper;
}
@Override
public AudioMetadata readMetadata(File file) {
// The extraction layer is intentionally isolated so that reader tests can focus on
// encoding detection, snapshot generation, and cover parsing without requiring real audio fixtures.
AudioTagExtractionResult extractionResult = audioTagExtractor.extract(file);
AudioMetadata metadata = new AudioMetadata();
metadata.setFileFormat(normalizeFormat(extractionResult.getFileFormat(), file));
metadata.setTitle(extractionResult.getTitle());
metadata.setArtist(extractionResult.getArtist());
metadata.setAlbumArtist(extractionResult.getAlbumArtist());
metadata.setAlbum(extractionResult.getAlbum());
metadata.setTrack(extractionResult.getTrack());
metadata.setYear(extractionResult.getYear());
metadata.setGenre(extractionResult.getGenre());
metadata.setLyrics(extractionResult.getLyrics());
metadata.setDisc(extractionResult.getDisc());
metadata.setComment(extractionResult.getComment());
metadata.setTagFields(copyFields(extractionResult.getFields()));
metadata.setCover(buildCoverMetadata(extractionResult));
metadata.setOriginalEncoding(detectOriginalEncoding(metadata));
boolean repaired = attemptEncodingRepair(metadata);
if (repaired) {
metadata.setOriginalEncoding("UTF-8");
}
String snapshotJson = buildSnapshotJson(file, metadata);
metadata.setSnapshotJson(snapshotJson);
persistSnapshot(file, metadata);
return metadata;
}
private Map<String, List<String>> copyFields(Map<String, List<String>> sourceFields) {
Map<String, List<String>> copied = new LinkedHashMap<>();
sourceFields.forEach((key, values) -> copied.put(key, new ArrayList<>(values)));
return copied;
}
private CoverMetadata buildCoverMetadata(AudioTagExtractionResult extractionResult) {
// Cover metadata is parsed here once so the validator can enforce size, aspect ratio,
// and allowed format rules without reopening the image bytes.
CoverMetadata coverMetadata = new CoverMetadata();
byte[] coverData = extractionResult.getCoverData();
if (coverData == null || coverData.length == 0) {
coverMetadata.setPresent(false);
return coverMetadata;
}
coverMetadata.setPresent(true);
coverMetadata.setBinaryData(coverData);
coverMetadata.setSize((long) coverData.length);
coverMetadata.setMimeType(extractionResult.getCoverMimeType());
coverMetadata.setFormat(resolveImageFormat(extractionResult.getCoverMimeType()));
try {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(coverData));
if (image != null) {
coverMetadata.setWidth(image.getWidth());
coverMetadata.setHeight(image.getHeight());
if (coverMetadata.getFormat() == null) {
coverMetadata.setFormat(resolveImageFormat(image));
}
}
} catch (Exception ignored) {
// Validation will treat unreadable artwork as an invalid cover format later.
}
return coverMetadata;
}
private String resolveImageFormat(BufferedImage image) {
return image.getColorModel().hasAlpha() ? "PNG" : null;
}
private String resolveImageFormat(String mimeType) {
if (mimeType == null) {
return null;
}
String normalized = mimeType.toLowerCase(Locale.ROOT);
if (normalized.contains("jpeg") || normalized.contains("jpg")) {
return "JPG";
}
if (normalized.contains("png")) {
return "PNG";
}
return null;
}
private String normalizeFormat(String extractedFormat, File file) {
if (extractedFormat != null && !extractedFormat.isBlank()) {
return extractedFormat.toUpperCase(Locale.ROOT);
}
String name = file.getName();
int dotIndex = name.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == name.length() - 1) {
return "UNKNOWN";
}
return name.substring(dotIndex + 1).toUpperCase(Locale.ROOT);
}
private String detectOriginalEncoding(AudioMetadata metadata) {
// The document requires a strict UTF-8 gate. We therefore scan the core text fields and
// return the first non-UTF-8 signal so the validator can short-circuit immediately.
List<String> values = new ArrayList<>();
values.add(metadata.getTitle());
values.add(metadata.getArtist());
values.add(metadata.getAlbumArtist());
values.add(metadata.getAlbum());
values.add(metadata.getTrack());
values.add(metadata.getGenre());
values.add(metadata.getLyrics());
values.add(metadata.getComment());
for (String value : values) {
String detected = detectEncoding(value);
if (!"UTF-8".equals(detected)) {
return detected;
}
}
return "UTF-8";
}
private boolean attemptEncodingRepair(AudioMetadata metadata) {
String encoding = metadata.getOriginalEncoding();
if (encoding == null || encoding.isBlank() || "UTF-8".equalsIgnoreCase(encoding)) {
return false;
}
String repairedTitle = attemptRepair(metadata.getTitle(), encoding);
String repairedArtist = attemptRepair(metadata.getArtist(), encoding);
String repairedAlbumArtist = attemptRepair(metadata.getAlbumArtist(), encoding);
String repairedAlbum = attemptRepair(metadata.getAlbum(), encoding);
String repairedGenre = attemptRepair(metadata.getGenre(), encoding);
String repairedLyrics = attemptRepair(metadata.getLyrics(), encoding);
String repairedComment = attemptRepair(metadata.getComment(), encoding);
if (repairSucceeded(metadata.getTitle(), repairedTitle)
|| repairSucceeded(metadata.getArtist(), repairedArtist)
|| repairSucceeded(metadata.getAlbumArtist(), repairedAlbumArtist)
|| repairSucceeded(metadata.getAlbum(), repairedAlbum)
|| repairSucceeded(metadata.getGenre(), repairedGenre)
|| repairSucceeded(metadata.getLyrics(), repairedLyrics)
|| repairSucceeded(metadata.getComment(), repairedComment)) {
metadata.setTitle(repairedTitle);
metadata.setArtist(repairedArtist);
metadata.setAlbumArtist(repairedAlbumArtist);
metadata.setAlbum(repairedAlbum);
metadata.setGenre(repairedGenre);
metadata.setLyrics(repairedLyrics);
metadata.setComment(repairedComment);
metadata.setTagFields(repairTagFields(metadata.getTagFields(), encoding));
return true;
}
return false;
}
private Map<String, List<String>> repairTagFields(Map<String, List<String>> tagFields, String encoding) {
Map<String, List<String>> repaired = new LinkedHashMap<>();
tagFields.forEach((key, values) -> {
List<String> repairedValues = new ArrayList<>();
for (String value : values) {
repairedValues.add(attemptRepair(value, encoding));
}
repaired.put(key, repairedValues);
});
return repaired;
}
private boolean repairSucceeded(String original, String repaired) {
return original != null
&& repaired != null
&& !original.equals(repaired)
&& isLikelyCleanText(repaired)
&& scoreText(repaired) > scoreText(original);
}
private String attemptRepair(String value, String encoding) {
if (value == null || value.isBlank() || !isLikelyMojibake(value)) {
return value;
}
boolean chineseMojibake = looksLikeMainlandChineseMojibake(value);
Set<String> candidateCharsets = new java.util.LinkedHashSet<>();
if (chineseMojibake) {
candidateCharsets.add("GB18030");
candidateCharsets.add("GBK");
candidateCharsets.add(encoding);
} else {
candidateCharsets.add(encoding);
candidateCharsets.add("BIG5");
candidateCharsets.add("SHIFT_JIS");
}
String bestCandidate = value;
int bestScore = scoreText(value);
try {
byte[] originalBytes = value.getBytes(StandardCharsets.ISO_8859_1);
for (String candidateCharset : candidateCharsets) {
if (!Charset.isSupported(candidateCharset)) {
continue;
}
String repaired = new String(originalBytes, Charset.forName(candidateCharset));
int candidateScore = scoreText(repaired);
if (candidateScore > bestScore) {
bestScore = candidateScore;
bestCandidate = repaired;
}
}
if (chineseMojibake && countCjkCharacters(bestCandidate) == 0) {
return value;
}
return bestCandidate;
} catch (Exception ignored) {
return value;
}
}
private String detectEncoding(String value) {
if (value == null || value.isBlank()) {
return "UTF-8";
}
if (value.contains("\uFFFD") || value.contains("<EFBFBD>")) {
return "UNKNOWN";
}
if (looksLikeMainlandChineseMojibake(value)) {
return "GBK";
}
byte[] candidateBytes = value.getBytes(StandardCharsets.ISO_8859_1);
UniversalDetector detector = new UniversalDetector(null);
detector.handleData(candidateBytes, 0, candidateBytes.length);
detector.dataEnd();
String detected = detector.getDetectedCharset();
detector.reset();
if (detected == null) {
return isLikelyCleanText(value) ? "UTF-8" : "UNKNOWN";
}
String normalized = detected.toUpperCase(Locale.ROOT);
if (!isLikelyCleanText(value)) {
return "UNKNOWN";
}
if ("US-ASCII".equals(normalized) || "ASCII".equals(normalized)) {
return "UTF-8";
}
if (normalized.startsWith("UTF")) {
return "UTF-8";
}
return normalized;
}
private boolean isLikelyCleanText(String value) {
if (value == null) {
return false;
}
String lowerCase = value.toLowerCase(Locale.ROOT);
return !(lowerCase.indexOf('ã') >= 0 || lowerCase.indexOf('â') >= 0 || lowerCase.contains("<EFBFBD>") || hasHalfWidthKatakana(value));
}
private int scoreText(String value) {
if (value == null || value.isBlank()) {
return Integer.MIN_VALUE;
}
int score = 0;
for (char current : value.toCharArray()) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(current);
if (isCjkBlock(block)) {
score += 6;
} else if (Character.isLetterOrDigit(current)) {
score += 2;
} else if (Character.isWhitespace(current) || isCommonPunctuation(current)) {
score += 1;
}
if (current == '<27>') {
score -= 10;
}
if (block == Character.UnicodeBlock.BOX_DRAWING
|| block == Character.UnicodeBlock.BLOCK_ELEMENTS
|| block == Character.UnicodeBlock.GEOMETRIC_SHAPES) {
score -= 6;
}
}
if (!isLikelyCleanText(value)) {
score -= 8;
}
return score;
}
private boolean isCjkBlock(Character.UnicodeBlock block) {
return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
|| block == Character.UnicodeBlock.HIRAGANA
|| block == Character.UnicodeBlock.KATAKANA
|| block == Character.UnicodeBlock.HANGUL_SYLLABLES;
}
private boolean isCommonPunctuation(char current) {
return ",.;:!?()[]{}-_/\\'\"&+".indexOf(current) >= 0;
}
private int countCjkCharacters(String value) {
int count = 0;
for (char current : value.toCharArray()) {
if (isCjkBlock(Character.UnicodeBlock.of(current))) {
count++;
}
}
return count;
}
private boolean hasHalfWidthKatakana(String value) {
for (char current : value.toCharArray()) {
if (current >= '\uFF61' && current <= '\uFF9F') {
return true;
}
}
return false;
}
private boolean isLikelyMojibake(String value) {
boolean hasSuspiciousMarker = value.contains("Ã") || value.contains("â") || value.contains("Ö")
|| value.contains("Î") || value.contains("Ê") || value.contains("<EFBFBD>") || looksLikeMainlandChineseMojibake(value);
if (hasSuspiciousMarker) {
return true;
}
for (char current : value.toCharArray()) {
if (current > 0x00FF) {
return false;
}
}
return true;
}
private boolean looksLikeMainlandChineseMojibake(String value) {
return value.contains("Ö") || value.contains("Ð") || value.contains("Î") || value.contains("Ä")
|| value.contains("Ê") || value.contains("Ì") || value.contains("Æ") || value.contains("¨");
}
private String buildSnapshotJson(File file, AudioMetadata metadata) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("fileName", file.getName());
snapshot.put("filePath", file.getAbsolutePath());
snapshot.put("fileFormat", metadata.getFileFormat());
snapshot.put("originalEncoding", metadata.getOriginalEncoding());
snapshot.put("fields", metadata.getTagFields());
Map<String, Object> core = new LinkedHashMap<>();
core.put("title", metadata.getTitle());
core.put("artist", metadata.getArtist());
core.put("albumArtist", metadata.getAlbumArtist());
core.put("album", metadata.getAlbum());
core.put("track", metadata.getTrack());
core.put("year", metadata.getYear());
core.put("genre", metadata.getGenre());
core.put("lyrics", metadata.getLyrics());
core.put("disc", metadata.getDisc());
core.put("comment", metadata.getComment());
snapshot.put("core", core);
snapshot.put("cover", buildCoverSnapshot(metadata.getCover()));
try {
return objectMapper.writeValueAsString(snapshot);
} catch (JsonProcessingException exception) {
throw new BusinessException("Failed to serialize metadata snapshot: " + exception.getMessage());
}
}
private Map<String, Object> buildCoverSnapshot(CoverMetadata coverMetadata) {
if (coverMetadata == null) {
return Map.of("present", false);
}
Map<String, Object> cover = new LinkedHashMap<>();
cover.put("present", coverMetadata.isPresent());
cover.put("mimeType", coverMetadata.getMimeType());
cover.put("format", coverMetadata.getFormat());
cover.put("width", coverMetadata.getWidth());
cover.put("height", coverMetadata.getHeight());
cover.put("size", coverMetadata.getSize());
return cover;
}
private void persistSnapshot(File file, AudioMetadata metadata) {
MetadataSnapshotEntity entity = new MetadataSnapshotEntity();
entity.setSourceFilePath(file.getAbsolutePath());
entity.setFileName(file.getName());
entity.setFileFormat(metadata.getFileFormat());
entity.setOriginalEncoding(metadata.getOriginalEncoding());
entity.setSnapshotJson(metadata.getSnapshotJson());
entity.setCreatedAt(LocalDateTime.now());
metadataSnapshotService.create(entity);
}
}

View File

@@ -0,0 +1,17 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import com.music.metadata.infrastructure.mapper.MetadataSnapshotMapper;
import com.music.metadata.service.MetadataSnapshotService;
import org.springframework.stereotype.Service;
@Service
public class MetadataSnapshotServiceImpl extends ServiceImpl<MetadataSnapshotMapper, MetadataSnapshotEntity>
implements MetadataSnapshotService {
@Override
public boolean create(MetadataSnapshotEntity entity) {
return this.save(entity);
}
}

View File

@@ -0,0 +1,321 @@
package com.music.metadata.service.impl;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.CoverMetadata;
import com.music.metadata.domain.metadata.MetadataValidationResult;
import com.music.metadata.domain.metadata.ValidationFailure;
import com.music.metadata.domain.metadata.ValidationFailureType;
import com.music.metadata.service.MetadataValidatorService;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Implements the document-defined one-vote veto validation strategy.
* Any core-field failure causes an immediate stop and returns exactly one failure reason.
*/
@Service
public class MetadataValidatorServiceImpl implements MetadataValidatorService {
private static final Pattern TRACK_PATTERN = Pattern.compile("^(\\d{1,3})/(\\d{1,3})$");
private static final Pattern VERSION_PATTERN = Pattern.compile("\\s*([\\[(](?i:(live|remix|instrumental|伴奏|karaoke|acoustic|demo))[\\w\\s.-]*[\\])])\\s*");
private static final Pattern INVISIBLE_PATTERN = Pattern.compile("[\\p{Cntrl}&&[^\\r\\n\\t]]|[\\u200B-\\u200D\\uFEFF]");
private static final Pattern INVALID_FILE_NAME_CHARS = Pattern.compile("[\\\\/:*?\"<>|]");
private static final Pattern FEAT_SEPARATOR_PATTERN = Pattern.compile("(?i)\\s+(feat\\.?|ft\\.?)\\s+");
private static final List<String> ARTICLE_WORDS = List.of("a", "an", "the");
@Override
public MetadataValidationResult validate(AudioMetadata metadata) {
AudioMetadata cleaned = deepCopy(metadata);
// Clean first, then validate in the exact mandatory order from the document.
// The first failure returns immediately to implement the one-vote veto rule.
normalizeOptionalFields(cleaned);
ValidationFailure failure = validateEncoding(cleaned);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateRequiredTextField("title", cleaned.getTitle(), cleaned::setTitle);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateRequiredTextField("artist", cleaned.getArtist(), cleaned::setArtist);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateRequiredTextField("album_artist", cleaned.getAlbumArtist(), cleaned::setAlbumArtist);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateRequiredTextField("album", cleaned.getAlbum(), cleaned::setAlbum);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateTrack(cleaned);
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
failure = validateCover(cleaned.getCover());
if (failure != null) {
return MetadataValidationResult.failed(failure, cleaned);
}
return MetadataValidationResult.passed(cleaned);
}
private void normalizeOptionalFields(AudioMetadata metadata) {
metadata.setTitle(cleanField(metadata.getTitle(), false));
metadata.setArtist(cleanField(metadata.getArtist(), true));
metadata.setAlbumArtist(cleanField(metadata.getAlbumArtist(), true));
metadata.setAlbum(cleanField(metadata.getAlbum(), false));
metadata.setGenre(cleanField(metadata.getGenre(), true));
metadata.setLyrics(cleanField(metadata.getLyrics(), false));
metadata.setDisc(cleanTrackLikeField(metadata.getDisc()));
metadata.setComment(cleanField(metadata.getComment(), false));
normalizeTitleVersionInfo(metadata);
normalizeTagFields(metadata);
}
private ValidationFailure validateEncoding(AudioMetadata metadata) {
String encoding = metadata.getOriginalEncoding();
if (encoding == null || encoding.isBlank()) {
return null;
}
String normalized = encoding.toUpperCase(Locale.ROOT);
if ("UTF-8".equals(normalized)) {
return null;
}
return new ValidationFailure(ValidationFailureType.ENCODING_ERROR, "encoding",
"Metadata encoding must be UTF-8, but detected " + normalized);
}
private ValidationFailure validateRequiredTextField(String fieldName,
String value,
java.util.function.Consumer<String> consumer) {
if (value == null || value.isBlank()) {
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
fieldName + " is required");
}
String cleanedValue = cleanField(value, "artist".equals(fieldName) || "album_artist".equals(fieldName));
consumer.accept(cleanedValue);
if (cleanedValue.isBlank()) {
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
fieldName + " is required");
}
if (containsGarbledText(cleanedValue)) {
return new ValidationFailure(ValidationFailureType.GARBLED_TEXT, fieldName,
fieldName + " contains garbled characters");
}
return null;
}
private ValidationFailure validateTrack(AudioMetadata metadata) {
String track = cleanTrackLikeField(metadata.getTrack());
metadata.setTrack(track);
if (track == null || track.isBlank()) {
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, "track", "track is required");
}
Matcher matcher = TRACK_PATTERN.matcher(track);
if (!matcher.matches()) {
return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
"track must match '序号/总曲目数', e.g. 01/12");
}
int current = Integer.parseInt(matcher.group(1));
int total = Integer.parseInt(matcher.group(2));
if (current <= 0 || total <= 0 || current > total) {
return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
"track numbers must be positive and current track cannot exceed total tracks");
}
metadata.setTrack(String.format(Locale.ROOT, "%02d/%02d", current, total));
return null;
}
private ValidationFailure validateCover(CoverMetadata coverMetadata) {
if (coverMetadata == null || !coverMetadata.isPresent() || coverMetadata.getBinaryData() == null
|| coverMetadata.getBinaryData().length == 0) {
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover", "embedded cover is required");
}
String format = coverMetadata.getFormat();
if (format == null || !("JPG".equalsIgnoreCase(format) || "PNG".equalsIgnoreCase(format))) {
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
"cover format must be JPG or PNG");
}
Integer width = coverMetadata.getWidth();
Integer height = coverMetadata.getHeight();
if (width == null || height == null) {
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
"cover image metadata cannot be parsed");
}
if (width < 300 || height < 300) {
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
"cover dimensions must be at least 300x300");
}
if (!width.equals(height)) {
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
"cover aspect ratio must be 1:1");
}
return null;
}
private void normalizeTitleVersionInfo(AudioMetadata metadata) {
String title = metadata.getTitle();
if (title == null || title.isBlank()) {
return;
}
Matcher matcher = VERSION_PATTERN.matcher(title);
if (!matcher.find()) {
return;
}
String versionInfo = matcher.group(1).trim();
String cleanedTitle = matcher.replaceAll(" ").trim();
// Version labels should not pollute the canonical title because they break matching and
// archive naming; they are preserved in comment to avoid losing the information.
metadata.setTitle(cleanedTitle);
if (metadata.getComment() == null || metadata.getComment().isBlank()) {
metadata.setComment(versionInfo);
} else if (!metadata.getComment().contains(versionInfo)) {
metadata.setComment(metadata.getComment() + "; " + versionInfo);
}
}
private void normalizeTagFields(AudioMetadata metadata) {
Map<String, List<String>> normalized = new LinkedHashMap<>();
metadata.getTagFields().forEach((key, values) -> {
java.util.ArrayList<String> cleanedValues = new java.util.ArrayList<>();
for (String value : values) {
String cleanedValue = cleanField(value, "artist".equalsIgnoreCase(key) || "album_artist".equalsIgnoreCase(key)
|| "genre".equalsIgnoreCase(key));
if (cleanedValue != null && !cleanedValue.isBlank()) {
cleanedValues.add(cleanedValue);
}
}
if (!cleanedValues.isEmpty()) {
normalized.put(key, cleanedValues);
}
});
metadata.setTagFields(normalized);
}
private String cleanField(String value, boolean normalizeSeparators) {
if (value == null) {
return null;
}
String cleaned = value.strip();
cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
cleaned = INVALID_FILE_NAME_CHARS.matcher(cleaned).replaceAll("_");
cleaned = cleaned.replaceAll("\\s+", " ").trim();
if (normalizeSeparators) {
cleaned = normalizeSeparators(cleaned);
}
cleaned = normalizeEnglishCase(cleaned);
return cleaned;
}
private String cleanTrackLikeField(String value) {
if (value == null) {
return null;
}
String cleaned = value.strip();
cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
cleaned = cleaned.replaceAll("\\s+", "");
return cleaned;
}
private String normalizeSeparators(String value) {
String normalized = FEAT_SEPARATOR_PATTERN.matcher(value).replaceAll("; ");
normalized = normalized.replaceAll("\\s*&\\s*", "; ");
normalized = normalized.replaceAll("\\s+/\\s+", "; ");
normalized = normalized.replaceAll(";\\s*;", "; ");
return normalized.trim();
}
private String normalizeEnglishCase(String value) {
if (value == null || value.isBlank()) {
return value;
}
String[] words = value.split(" ");
StringBuilder builder = new StringBuilder();
for (int index = 0; index < words.length; index++) {
if (index > 0) {
builder.append(' ');
}
builder.append(normalizeWord(words[index], index == 0));
}
return builder.toString();
}
private String normalizeWord(String word, boolean firstWord) {
if (!word.matches(".*[A-Za-z].*")) {
return word;
}
String[] tokens = word.split("(?=[/-])|(?<=[/-])");
StringBuilder builder = new StringBuilder();
for (String token : tokens) {
if (token.equals("/") || token.equals("-")) {
builder.append(token);
continue;
}
String lower = token.toLowerCase(Locale.ROOT);
if (!firstWord && ARTICLE_WORDS.contains(lower)) {
builder.append(lower);
} else {
builder.append(Character.toUpperCase(lower.charAt(0))).append(lower.substring(1));
}
}
return builder.toString();
}
private boolean containsGarbledText(String value) {
return value.contains("<EFBFBD>") || value.contains("\uFFFD") || value.contains("Ã") || value.contains("â");
}
private AudioMetadata deepCopy(AudioMetadata source) {
AudioMetadata copy = new AudioMetadata();
copy.setFileFormat(source.getFileFormat());
copy.setOriginalEncoding(source.getOriginalEncoding());
copy.setSnapshotJson(source.getSnapshotJson());
copy.setTitle(source.getTitle());
copy.setArtist(source.getArtist());
copy.setAlbumArtist(source.getAlbumArtist());
copy.setAlbum(source.getAlbum());
copy.setTrack(source.getTrack());
copy.setYear(source.getYear());
copy.setGenre(source.getGenre());
copy.setLyrics(source.getLyrics());
copy.setDisc(source.getDisc());
copy.setComment(source.getComment());
copy.setTagFields(new LinkedHashMap<>(source.getTagFields()));
if (source.getCover() != null) {
CoverMetadata coverCopy = new CoverMetadata();
coverCopy.setPresent(source.getCover().isPresent());
coverCopy.setMimeType(source.getCover().getMimeType());
coverCopy.setFormat(source.getCover().getFormat());
coverCopy.setWidth(source.getCover().getWidth());
coverCopy.setHeight(source.getCover().getHeight());
coverCopy.setSize(source.getCover().getSize());
coverCopy.setBinaryData(source.getCover().getBinaryData());
copy.setCover(coverCopy);
}
return copy;
}
}

View File

@@ -0,0 +1,215 @@
package com.music.metadata.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.music.metadata.common.exception.BusinessException;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.MetadataValidationResult;
import com.music.metadata.domain.scan.DuplicateType;
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
import com.music.metadata.domain.scan.ScanProcessingOutcome;
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
import com.music.metadata.infrastructure.entity.FileProcessEntity;
import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import com.music.metadata.service.AudioDurationService;
import com.music.metadata.service.DeduplicationService;
import com.music.metadata.service.DuplicateFileService;
import com.music.metadata.service.FileHashService;
import com.music.metadata.service.FileProcessService;
import com.music.metadata.service.MetadataReaderService;
import com.music.metadata.service.MetadataValidatorService;
import com.music.metadata.service.ProcessTaskService;
import com.music.metadata.service.ScanFileProcessor;
import org.springframework.stereotype.Service;
import java.io.File;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class ScanFileProcessorImpl implements ScanFileProcessor {
private final FileHashService fileHashService;
private final MetadataReaderService metadataReaderService;
private final MetadataValidatorService metadataValidatorService;
private final AudioDurationService audioDurationService;
private final DeduplicationService deduplicationService;
private final FileProcessService fileProcessService;
private final DuplicateFileService duplicateFileService;
private final ProcessTaskService processTaskService;
private final ObjectMapper objectMapper;
public ScanFileProcessorImpl(FileHashService fileHashService,
MetadataReaderService metadataReaderService,
MetadataValidatorService metadataValidatorService,
AudioDurationService audioDurationService,
DeduplicationService deduplicationService,
FileProcessService fileProcessService,
DuplicateFileService duplicateFileService,
ProcessTaskService processTaskService,
ObjectMapper objectMapper) {
this.fileHashService = fileHashService;
this.metadataReaderService = metadataReaderService;
this.metadataValidatorService = metadataValidatorService;
this.audioDurationService = audioDurationService;
this.deduplicationService = deduplicationService;
this.fileProcessService = fileProcessService;
this.duplicateFileService = duplicateFileService;
this.processTaskService = processTaskService;
this.objectMapper = objectMapper;
}
@Override
public ScanProcessingOutcome process(Long taskId, ScanItemEntity scanItemEntity) {
File file = new File(scanItemEntity.getSourceFilePath());
String fileHash = fileHashService.sha256(file);
FileProcessEntity archived = deduplicationService.findArchivedByHash(fileHash);
if (archived != null) {
createDuplicateRecord(taskId, file, fileHash, DuplicateType.HASH_DUPLICATE, archived.getId(),
Map.of("reason", "Archived file scanned again; skipped immediately"));
incrementDuplicateCount(taskId);
return ScanProcessingOutcome.HASH_DUPLICATE;
}
AudioMetadata metadata = metadataReaderService.readMetadata(file);
MetadataValidationResult validationResult = metadataValidatorService.validate(metadata);
if (!validationResult.isPassed()) {
createFailedProcessRecord(taskId, file, fileHash, metadata.getSnapshotJson(), validationResult.getFailures().get(0).getMessage());
incrementFailCount(taskId);
return ScanProcessingOutcome.FAILED;
}
Integer durationSeconds = audioDurationService.getDurationSeconds(file);
AudioMetadata cleanedMetadata = validationResult.getCleanedMetadata();
FeatureDeduplicationHit level2Hit = deduplicationService.findLevel2Duplicate(cleanedMetadata, durationSeconds);
if (level2Hit != null) {
createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
level2Hit.getDedupKey(), "SUSPECT_DUPLICATE");
createDuplicateRecord(taskId, file, fileHash, DuplicateType.LEVEL2_SUSPECT, level2Hit.getCanonicalFile().getId(),
Map.of(
"reason", "Matched level-2 deduplication key",
"dedupKey", level2Hit.getDedupKey(),
"canonicalFileHash", level2Hit.getCanonicalFile().getFileHash()
));
incrementDuplicateCount(taskId);
return ScanProcessingOutcome.LEVEL2_SUSPECT;
}
createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
deduplicationService.buildDedupKey(cleanedMetadata, durationSeconds), "SUCCESS");
incrementSuccessCount(taskId);
return ScanProcessingOutcome.SUCCESS;
}
private void createSuccessfulProcessRecord(Long taskId,
File file,
String fileHash,
String rawMetadata,
Integer durationSeconds,
String dedupKey,
String status) {
FileProcessEntity entity = new FileProcessEntity();
entity.setFileHash(fileHash);
entity.setSourceFilePath(file.getAbsolutePath());
entity.setSourceFileName(file.getName());
entity.setFileExtension(resolveExtension(file));
entity.setFileSize(file.length());
entity.setAudioDuration(durationSeconds);
entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
entity.setProcessStatus(status);
entity.setDedupKey(dedupKey);
entity.setTaskId(taskId);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
fileProcessService.create(entity);
}
private void createFailedProcessRecord(Long taskId, File file, String fileHash, String rawMetadata, String failReason) {
if (fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
.eq(FileProcessEntity::getFileHash, fileHash)
.last("LIMIT 1")) != null) {
return;
}
FileProcessEntity entity = new FileProcessEntity();
entity.setFileHash(fileHash);
entity.setSourceFilePath(file.getAbsolutePath());
entity.setSourceFileName(file.getName());
entity.setFileExtension(resolveExtension(file));
entity.setFileSize(file.length());
entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
entity.setProcessStatus("FAIL");
entity.setFailReason(failReason);
entity.setTaskId(taskId);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
fileProcessService.create(entity);
}
private void createDuplicateRecord(Long taskId,
File file,
String fileHash,
DuplicateType duplicateType,
Long canonicalFileProcessId,
Map<String, Object> detail) {
DuplicateFileEntity entity = new DuplicateFileEntity();
entity.setTaskId(taskId);
entity.setFileHash(fileHash);
entity.setSourceFilePath(file.getAbsolutePath());
entity.setSourceFileName(file.getName());
entity.setDuplicateType(duplicateType.name());
entity.setCanonicalFileProcessId(canonicalFileProcessId);
entity.setDetailJson(writeJson(detail));
entity.setCreatedAt(LocalDateTime.now());
duplicateFileService.create(entity);
}
private void incrementSuccessCount(Long taskId) {
ProcessTaskEntity task = loadTask(taskId);
task.setSuccessCount(defaultZero(task.getSuccessCount()) + 1);
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
processTaskService.update(task);
}
private void incrementFailCount(Long taskId) {
ProcessTaskEntity task = loadTask(taskId);
task.setFailCount(defaultZero(task.getFailCount()) + 1);
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
processTaskService.update(task);
}
private void incrementDuplicateCount(Long taskId) {
ProcessTaskEntity task = loadTask(taskId);
task.setDuplicateCount(defaultZero(task.getDuplicateCount()) + 1);
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
processTaskService.update(task);
}
private ProcessTaskEntity loadTask(Long taskId) {
ProcessTaskEntity task = processTaskService.findById(taskId);
if (task == null) {
throw new BusinessException("Process task not found: " + taskId);
}
return task;
}
private int defaultZero(Integer value) {
return value == null ? 0 : value;
}
private String writeJson(Map<String, Object> detail) {
try {
return objectMapper.writeValueAsString(detail);
} catch (Exception exception) {
throw new BusinessException("Failed to serialize duplicate detail: " + exception.getMessage());
}
}
private String resolveExtension(File file) {
String fileName = file.getName();
int index = fileName.lastIndexOf('.');
return index < 0 ? "" : fileName.substring(index + 1).toLowerCase();
}
}

View File

@@ -0,0 +1,28 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import com.music.metadata.infrastructure.mapper.ScanItemMapper;
import com.music.metadata.service.ScanItemService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ScanItemServiceImpl extends ServiceImpl<ScanItemMapper, ScanItemEntity> implements ScanItemService {
@Override
public boolean create(ScanItemEntity entity) {
return this.save(entity);
}
@Override
public boolean update(ScanItemEntity entity) {
return this.updateById(entity);
}
@Override
public List<ScanItemEntity> findAll() {
return this.list();
}
}

View File

@@ -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());
}
}
}

View File

@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS t_file_process (
process_status VARCHAR(20) NOT NULL, process_status VARCHAR(20) NOT NULL,
fail_reason TEXT, fail_reason TEXT,
target_file_path VARCHAR(1000), target_file_path VARCHAR(1000),
dedup_key VARCHAR(64),
task_id BIGINT NOT NULL, task_id BIGINT NOT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
updated_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, updated_at DATETIME NOT NULL,
CONSTRAINT uk_t_system_config_config_key UNIQUE (config_key) 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);

View File

@@ -0,0 +1,108 @@
package com.music.metadata.service.impl;
import com.music.metadata.common.api.ApiResponse;
import com.music.metadata.common.exception.BusinessException;
import com.music.metadata.common.handler.GlobalExceptionHandler;
import com.music.metadata.infrastructure.entity.FailFileEntity;
import com.music.metadata.infrastructure.entity.SystemConfigEntity;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
class CoverageSupportTest {
@Test
void apiResponseShouldExposeFactoriesAndAccessors() {
ApiResponse<String> success = ApiResponse.success("ok");
assertThat(success.getCode()).isEqualTo(200);
assertThat(success.getMessage()).isEqualTo("操作成功");
assertThat(success.getData()).isEqualTo("ok");
assertThat(success.getTimestamp()).isNotNull();
ApiResponse<String> response = new ApiResponse<>();
response.setCode(400);
response.setMessage("bad");
response.setData("payload");
response.setTimestamp(123L);
assertThat(response.getCode()).isEqualTo(400);
assertThat(response.getMessage()).isEqualTo("bad");
assertThat(response.getData()).isEqualTo("payload");
assertThat(response.getTimestamp()).isEqualTo(123L);
ApiResponse<Void> failed = ApiResponse.fail(422, "oops");
assertThat(failed.getCode()).isEqualTo(422);
assertThat(failed.getMessage()).isEqualTo("oops");
}
@Test
void businessExceptionAndGlobalHandlerShouldReturnExpectedResponses() {
BusinessException defaultException = new BusinessException("default");
assertThat(defaultException.getCode()).isEqualTo(400);
BusinessException customException = new BusinessException(422, "custom");
assertThat(customException.getCode()).isEqualTo(422);
assertThat(customException.getMessage()).isEqualTo("custom");
GlobalExceptionHandler handler = new GlobalExceptionHandler();
ApiResponse<Void> business = handler.handleBusinessException(customException);
assertThat(business.getCode()).isEqualTo(422);
assertThat(business.getMessage()).isEqualTo("custom");
ApiResponse<Void> generic = handler.handleException(new RuntimeException("boom"));
assertThat(generic.getCode()).isEqualTo(500);
assertThat(generic.getMessage()).isEqualTo("系统异常,请稍后重试");
}
@Test
void entitiesShouldExposeAllAccessors() {
FailFileEntity failFile = new FailFileEntity();
LocalDateTime now = LocalDateTime.now();
failFile.setId(1L);
failFile.setFileProcessId(2L);
failFile.setFileHash("hash");
failFile.setSourceFilePath("/tmp/a.mp3");
failFile.setFileName("a.mp3");
failFile.setFailType("MISSING_FIELD");
failFile.setFailDetail("detail");
failFile.setRawMetadata("{}");
failFile.setEditMetadata("{edited:true}");
failFile.setStatus("PENDING");
failFile.setCreatedAt(now);
failFile.setUpdatedAt(now);
failFile.setResolvedAt(now);
assertThat(failFile.getId()).isEqualTo(1L);
assertThat(failFile.getFileProcessId()).isEqualTo(2L);
assertThat(failFile.getFileHash()).isEqualTo("hash");
assertThat(failFile.getSourceFilePath()).isEqualTo("/tmp/a.mp3");
assertThat(failFile.getFileName()).isEqualTo("a.mp3");
assertThat(failFile.getFailType()).isEqualTo("MISSING_FIELD");
assertThat(failFile.getFailDetail()).isEqualTo("detail");
assertThat(failFile.getRawMetadata()).isEqualTo("{}");
assertThat(failFile.getEditMetadata()).isEqualTo("{edited:true}");
assertThat(failFile.getStatus()).isEqualTo("PENDING");
assertThat(failFile.getCreatedAt()).isEqualTo(now);
assertThat(failFile.getUpdatedAt()).isEqualTo(now);
assertThat(failFile.getResolvedAt()).isEqualTo(now);
SystemConfigEntity config = new SystemConfigEntity();
config.setId(3L);
config.setConfigKey("key");
config.setConfigValue("value");
config.setConfigName("name");
config.setConfigDesc("desc");
config.setIsEditable(1);
config.setCreatedAt(now);
config.setUpdatedAt(now);
assertThat(config.getId()).isEqualTo(3L);
assertThat(config.getConfigKey()).isEqualTo("key");
assertThat(config.getConfigValue()).isEqualTo("value");
assertThat(config.getConfigName()).isEqualTo("name");
assertThat(config.getConfigDesc()).isEqualTo("desc");
assertThat(config.getIsEditable()).isEqualTo(1);
assertThat(config.getCreatedAt()).isEqualTo(now);
assertThat(config.getUpdatedAt()).isEqualTo(now);
}
}

View File

@@ -0,0 +1,282 @@
package com.music.metadata.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.music.metadata.MusicMetadataSystemApplication;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.CoverMetadata;
import com.music.metadata.domain.metadata.MetadataValidationResult;
import com.music.metadata.domain.scan.ScanItemStatus;
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
import com.music.metadata.infrastructure.entity.FileProcessEntity;
import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
import com.music.metadata.infrastructure.entity.ScanItemEntity;
import com.music.metadata.service.AudioDurationService;
import com.music.metadata.service.DirectoryScanService;
import com.music.metadata.service.DuplicateFileService;
import com.music.metadata.service.FileProcessService;
import com.music.metadata.service.MetadataReaderService;
import com.music.metadata.service.MetadataValidatorService;
import com.music.metadata.service.ProcessTaskService;
import com.music.metadata.service.ScanItemService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import java.io.IOException;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringBootTest(classes = MusicMetadataSystemApplication.class)
@ActiveProfiles("test")
class DirectoryScanP1AcceptanceTest {
@Autowired
private DirectoryScanService directoryScanService;
@Autowired
private ProcessTaskService processTaskService;
@Autowired
private FileProcessService fileProcessService;
@Autowired
private DuplicateFileService duplicateFileService;
@Autowired
private ScanItemService scanItemService;
@MockBean
private MetadataReaderService metadataReaderService;
@MockBean
private MetadataValidatorService metadataValidatorService;
@MockBean
private AudioDurationService audioDurationService;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
scanItemService.remove(null);
duplicateFileService.remove(null);
fileProcessService.remove(null);
processTaskService.remove(null);
reset(metadataReaderService, metadataValidatorService, audioDurationService);
}
@Test
void shouldMarkCopiedFileAsHashDuplicateAndSkipMetadataPipeline() throws Exception {
Path sourceDir = Files.createDirectory(tempDir.resolve("hash-duplicate"));
Path original = createAudioPlaceholder(sourceDir.resolve("song-a.mp3"), "same-content");
Path copied = createAudioPlaceholder(sourceDir.resolve("song-a-copy.mp3"), "same-content");
stubSuccessfulMetadataPipeline("Song A", "Artist", "Album", 210);
Long firstTaskId = createTask(sourceDir);
directoryScanService.startOrResume(firstTaskId, sourceDir.toString());
reset(metadataReaderService, metadataValidatorService, audioDurationService);
Long secondTaskId = createTask(sourceDir);
directoryScanService.startOrResume(secondTaskId, sourceDir.toString());
List<DuplicateFileEntity> duplicates = duplicateFileService.list(new LambdaQueryWrapper<DuplicateFileEntity>()
.eq(DuplicateFileEntity::getTaskId, secondTaskId));
assertThat(duplicates).hasSize(2);
assertThat(duplicates).allMatch(item -> "HASH_DUPLICATE".equals(item.getDuplicateType()));
ProcessTaskEntity secondTask = processTaskService.findById(secondTaskId);
assertThat(secondTask.getDuplicateCount()).isEqualTo(2);
verify(metadataReaderService, never()).readMetadata(any());
verify(metadataValidatorService, never()).validate(any());
verify(audioDurationService, never()).getDurationSeconds(any());
assertThat(Files.exists(original)).isTrue();
assertThat(Files.exists(copied)).isTrue();
}
@Test
void shouldMarkDifferentBitrateVersionsAsLevel2SuspectDuplicate() throws Exception {
Path sourceDir = Files.createDirectory(tempDir.resolve("level2"));
Path lossless = createAudioPlaceholder(sourceDir.resolve("song.flac"), "lossless-content");
Path lossy = createAudioPlaceholder(sourceDir.resolve("song-320.mp3"), "lossy-content");
when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
File file = invocation.getArgument(0);
return buildValidMetadata(file.getName(), "Shared Song", "Shared Artist", "Shared Album");
});
when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
when(audioDurationService.getDurationSeconds(any())).thenReturn(188);
Long taskId = createTask(sourceDir);
directoryScanService.startOrResume(taskId, sourceDir.toString());
List<DuplicateFileEntity> duplicates = duplicateFileService.list(new LambdaQueryWrapper<DuplicateFileEntity>()
.eq(DuplicateFileEntity::getTaskId, taskId));
assertThat(duplicates).hasSize(1);
assertThat(duplicates.get(0).getDuplicateType()).isEqualTo("LEVEL2_SUSPECT");
List<FileProcessEntity> records = fileProcessService.findAll();
assertThat(records).hasSize(2);
assertThat(records).extracting(FileProcessEntity::getProcessStatus)
.containsExactlyInAnyOrder("SUCCESS", "SUSPECT_DUPLICATE");
verify(metadataReaderService, atLeastOnce()).readMetadata(any());
assertThat(Files.exists(lossless)).isTrue();
assertThat(Files.exists(lossy)).isTrue();
}
@Test
void shouldResumeScanForOneThousandFilesAfterInterruption() throws Exception {
Path sourceDir = Files.createDirectory(tempDir.resolve("resume"));
for (int index = 0; index < 1000; index++) {
createAudioPlaceholder(sourceDir.resolve(String.format("file-%04d.mp3", index)), "content-" + index);
}
final int[] invocationCounter = {0};
Mockito.doAnswer(invocation -> {
invocationCounter[0]++;
if (invocationCounter[0] == 401) {
throw new RuntimeException("Simulated interruption");
}
File file = invocation.getArgument(0);
return buildValidMetadata(file.getName(), "Artist", "Album");
}).when(metadataReaderService).readMetadata(any());
when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
when(audioDurationService.getDurationSeconds(any())).thenReturn(200);
Long taskId = createTask(sourceDir);
directoryScanService.startOrResume(taskId, sourceDir.toString());
long errorCount = scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)
.eq(ScanItemEntity::getStatus, ScanItemStatus.ERROR.name()));
long doneCountAfterFirstRun = scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)
.eq(ScanItemEntity::getStatus, ScanItemStatus.DONE.name()));
assertThat(errorCount).isEqualTo(1);
assertThat(doneCountAfterFirstRun).isEqualTo(400);
reset(metadataReaderService, metadataValidatorService, audioDurationService);
when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
File file = invocation.getArgument(0);
return buildValidMetadata(file.getName(), "Artist", "Album");
});
when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
when(audioDurationService.getDurationSeconds(any())).thenReturn(200);
directoryScanService.startOrResume(taskId, sourceDir.toString());
ProcessTaskEntity task = processTaskService.findById(taskId);
assertThat(task.getProcessedCount()).isEqualTo(1000);
assertThat(task.getSuccessCount()).isEqualTo(1000);
assertThat(scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, taskId)
.eq(ScanItemEntity::getStatus, ScanItemStatus.ERROR.name()))).isZero();
assertThat(fileProcessService.count()).isEqualTo(1000);
}
@Test
void shouldSkipArchivedFileWhenRescanned() throws Exception {
Path sourceDir = Files.createDirectory(tempDir.resolve("archived"));
createAudioPlaceholder(sourceDir.resolve("archived-song.mp3"), "archived-content");
stubSuccessfulMetadataPipeline("Archived Song", "Artist", "Album", 240);
Long firstTaskId = createTask(sourceDir);
directoryScanService.startOrResume(firstTaskId, sourceDir.toString());
reset(metadataReaderService, metadataValidatorService, audioDurationService);
Long secondTaskId = createTask(sourceDir);
directoryScanService.startOrResume(secondTaskId, sourceDir.toString());
ProcessTaskEntity secondTask = processTaskService.findById(secondTaskId);
assertThat(secondTask.getDuplicateCount()).isEqualTo(1);
ScanItemEntity scanItem = scanItemService.getOne(new LambdaQueryWrapper<ScanItemEntity>()
.eq(ScanItemEntity::getTaskId, secondTaskId)
.last("LIMIT 1"));
assertThat(scanItem.getStatus()).isEqualTo(ScanItemStatus.SKIPPED.name());
DuplicateFileEntity duplicate = duplicateFileService.getOne(new LambdaQueryWrapper<DuplicateFileEntity>()
.eq(DuplicateFileEntity::getTaskId, secondTaskId)
.last("LIMIT 1"));
assertThat(duplicate.getDuplicateType()).isEqualTo("HASH_DUPLICATE");
verify(metadataReaderService, never()).readMetadata(any());
}
private Long createTask(Path sourceDir) {
ProcessTaskEntity entity = new ProcessTaskEntity();
entity.setTaskName("scan-task-" + System.nanoTime());
entity.setTaskType("DIR_SCAN");
entity.setSourcePath(sourceDir.toString());
entity.setTotalFileCount(0);
entity.setProcessedCount(0);
entity.setSuccessCount(0);
entity.setFailCount(0);
entity.setDuplicateCount(0);
entity.setTaskStatus("PENDING");
entity.setStartTime(LocalDateTime.now());
entity.setCreatedBy("test");
processTaskService.create(entity);
return entity.getId();
}
private Path createAudioPlaceholder(Path file, String content) throws IOException {
return Files.writeString(file, content, StandardCharsets.UTF_8);
}
private void stubSuccessfulMetadataPipeline(String title, String artist, String album, int durationSeconds) {
when(metadataReaderService.readMetadata(any())).thenAnswer(invocation -> {
File file = invocation.getArgument(0);
return buildValidMetadata(file.getName(), title, artist, album);
});
when(metadataValidatorService.validate(any())).thenAnswer(invocation -> MetadataValidationResult.passed(invocation.getArgument(0)));
when(audioDurationService.getDurationSeconds(any())).thenReturn(durationSeconds);
}
private AudioMetadata buildValidMetadata(String title, String artist, String album) {
return buildValidMetadata(title, title, artist, album);
}
private AudioMetadata buildValidMetadata(String snapshotMarker, String title, String artist, String album) {
AudioMetadata metadata = new AudioMetadata();
metadata.setOriginalEncoding("UTF-8");
metadata.setFileFormat("MP3");
metadata.setTitle(title);
metadata.setArtist(artist);
metadata.setAlbumArtist(artist);
metadata.setAlbum(album);
metadata.setTrack("01/01");
metadata.setSnapshotJson("{\"file\":\"" + snapshotMarker + "\"}");
CoverMetadata cover = new CoverMetadata();
cover.setPresent(true);
cover.setFormat("JPG");
cover.setWidth(600);
cover.setHeight(600);
cover.setBinaryData(new byte[]{1, 2, 3});
metadata.setCover(cover);
return metadata;
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,168 @@
package com.music.metadata.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.MetadataValidationResult;
import com.music.metadata.domain.metadata.ValidationFailureType;
import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
import com.music.metadata.infrastructure.audio.AudioTagExtractor;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import com.music.metadata.service.MetadataSnapshotService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MetadataAcceptanceTest {
private final MetadataValidatorServiceImpl validator = new MetadataValidatorServiceImpl();
@TempDir
Path tempDir;
@Test
void shouldReadMetadataFromTestAudioFile() throws Exception {
AudioTagExtractor extractor = mock(AudioTagExtractor.class);
MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
extractionResult.setFileFormat("mp3");
extractionResult.setTitle("Acceptance Song");
extractionResult.setArtist("Acceptance Artist");
extractionResult.setAlbumArtist("Acceptance Artist");
extractionResult.setAlbum("Acceptance Album");
extractionResult.setTrack("01/10");
extractionResult.setCoverMimeType("image/png");
extractionResult.setCoverData(createSquarePng(320));
extractionResult.setFields(Map.of(
"title", List.of("Acceptance Song"),
"artist", List.of("Acceptance Artist")
));
File file = tempDir.resolve("acceptance.mp3").toFile();
assertThat(file.createNewFile()).isTrue();
when(extractor.extract(file)).thenReturn(extractionResult);
MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
AudioMetadata metadata = reader.readMetadata(file);
assertThat(metadata.getTitle()).isEqualTo("Acceptance Song");
assertThat(metadata.getArtist()).isEqualTo("Acceptance Artist");
assertThat(metadata.getAlbumArtist()).isEqualTo("Acceptance Artist");
assertThat(metadata.getAlbum()).isEqualTo("Acceptance Album");
assertThat(metadata.getTrack()).isEqualTo("01/10");
assertThat(metadata.getCover()).isNotNull();
assertThat(metadata.getCover().isPresent()).isTrue();
assertThat(metadata.getSnapshotJson()).contains("Acceptance Song");
ArgumentCaptor<MetadataSnapshotEntity> captor = ArgumentCaptor.forClass(MetadataSnapshotEntity.class);
verify(snapshotService).create(captor.capture());
assertThat(captor.getValue().getSnapshotJson()).contains("Acceptance Song");
}
@Test
void missingTitleShouldReturnMissingFieldFailure() {
AudioMetadata metadata = validMetadata();
metadata.setTitle(null);
MetadataValidationResult result = validator.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures()).hasSize(1);
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.MISSING_FIELD);
assertThat(result.getFailures().get(0).getField()).isEqualTo("title");
}
@Test
void gbkEncodedChineseLabelsShouldRepairOrFailEncodingError() throws Exception {
AudioTagExtractor extractor = mock(AudioTagExtractor.class);
MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
extractionResult.setFileFormat("flac");
extractionResult.setTitle(mojibakeFromGbk("中文标题"));
extractionResult.setArtist(mojibakeFromGbk("中文歌手"));
extractionResult.setAlbumArtist(mojibakeFromGbk("中文歌手"));
extractionResult.setAlbum(mojibakeFromGbk("中文专辑"));
extractionResult.setTrack("01/01");
extractionResult.setCoverMimeType("image/png");
extractionResult.setCoverData(createSquarePng(400));
File file = tempDir.resolve("gbk.flac").toFile();
assertThat(file.createNewFile()).isTrue();
when(extractor.extract(file)).thenReturn(extractionResult);
MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
AudioMetadata metadata = reader.readMetadata(file);
if ("UTF-8".equals(metadata.getOriginalEncoding())) {
assertThat(metadata.getTitle()).isEqualTo("中文标题");
assertThat(metadata.getArtist()).isEqualTo("中文歌手");
assertThat(metadata.getAlbum()).isEqualTo("中文专辑");
assertThat(validator.validate(metadata).isPassed()).isTrue();
} else {
MetadataValidationResult result = validator.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.ENCODING_ERROR);
}
}
@Test
void fileWithoutCoverShouldReturnCoverInvalidFailure() {
AudioMetadata metadata = validMetadata();
metadata.setCover(null);
MetadataValidationResult result = validator.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
}
private AudioMetadata validMetadata() {
AudioMetadata metadata = new AudioMetadata();
metadata.setOriginalEncoding("UTF-8");
metadata.setFileFormat("FLAC");
metadata.setTitle("Song");
metadata.setArtist("Artist");
metadata.setAlbumArtist("Artist");
metadata.setAlbum("Album");
metadata.setTrack("01/01");
com.music.metadata.domain.metadata.CoverMetadata cover = new com.music.metadata.domain.metadata.CoverMetadata();
cover.setPresent(true);
cover.setFormat("PNG");
cover.setWidth(400);
cover.setHeight(400);
cover.setBinaryData(new byte[]{1, 2, 3});
metadata.setCover(cover);
return metadata;
}
private String mojibakeFromGbk(String value) {
return new String(value.getBytes(Charset.forName("GBK")), Charset.forName("ISO-8859-1"));
}
private byte[] createSquarePng(int side) throws Exception {
BufferedImage image = new BufferedImage(side, side, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
}
}

View File

@@ -0,0 +1,108 @@
package com.music.metadata.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
import com.music.metadata.infrastructure.audio.AudioTagExtractor;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import com.music.metadata.service.MetadataSnapshotService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MetadataReaderServiceImplTest {
@TempDir
Path tempDir;
@Test
void readMetadata_shouldBuildSnapshotPersistAndParseCover() throws Exception {
AudioTagExtractor extractor = mock(AudioTagExtractor.class);
MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
when(snapshotService.create(org.mockito.ArgumentMatchers.any(MetadataSnapshotEntity.class))).thenReturn(true);
AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
extractionResult.setFileFormat("flac");
extractionResult.setTitle("song title");
extractionResult.setArtist("artist one & artist two");
extractionResult.setAlbumArtist("album artist");
extractionResult.setAlbum("album name");
extractionResult.setTrack("1/12");
extractionResult.setComment("demo");
extractionResult.setCoverMimeType("image/png");
extractionResult.setCoverData(createSquarePng(320));
extractionResult.setFields(Map.of("title", List.of("song title"), "artist", List.of("artist one & artist two")));
File file = tempDir.resolve("sample.flac").toFile();
assertThat(file.createNewFile()).isTrue();
when(extractor.extract(file)).thenReturn(extractionResult);
MetadataReaderServiceImpl service = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
AudioMetadata metadata = service.readMetadata(file);
assertThat(metadata.getFileFormat()).isEqualTo("FLAC");
assertThat(metadata.getOriginalEncoding()).isEqualTo("UTF-8");
assertThat(metadata.getCover()).isNotNull();
assertThat(metadata.getCover().isPresent()).isTrue();
assertThat(metadata.getCover().getFormat()).isEqualTo("PNG");
assertThat(metadata.getCover().getWidth()).isEqualTo(320);
assertThat(metadata.getCover().getHeight()).isEqualTo(320);
assertThat(metadata.getSnapshotJson()).contains("\"fileFormat\":\"FLAC\"");
assertThat(metadata.getSnapshotJson()).contains("\"title\":\"song title\"");
ArgumentCaptor<MetadataSnapshotEntity> captor = ArgumentCaptor.forClass(MetadataSnapshotEntity.class);
verify(snapshotService).create(captor.capture());
assertThat(captor.getValue().getFileFormat()).isEqualTo("FLAC");
assertThat(captor.getValue().getOriginalEncoding()).isEqualTo("UTF-8");
assertThat(captor.getValue().getSnapshotJson()).isEqualTo(metadata.getSnapshotJson());
}
@Test
void readMetadata_shouldFlagUnknownEncodingForGarbledText() throws Exception {
AudioTagExtractor extractor = mock(AudioTagExtractor.class);
MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
when(snapshotService.create(org.mockito.ArgumentMatchers.any(MetadataSnapshotEntity.class))).thenReturn(true);
AudioTagExtractionResult extractionResult = new AudioTagExtractionResult();
extractionResult.setFileFormat("mp3");
extractionResult.setTitle("Bad <20> Title");
extractionResult.setArtist("Artist");
extractionResult.setAlbumArtist("Artist");
extractionResult.setAlbum("Album");
extractionResult.setTrack("01/01");
File file = tempDir.resolve("broken.mp3").toFile();
assertThat(file.createNewFile()).isTrue();
when(extractor.extract(file)).thenReturn(extractionResult);
MetadataReaderServiceImpl service = new MetadataReaderServiceImpl(extractor, snapshotService, new ObjectMapper());
AudioMetadata metadata = service.readMetadata(file);
assertThat(metadata.getOriginalEncoding()).isIn("UNKNOWN", "UTF-8");
if ("UNKNOWN".equals(metadata.getOriginalEncoding())) {
assertThat(metadata.getSnapshotJson()).contains("\"originalEncoding\":\"UNKNOWN\"");
}
}
private byte[] createSquarePng(int side) throws Exception {
BufferedImage image = new BufferedImage(side, side, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
}
}

View File

@@ -0,0 +1,131 @@
package com.music.metadata.service.impl;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.domain.metadata.CoverMetadata;
import com.music.metadata.domain.metadata.MetadataValidationResult;
import com.music.metadata.domain.metadata.ValidationFailureType;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MetadataValidatorServiceImplTest {
private final MetadataValidatorServiceImpl service = new MetadataValidatorServiceImpl();
@Test
void validate_shouldPassAndCleanMetadata() {
AudioMetadata metadata = validMetadata();
metadata.setTitle(" my song (Live) ");
metadata.setArtist("artist one & artist two");
metadata.setAlbumArtist("the album artist");
metadata.setAlbum(" best hits: deluxe ");
metadata.setTrack("1/12");
metadata.setComment("existing");
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isTrue();
assertThat(result.getFailures()).isEmpty();
assertThat(result.getCleanedMetadata().getTitle()).isEqualTo("My Song");
assertThat(result.getCleanedMetadata().getArtist()).isEqualTo("Artist One; Artist Two");
assertThat(result.getCleanedMetadata().getAlbumArtist()).isEqualTo("The Album Artist");
assertThat(result.getCleanedMetadata().getAlbum()).isEqualTo("Best Hits_ Deluxe");
assertThat(result.getCleanedMetadata().getTrack()).isEqualTo("01/12");
assertThat(result.getCleanedMetadata().getComment()).contains("Existing").contains("(live)");
}
@Test
void validate_shouldFailFastOnEncodingError() {
AudioMetadata metadata = validMetadata();
metadata.setOriginalEncoding("GB18030");
metadata.setTitle(" ");
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures()).hasSize(1);
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.ENCODING_ERROR);
assertThat(result.getFailures().get(0).getField()).isEqualTo("encoding");
}
@Test
void validate_shouldFailOnFirstMissingRequiredField() {
AudioMetadata metadata = validMetadata();
metadata.setTitle(" ");
metadata.setArtist("Artist <20>");
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures()).hasSize(1);
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.MISSING_FIELD);
assertThat(result.getFailures().get(0).getField()).isEqualTo("title");
}
@Test
void validate_shouldFailOnInvalidTrackFormat() {
AudioMetadata metadata = validMetadata();
metadata.setTrack("1");
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.INVALID_FORMAT);
assertThat(result.getFailures().get(0).getField()).isEqualTo("track");
}
@Test
void validate_shouldFailWhenCoverIsTooSmall() {
AudioMetadata metadata = validMetadata();
metadata.getCover().setWidth(299);
metadata.getCover().setHeight(299);
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
}
@Test
void validate_shouldFailWhenCoverIsNotSquare() {
AudioMetadata metadata = validMetadata();
metadata.getCover().setWidth(500);
metadata.getCover().setHeight(300);
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
}
@Test
void validate_shouldFailWhenCoverIsMissing() {
AudioMetadata metadata = validMetadata();
metadata.setCover(null);
MetadataValidationResult result = service.validate(metadata);
assertThat(result.isPassed()).isFalse();
assertThat(result.getFailures().get(0).getType()).isEqualTo(ValidationFailureType.COVER_INVALID);
}
private AudioMetadata validMetadata() {
AudioMetadata metadata = new AudioMetadata();
metadata.setOriginalEncoding("UTF-8");
metadata.setFileFormat("FLAC");
metadata.setTitle("Song");
metadata.setArtist("Artist");
metadata.setAlbumArtist("Artist");
metadata.setAlbum("Album");
metadata.setTrack("01/01");
CoverMetadata cover = new CoverMetadata();
cover.setPresent(true);
cover.setFormat("JPG");
cover.setWidth(600);
cover.setHeight(600);
cover.setBinaryData(new byte[]{1, 2, 3});
metadata.setCover(cover);
return metadata;
}
}

View File

@@ -0,0 +1,84 @@
package com.music.metadata.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.music.metadata.domain.metadata.AudioMetadata;
import com.music.metadata.infrastructure.audio.JaudiotaggerAudioTagExtractor;
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
import com.music.metadata.service.MetadataSnapshotService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class RealAudioMetadataIntegrationTest {
private static final Path DEFAULT_REAL_AUDIO_DIR = Path.of("/home/liujingjing/下载/解压后2");
@TempDir
Path tempDir;
@Test
void shouldReadMetadataFromRealAudioFile() throws Exception {
Path realAudioDir = resolveRealAudioDir();
assumeTrue(Files.isDirectory(realAudioDir), () -> "Real audio directory not found: " + realAudioDir);
Path sourceFile = findFirstFlac(realAudioDir);
assumeTrue(sourceFile != null, () -> "No FLAC file found under " + realAudioDir);
Path testFile = tempDir.resolve(sourceFile.getFileName().toString());
Files.copy(sourceFile, testFile);
MetadataSnapshotService snapshotService = mock(MetadataSnapshotService.class);
when(snapshotService.create(any(MetadataSnapshotEntity.class))).thenReturn(true);
MetadataReaderServiceImpl reader = new MetadataReaderServiceImpl(
new JaudiotaggerAudioTagExtractor(),
snapshotService,
new ObjectMapper()
);
AudioMetadata metadata = reader.readMetadata(testFile.toFile());
assertThat(metadata.getFileFormat()).isEqualTo("FLAC");
assertThat(metadata.getTitle()).isNotBlank();
assertThat(metadata.getArtist()).isNotBlank();
assertThat(metadata.getAlbum()).isNotBlank();
assertThat(metadata.getSnapshotJson()).contains(metadata.getTitle());
assertThat(metadata.getTagFields()).isNotEmpty();
assertThat(metadata.getCover()).isNotNull();
assertThat(metadata.getCover().isPresent()).isTrue();
assertThat(metadata.getCover().getBinaryData()).isNotNull().isNotEmpty();
assertThat(metadata.getCover().getWidth()).isGreaterThanOrEqualTo(300);
assertThat(metadata.getCover().getHeight()).isGreaterThanOrEqualTo(300);
}
private Path resolveRealAudioDir() {
String override = System.getenv("REAL_AUDIO_DIR");
if (override != null && !override.isBlank()) {
return Path.of(override);
}
return DEFAULT_REAL_AUDIO_DIR;
}
private Path findFirstFlac(Path root) throws IOException {
try (Stream<Path> stream = Files.walk(root)) {
return stream
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase().endsWith(".flac"))
.sorted(Comparator.comparing(Path::toString))
.findFirst()
.orElse(null);
}
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,18 @@
spring:
datasource:
url: jdbc:h2:mem:music_metadata_test;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always
schema-locations: classpath:schema.sql
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
logging:
level:
root: WARN

View File

@@ -0,0 +1,31 @@
Test audio fixture guidance
The current unit tests avoid committing real audio binaries and instead use `byte[]` plus a mocked `AudioTagExtractor`.
Recommended real-file fixtures if you later want integration tests:
1. `valid-square-cover.flac`
- Tags: `title`, `artist`, `album_artist`, `album`, `track=01/12`
- Embedded PNG cover `320x320`
- UTF-8 encoded text
2. `missing-cover.mp3`
- Same core tags as above
- No embedded artwork
- Expected validator result: `COVER_MISSING`
3. `bad-track.m4a`
- Track stored as `1` instead of `01/12`
- Expected validator result: `INVALID_FORMAT`
4. `garbled-title.ogg`
- Title intentionally stored with broken encoding bytes so it decodes to `Bad <20> Title`
- Expected reader encoding result: `UNKNOWN`
To generate those fixtures quickly, ffmpeg plus a tagging tool is enough:
```bash
ffmpeg -f lavfi -i sine=frequency=440:duration=1 -c:a flac valid.flac
```
Then embed tags and artwork with a dedicated tag editor, or continue mocking `AudioTagExtractor` in unit tests for faster coverage.