feat: add metadata validation and scan acceptance support
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package com.music.metadata.domain.metadata;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Normalized in-memory representation of metadata read from an audio file.
|
||||
*/
|
||||
public class AudioMetadata {
|
||||
|
||||
private String fileFormat;
|
||||
private String originalEncoding;
|
||||
private String snapshotJson;
|
||||
private String title;
|
||||
private String artist;
|
||||
private String albumArtist;
|
||||
private String album;
|
||||
private String track;
|
||||
private String year;
|
||||
private String genre;
|
||||
private String lyrics;
|
||||
private String disc;
|
||||
private String comment;
|
||||
private CoverMetadata cover;
|
||||
private Map<String, List<String>> tagFields = new LinkedHashMap<>();
|
||||
|
||||
public String getFileFormat() {
|
||||
return fileFormat;
|
||||
}
|
||||
|
||||
public void setFileFormat(String fileFormat) {
|
||||
this.fileFormat = fileFormat;
|
||||
}
|
||||
|
||||
public String getOriginalEncoding() {
|
||||
return originalEncoding;
|
||||
}
|
||||
|
||||
public void setOriginalEncoding(String originalEncoding) {
|
||||
this.originalEncoding = originalEncoding;
|
||||
}
|
||||
|
||||
public String getSnapshotJson() {
|
||||
return snapshotJson;
|
||||
}
|
||||
|
||||
public void setSnapshotJson(String snapshotJson) {
|
||||
this.snapshotJson = snapshotJson;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getArtist() {
|
||||
return artist;
|
||||
}
|
||||
|
||||
public void setArtist(String artist) {
|
||||
this.artist = artist;
|
||||
}
|
||||
|
||||
public String getAlbumArtist() {
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
public void setAlbumArtist(String albumArtist) {
|
||||
this.albumArtist = albumArtist;
|
||||
}
|
||||
|
||||
public String getAlbum() {
|
||||
return album;
|
||||
}
|
||||
|
||||
public void setAlbum(String album) {
|
||||
this.album = album;
|
||||
}
|
||||
|
||||
public String getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
public void setTrack(String track) {
|
||||
this.track = track;
|
||||
}
|
||||
|
||||
public String getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(String year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public String getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(String genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public String getLyrics() {
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
public void setLyrics(String lyrics) {
|
||||
this.lyrics = lyrics;
|
||||
}
|
||||
|
||||
public String getDisc() {
|
||||
return disc;
|
||||
}
|
||||
|
||||
public void setDisc(String disc) {
|
||||
this.disc = disc;
|
||||
}
|
||||
|
||||
public String getComment() {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public void setComment(String comment) {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public CoverMetadata getCover() {
|
||||
return cover;
|
||||
}
|
||||
|
||||
public void setCover(CoverMetadata cover) {
|
||||
this.cover = cover;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getTagFields() {
|
||||
return tagFields;
|
||||
}
|
||||
|
||||
public void setTagFields(Map<String, List<String>> tagFields) {
|
||||
this.tagFields = tagFields;
|
||||
}
|
||||
|
||||
public void putTagField(String key, String value) {
|
||||
tagFields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.music.metadata.domain.metadata;
|
||||
|
||||
/**
|
||||
* Album artwork metadata extracted from an audio file.
|
||||
*/
|
||||
public class CoverMetadata {
|
||||
|
||||
private boolean present;
|
||||
private String mimeType;
|
||||
private String format;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
private Long size;
|
||||
private byte[] binaryData;
|
||||
|
||||
public boolean isPresent() {
|
||||
return present;
|
||||
}
|
||||
|
||||
public void setPresent(boolean present) {
|
||||
this.present = present;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public void setMimeType(String mimeType) {
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public void setFormat(String format) {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public Integer getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public void setWidth(Integer width) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
public Integer getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public void setHeight(Integer height) {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public Long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(Long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public byte[] getBinaryData() {
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
public void setBinaryData(byte[] binaryData) {
|
||||
this.binaryData = binaryData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.music.metadata.domain.metadata;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result returned by the one-vote veto validator.
|
||||
*/
|
||||
public class MetadataValidationResult {
|
||||
|
||||
private final boolean passed;
|
||||
private final List<ValidationFailure> failures;
|
||||
private final AudioMetadata cleanedMetadata;
|
||||
|
||||
private MetadataValidationResult(boolean passed, List<ValidationFailure> failures, AudioMetadata cleanedMetadata) {
|
||||
this.passed = passed;
|
||||
this.failures = failures;
|
||||
this.cleanedMetadata = cleanedMetadata;
|
||||
}
|
||||
|
||||
public static MetadataValidationResult passed(AudioMetadata cleanedMetadata) {
|
||||
return new MetadataValidationResult(true, Collections.emptyList(), cleanedMetadata);
|
||||
}
|
||||
|
||||
public static MetadataValidationResult failed(ValidationFailure failure, AudioMetadata cleanedMetadata) {
|
||||
List<ValidationFailure> failures = new ArrayList<>();
|
||||
failures.add(failure);
|
||||
return new MetadataValidationResult(false, failures, cleanedMetadata);
|
||||
}
|
||||
|
||||
public boolean isPassed() {
|
||||
return passed;
|
||||
}
|
||||
|
||||
public List<ValidationFailure> getFailures() {
|
||||
return failures;
|
||||
}
|
||||
|
||||
public AudioMetadata getCleanedMetadata() {
|
||||
return cleanedMetadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.music.metadata.domain.metadata;
|
||||
|
||||
/**
|
||||
* One concrete validation failure reason.
|
||||
*/
|
||||
public class ValidationFailure {
|
||||
|
||||
private final ValidationFailureType type;
|
||||
private final String field;
|
||||
private final String message;
|
||||
|
||||
public ValidationFailure(ValidationFailureType type, String field, String message) {
|
||||
this.type = type;
|
||||
this.field = field;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public ValidationFailureType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.music.metadata.domain.metadata;
|
||||
|
||||
/**
|
||||
* Structured failure codes used by the one-vote veto validator.
|
||||
*/
|
||||
public enum ValidationFailureType {
|
||||
MISSING_FIELD,
|
||||
ENCODING_ERROR,
|
||||
INVALID_FORMAT,
|
||||
COVER_INVALID,
|
||||
GARBLED_TEXT
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum DuplicateType {
|
||||
HASH_DUPLICATE,
|
||||
LEVEL2_SUSPECT
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
import com.music.metadata.infrastructure.entity.FileProcessEntity;
|
||||
|
||||
public class FeatureDeduplicationHit {
|
||||
|
||||
private final String dedupKey;
|
||||
private final FileProcessEntity canonicalFile;
|
||||
|
||||
public FeatureDeduplicationHit(String dedupKey, FileProcessEntity canonicalFile) {
|
||||
this.dedupKey = dedupKey;
|
||||
this.canonicalFile = canonicalFile;
|
||||
}
|
||||
|
||||
public String getDedupKey() {
|
||||
return dedupKey;
|
||||
}
|
||||
|
||||
public FileProcessEntity getCanonicalFile() {
|
||||
return canonicalFile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum ScanItemStatus {
|
||||
DISCOVERED,
|
||||
PROCESSING,
|
||||
DONE,
|
||||
SKIPPED,
|
||||
ERROR
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.domain.scan;
|
||||
|
||||
public enum ScanProcessingOutcome {
|
||||
SUCCESS,
|
||||
HASH_DUPLICATE,
|
||||
LEVEL2_SUSPECT,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.music.metadata.infrastructure.audio;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Raw extraction result from the metadata library before business validation.
|
||||
*/
|
||||
public class AudioTagExtractionResult {
|
||||
|
||||
private String fileFormat;
|
||||
private String title;
|
||||
private String artist;
|
||||
private String albumArtist;
|
||||
private String album;
|
||||
private String track;
|
||||
private String year;
|
||||
private String genre;
|
||||
private String lyrics;
|
||||
private String disc;
|
||||
private String comment;
|
||||
private byte[] coverData;
|
||||
private String coverMimeType;
|
||||
private Map<String, List<String>> fields = new LinkedHashMap<>();
|
||||
|
||||
public String getFileFormat() {
|
||||
return fileFormat;
|
||||
}
|
||||
|
||||
public void setFileFormat(String fileFormat) {
|
||||
this.fileFormat = fileFormat;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getArtist() {
|
||||
return artist;
|
||||
}
|
||||
|
||||
public void setArtist(String artist) {
|
||||
this.artist = artist;
|
||||
}
|
||||
|
||||
public String getAlbumArtist() {
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
public void setAlbumArtist(String albumArtist) {
|
||||
this.albumArtist = albumArtist;
|
||||
}
|
||||
|
||||
public String getAlbum() {
|
||||
return album;
|
||||
}
|
||||
|
||||
public void setAlbum(String album) {
|
||||
this.album = album;
|
||||
}
|
||||
|
||||
public String getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
public void setTrack(String track) {
|
||||
this.track = track;
|
||||
}
|
||||
|
||||
public String getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(String year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public String getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(String genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public String getLyrics() {
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
public void setLyrics(String lyrics) {
|
||||
this.lyrics = lyrics;
|
||||
}
|
||||
|
||||
public String getDisc() {
|
||||
return disc;
|
||||
}
|
||||
|
||||
public void setDisc(String disc) {
|
||||
this.disc = disc;
|
||||
}
|
||||
|
||||
public String getComment() {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public void setComment(String comment) {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public byte[] getCoverData() {
|
||||
return coverData;
|
||||
}
|
||||
|
||||
public void setCoverData(byte[] coverData) {
|
||||
this.coverData = coverData;
|
||||
}
|
||||
|
||||
public String getCoverMimeType() {
|
||||
return coverMimeType;
|
||||
}
|
||||
|
||||
public void setCoverMimeType(String coverMimeType) {
|
||||
this.coverMimeType = coverMimeType;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getFields() {
|
||||
return fields;
|
||||
}
|
||||
|
||||
public void setFields(Map<String, List<String>> fields) {
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
public void addField(String key, String value) {
|
||||
fields.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.music.metadata.infrastructure.audio;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Abstraction around the metadata library to keep reader tests isolated.
|
||||
*/
|
||||
public interface AudioTagExtractor {
|
||||
|
||||
AudioTagExtractionResult extract(File file);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.music.metadata.infrastructure.audio;
|
||||
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.jaudiotagger.tag.TagField;
|
||||
import org.jaudiotagger.tag.images.Artwork;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Production extractor backed by jaudiotagger.
|
||||
*/
|
||||
@Component
|
||||
public class JaudiotaggerAudioTagExtractor implements AudioTagExtractor {
|
||||
|
||||
@Override
|
||||
public AudioTagExtractionResult extract(File file) {
|
||||
try {
|
||||
AudioFile audioFile = AudioFileIO.read(file);
|
||||
Tag tag = audioFile.getTag();
|
||||
|
||||
AudioTagExtractionResult result = new AudioTagExtractionResult();
|
||||
result.setFileFormat(audioFile.getExt());
|
||||
|
||||
if (tag == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setTitle(readFirst(tag, FieldKey.TITLE));
|
||||
result.setArtist(readFirst(tag, FieldKey.ARTIST));
|
||||
result.setAlbumArtist(readFirst(tag, FieldKey.ALBUM_ARTIST));
|
||||
result.setAlbum(readFirst(tag, FieldKey.ALBUM));
|
||||
result.setTrack(readTrack(tag));
|
||||
result.setYear(readFirst(tag, FieldKey.YEAR));
|
||||
result.setGenre(readFirst(tag, FieldKey.GENRE));
|
||||
result.setLyrics(readFirst(tag, FieldKey.LYRICS));
|
||||
result.setDisc(readDisc(tag));
|
||||
result.setComment(readFirst(tag, FieldKey.COMMENT));
|
||||
|
||||
for (FieldKey fieldKey : FieldKey.values()) {
|
||||
try {
|
||||
List<String> values = tag.getAll(fieldKey);
|
||||
if (values == null || values.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isEmpty()) {
|
||||
result.addField(fieldKey.name().toLowerCase(), value);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Some libraries throw while materializing special fields such as artwork-backed values.
|
||||
// Best-effort extraction is enough here because core fields are read explicitly above.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Iterator<TagField> iterator = tag.getFields();
|
||||
while (iterator.hasNext()) {
|
||||
TagField field = iterator.next();
|
||||
try {
|
||||
String key = field.getId();
|
||||
String value = field.toString();
|
||||
if (key != null && !key.isBlank() && value != null && !value.isBlank()) {
|
||||
result.addField(key, value);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Ignore malformed/special fields and keep extracting the rest.
|
||||
}
|
||||
}
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Some tag implementations do not expose a stable field iterator. FieldKey values remain available.
|
||||
}
|
||||
|
||||
try {
|
||||
Artwork artwork = tag.getFirstArtwork();
|
||||
if (artwork != null) {
|
||||
result.setCoverData(artwork.getBinaryData());
|
||||
result.setCoverMimeType(artwork.getMimeType());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Some FLAC files expose artwork metadata but Jaudiotagger cannot materialize it.
|
||||
// Keep the text tags readable and let downstream cover validation decide the result.
|
||||
}
|
||||
return result;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("Failed to read audio metadata: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String readTrack(Tag tag) throws Exception {
|
||||
String track = readFirst(tag, FieldKey.TRACK);
|
||||
String trackTotal = readFirst(tag, FieldKey.TRACK_TOTAL);
|
||||
if (track == null || track.isBlank()) {
|
||||
return trackTotal;
|
||||
}
|
||||
if (track.contains("/")) {
|
||||
return track;
|
||||
}
|
||||
if (trackTotal == null || trackTotal.isBlank()) {
|
||||
return track;
|
||||
}
|
||||
return track + "/" + trackTotal;
|
||||
}
|
||||
|
||||
private String readDisc(Tag tag) throws Exception {
|
||||
String disc = readFirst(tag, FieldKey.DISC_NO);
|
||||
String discTotal = readFirst(tag, FieldKey.DISC_TOTAL);
|
||||
if (disc == null || disc.isBlank()) {
|
||||
return discTotal;
|
||||
}
|
||||
if (disc.contains("/")) {
|
||||
return disc;
|
||||
}
|
||||
if (discTotal == null || discTotal.isBlank()) {
|
||||
return disc;
|
||||
}
|
||||
return disc + "/" + discTotal;
|
||||
}
|
||||
|
||||
private String readFirst(Tag tag, FieldKey key) throws Exception {
|
||||
String value = tag.getFirst(key);
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.music.metadata.infrastructure.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("t_duplicate_file")
|
||||
public class DuplicateFileEntity implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("task_id")
|
||||
private Long taskId;
|
||||
|
||||
@TableField("file_hash")
|
||||
private String fileHash;
|
||||
|
||||
@TableField("source_file_path")
|
||||
private String sourceFilePath;
|
||||
|
||||
@TableField("source_file_name")
|
||||
private String sourceFileName;
|
||||
|
||||
@TableField("duplicate_type")
|
||||
private String duplicateType;
|
||||
|
||||
@TableField("canonical_file_process_id")
|
||||
private Long canonicalFileProcessId;
|
||||
|
||||
@TableField("detail_json")
|
||||
private String detailJson;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(Long taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getFileHash() {
|
||||
return fileHash;
|
||||
}
|
||||
|
||||
public void setFileHash(String fileHash) {
|
||||
this.fileHash = fileHash;
|
||||
}
|
||||
|
||||
public String getSourceFilePath() {
|
||||
return sourceFilePath;
|
||||
}
|
||||
|
||||
public void setSourceFilePath(String sourceFilePath) {
|
||||
this.sourceFilePath = sourceFilePath;
|
||||
}
|
||||
|
||||
public String getSourceFileName() {
|
||||
return sourceFileName;
|
||||
}
|
||||
|
||||
public void setSourceFileName(String sourceFileName) {
|
||||
this.sourceFileName = sourceFileName;
|
||||
}
|
||||
|
||||
public String getDuplicateType() {
|
||||
return duplicateType;
|
||||
}
|
||||
|
||||
public void setDuplicateType(String duplicateType) {
|
||||
this.duplicateType = duplicateType;
|
||||
}
|
||||
|
||||
public Long getCanonicalFileProcessId() {
|
||||
return canonicalFileProcessId;
|
||||
}
|
||||
|
||||
public void setCanonicalFileProcessId(Long canonicalFileProcessId) {
|
||||
this.canonicalFileProcessId = canonicalFileProcessId;
|
||||
}
|
||||
|
||||
public String getDetailJson() {
|
||||
return detailJson;
|
||||
}
|
||||
|
||||
public void setDetailJson(String detailJson) {
|
||||
this.detailJson = detailJson;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ public class FileProcessEntity implements Serializable {
|
||||
@TableField("target_file_path")
|
||||
private String targetFilePath;
|
||||
|
||||
@TableField("dedup_key")
|
||||
private String dedupKey;
|
||||
|
||||
@TableField("task_id")
|
||||
private Long taskId;
|
||||
|
||||
@@ -141,6 +144,14 @@ public class FileProcessEntity implements Serializable {
|
||||
this.targetFilePath = targetFilePath;
|
||||
}
|
||||
|
||||
public String getDedupKey() {
|
||||
return dedupKey;
|
||||
}
|
||||
|
||||
public void setDedupKey(String dedupKey) {
|
||||
this.dedupKey = dedupKey;
|
||||
}
|
||||
|
||||
public Long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.music.metadata.infrastructure.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Stores the immutable raw metadata snapshot used for traceability and rollback.
|
||||
*/
|
||||
@TableName("t_metadata_snapshot")
|
||||
public class MetadataSnapshotEntity implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("source_file_path")
|
||||
private String sourceFilePath;
|
||||
|
||||
@TableField("file_name")
|
||||
private String fileName;
|
||||
|
||||
@TableField("file_format")
|
||||
private String fileFormat;
|
||||
|
||||
@TableField("original_encoding")
|
||||
private String originalEncoding;
|
||||
|
||||
@TableField("snapshot_json")
|
||||
private String snapshotJson;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getSourceFilePath() {
|
||||
return sourceFilePath;
|
||||
}
|
||||
|
||||
public void setSourceFilePath(String sourceFilePath) {
|
||||
this.sourceFilePath = sourceFilePath;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFileFormat() {
|
||||
return fileFormat;
|
||||
}
|
||||
|
||||
public void setFileFormat(String fileFormat) {
|
||||
this.fileFormat = fileFormat;
|
||||
}
|
||||
|
||||
public String getOriginalEncoding() {
|
||||
return originalEncoding;
|
||||
}
|
||||
|
||||
public void setOriginalEncoding(String originalEncoding) {
|
||||
this.originalEncoding = originalEncoding;
|
||||
}
|
||||
|
||||
public String getSnapshotJson() {
|
||||
return snapshotJson;
|
||||
}
|
||||
|
||||
public void setSnapshotJson(String snapshotJson) {
|
||||
this.snapshotJson = snapshotJson;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.music.metadata.infrastructure.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("t_scan_item")
|
||||
public class ScanItemEntity implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("task_id")
|
||||
private Long taskId;
|
||||
|
||||
@TableField("source_file_path")
|
||||
private String sourceFilePath;
|
||||
|
||||
@TableField("status")
|
||||
private String status;
|
||||
|
||||
@TableField("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(Long taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getSourceFilePath() {
|
||||
return sourceFilePath;
|
||||
}
|
||||
|
||||
public void setSourceFilePath(String sourceFilePath) {
|
||||
this.sourceFilePath = sourceFilePath;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.infrastructure.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DuplicateFileMapper extends BaseMapper<DuplicateFileEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.infrastructure.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MetadataSnapshotMapper extends BaseMapper<MetadataSnapshotEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.infrastructure.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ScanItemMapper extends BaseMapper<ScanItemEntity> {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public interface AudioDurationService {
|
||||
|
||||
Integer getDurationSeconds(File file);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
|
||||
import com.music.metadata.infrastructure.entity.FileProcessEntity;
|
||||
|
||||
public interface DeduplicationService {
|
||||
|
||||
FileProcessEntity findArchivedByHash(String fileHash);
|
||||
|
||||
FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration);
|
||||
|
||||
String buildDedupKey(AudioMetadata metadata, Integer audioDuration);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
public interface DirectoryScanService {
|
||||
|
||||
void startOrResume(Long taskId, String sourcePath);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface DuplicateFileService extends IService<DuplicateFileEntity> {
|
||||
|
||||
boolean create(DuplicateFileEntity entity);
|
||||
|
||||
List<DuplicateFileEntity> findAll();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public interface FileHashService {
|
||||
|
||||
String sha256(File file);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public interface MetadataReaderService {
|
||||
|
||||
AudioMetadata readMetadata(File file);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
|
||||
|
||||
public interface MetadataSnapshotService extends IService<MetadataSnapshotEntity> {
|
||||
|
||||
boolean create(MetadataSnapshotEntity entity);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.metadata.MetadataValidationResult;
|
||||
|
||||
public interface MetadataValidatorService {
|
||||
|
||||
MetadataValidationResult validate(AudioMetadata metadata);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.music.metadata.domain.scan.ScanProcessingOutcome;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
|
||||
public interface ScanFileProcessor {
|
||||
|
||||
ScanProcessingOutcome process(Long taskId, ScanItemEntity scanItemEntity);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.music.metadata.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ScanItemService extends IService<ScanItemEntity> {
|
||||
|
||||
boolean create(ScanItemEntity entity);
|
||||
|
||||
boolean update(ScanItemEntity entity);
|
||||
|
||||
List<ScanItemEntity> findAll();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
|
||||
import com.music.metadata.infrastructure.entity.FileProcessEntity;
|
||||
import com.music.metadata.service.DeduplicationService;
|
||||
import com.music.metadata.service.FileHashService;
|
||||
import com.music.metadata.service.FileProcessService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class DeduplicationServiceImpl implements DeduplicationService {
|
||||
|
||||
private final FileProcessService fileProcessService;
|
||||
|
||||
public DeduplicationServiceImpl(FileProcessService fileProcessService) {
|
||||
this.fileProcessService = fileProcessService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileProcessEntity findArchivedByHash(String fileHash) {
|
||||
return fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
|
||||
.eq(FileProcessEntity::getFileHash, fileHash)
|
||||
.eq(FileProcessEntity::getProcessStatus, "SUCCESS")
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeatureDeduplicationHit findLevel2Duplicate(AudioMetadata metadata, Integer audioDuration) {
|
||||
String dedupKey = buildDedupKey(metadata, audioDuration);
|
||||
FileProcessEntity canonical = fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
|
||||
.eq(FileProcessEntity::getDedupKey, dedupKey)
|
||||
.eq(FileProcessEntity::getProcessStatus, "SUCCESS")
|
||||
.last("LIMIT 1"));
|
||||
if (canonical == null) {
|
||||
return null;
|
||||
}
|
||||
return new FeatureDeduplicationHit(dedupKey, canonical);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildDedupKey(AudioMetadata metadata, Integer audioDuration) {
|
||||
String artist = normalize(metadata.getArtist());
|
||||
String title = normalize(metadata.getTitle());
|
||||
String album = normalize(metadata.getAlbum());
|
||||
String duration = audioDuration == null ? "0" : String.valueOf(audioDuration);
|
||||
return sha256(artist + "|" + title + "|" + album + "|" + duration);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.strip().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String sha256(String input) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte value : hash) {
|
||||
builder.append(String.format("%02x", value));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to create deduplication key", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import com.music.metadata.domain.scan.ScanItemStatus;
|
||||
import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
import com.music.metadata.service.DirectoryScanService;
|
||||
import com.music.metadata.service.ProcessTaskService;
|
||||
import com.music.metadata.service.ScanFileProcessor;
|
||||
import com.music.metadata.service.ScanItemService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class DirectoryScanServiceImpl implements DirectoryScanService {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "flac", "m4a", "ogg", "wav");
|
||||
|
||||
private final ScanItemService scanItemService;
|
||||
private final ProcessTaskService processTaskService;
|
||||
private final ScanFileProcessor scanFileProcessor;
|
||||
|
||||
public DirectoryScanServiceImpl(ScanItemService scanItemService,
|
||||
ProcessTaskService processTaskService,
|
||||
ScanFileProcessor scanFileProcessor) {
|
||||
this.scanItemService = scanItemService;
|
||||
this.processTaskService = processTaskService;
|
||||
this.scanFileProcessor = scanFileProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startOrResume(Long taskId, String sourcePath) {
|
||||
ProcessTaskEntity existingTask = processTaskService.findById(taskId);
|
||||
if (existingTask == null) {
|
||||
throw new BusinessException("Process task not found: " + taskId);
|
||||
}
|
||||
|
||||
discoverFiles(taskId, sourcePath);
|
||||
ProcessTaskEntity runningTask = processTaskService.findById(taskId);
|
||||
runningTask.setTaskStatus("RUNNING");
|
||||
processTaskService.update(runningTask);
|
||||
|
||||
List<ScanItemEntity> pendingItems = scanItemService.list(new LambdaQueryWrapper<ScanItemEntity>()
|
||||
.eq(ScanItemEntity::getTaskId, taskId)
|
||||
.in(ScanItemEntity::getStatus,
|
||||
ScanItemStatus.DISCOVERED.name(),
|
||||
ScanItemStatus.ERROR.name(),
|
||||
ScanItemStatus.PROCESSING.name())
|
||||
.orderByAsc(ScanItemEntity::getId));
|
||||
|
||||
boolean interrupted = false;
|
||||
for (ScanItemEntity pendingItem : pendingItems) {
|
||||
pendingItem.setStatus(ScanItemStatus.PROCESSING.name());
|
||||
pendingItem.setUpdatedAt(LocalDateTime.now());
|
||||
scanItemService.update(pendingItem);
|
||||
|
||||
try {
|
||||
switch (scanFileProcessor.process(taskId, pendingItem)) {
|
||||
case SUCCESS, LEVEL2_SUSPECT, FAILED -> pendingItem.setStatus(ScanItemStatus.DONE.name());
|
||||
case HASH_DUPLICATE, SKIPPED -> pendingItem.setStatus(ScanItemStatus.SKIPPED.name());
|
||||
default -> pendingItem.setStatus(ScanItemStatus.ERROR.name());
|
||||
}
|
||||
pendingItem.setErrorMessage(null);
|
||||
} catch (Exception exception) {
|
||||
pendingItem.setStatus(ScanItemStatus.ERROR.name());
|
||||
pendingItem.setErrorMessage(exception.getMessage());
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
pendingItem.setUpdatedAt(LocalDateTime.now());
|
||||
scanItemService.update(pendingItem);
|
||||
|
||||
if (interrupted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ProcessTaskEntity finishedTask = processTaskService.findById(taskId);
|
||||
finishedTask.setTaskStatus(interrupted ? "ERROR" : "FINISHED");
|
||||
finishedTask.setEndTime(LocalDateTime.now());
|
||||
processTaskService.update(finishedTask);
|
||||
}
|
||||
|
||||
private void discoverFiles(Long taskId, String sourcePath) {
|
||||
Path path = Paths.get(sourcePath);
|
||||
if (!Files.exists(path)) {
|
||||
throw new BusinessException("Source path does not exist: " + sourcePath);
|
||||
}
|
||||
|
||||
try (Stream<Path> stream = Files.walk(path)) {
|
||||
stream.filter(Files::isRegularFile)
|
||||
.filter(this::isSupportedAudioFile)
|
||||
.forEach(filePath -> ensureScanItemExists(taskId, filePath));
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("Failed to scan source path: " + exception.getMessage());
|
||||
}
|
||||
|
||||
ProcessTaskEntity task = processTaskService.findById(taskId);
|
||||
task.setTotalFileCount((int) scanItemService.count(new LambdaQueryWrapper<ScanItemEntity>()
|
||||
.eq(ScanItemEntity::getTaskId, taskId)));
|
||||
processTaskService.update(task);
|
||||
}
|
||||
|
||||
private void ensureScanItemExists(Long taskId, Path filePath) {
|
||||
String absolutePath = filePath.toAbsolutePath().toString();
|
||||
ScanItemEntity existing = scanItemService.getOne(new LambdaQueryWrapper<ScanItemEntity>()
|
||||
.eq(ScanItemEntity::getTaskId, taskId)
|
||||
.eq(ScanItemEntity::getSourceFilePath, absolutePath)
|
||||
.last("LIMIT 1"));
|
||||
if (existing != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScanItemEntity entity = new ScanItemEntity();
|
||||
entity.setTaskId(taskId);
|
||||
entity.setSourceFilePath(absolutePath);
|
||||
entity.setStatus(ScanItemStatus.DISCOVERED.name());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
scanItemService.create(entity);
|
||||
}
|
||||
|
||||
private boolean isSupportedAudioFile(Path path) {
|
||||
String fileName = path.getFileName().toString();
|
||||
int index = fileName.lastIndexOf('.');
|
||||
if (index < 0 || index == fileName.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
return SUPPORTED_EXTENSIONS.contains(fileName.substring(index + 1).toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
|
||||
import com.music.metadata.infrastructure.mapper.DuplicateFileMapper;
|
||||
import com.music.metadata.service.DuplicateFileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class DuplicateFileServiceImpl extends ServiceImpl<DuplicateFileMapper, DuplicateFileEntity>
|
||||
implements DuplicateFileService {
|
||||
|
||||
@Override
|
||||
public boolean create(DuplicateFileEntity entity) {
|
||||
return this.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DuplicateFileEntity> findAll() {
|
||||
return this.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import com.music.metadata.service.AudioDurationService;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@Service
|
||||
public class JaudiotaggerAudioDurationServiceImpl implements AudioDurationService {
|
||||
|
||||
@Override
|
||||
public Integer getDurationSeconds(File file) {
|
||||
try {
|
||||
AudioFile audioFile = AudioFileIO.read(file);
|
||||
return audioFile.getAudioHeader() == null ? null : audioFile.getAudioHeader().getTrackLength();
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("Failed to read audio duration: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.metadata.CoverMetadata;
|
||||
import com.music.metadata.infrastructure.audio.AudioTagExtractionResult;
|
||||
import com.music.metadata.infrastructure.audio.AudioTagExtractor;
|
||||
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
|
||||
import com.music.metadata.service.MetadataReaderService;
|
||||
import com.music.metadata.service.MetadataSnapshotService;
|
||||
import org.mozilla.universalchardet.UniversalDetector;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reads audio metadata, generates a raw JSON snapshot, and stores it for traceability.
|
||||
*/
|
||||
@Service
|
||||
public class MetadataReaderServiceImpl implements MetadataReaderService {
|
||||
|
||||
private final AudioTagExtractor audioTagExtractor;
|
||||
private final MetadataSnapshotService metadataSnapshotService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MetadataReaderServiceImpl(AudioTagExtractor audioTagExtractor,
|
||||
MetadataSnapshotService metadataSnapshotService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.audioTagExtractor = audioTagExtractor;
|
||||
this.metadataSnapshotService = metadataSnapshotService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioMetadata readMetadata(File file) {
|
||||
// The extraction layer is intentionally isolated so that reader tests can focus on
|
||||
// encoding detection, snapshot generation, and cover parsing without requiring real audio fixtures.
|
||||
AudioTagExtractionResult extractionResult = audioTagExtractor.extract(file);
|
||||
|
||||
AudioMetadata metadata = new AudioMetadata();
|
||||
metadata.setFileFormat(normalizeFormat(extractionResult.getFileFormat(), file));
|
||||
metadata.setTitle(extractionResult.getTitle());
|
||||
metadata.setArtist(extractionResult.getArtist());
|
||||
metadata.setAlbumArtist(extractionResult.getAlbumArtist());
|
||||
metadata.setAlbum(extractionResult.getAlbum());
|
||||
metadata.setTrack(extractionResult.getTrack());
|
||||
metadata.setYear(extractionResult.getYear());
|
||||
metadata.setGenre(extractionResult.getGenre());
|
||||
metadata.setLyrics(extractionResult.getLyrics());
|
||||
metadata.setDisc(extractionResult.getDisc());
|
||||
metadata.setComment(extractionResult.getComment());
|
||||
metadata.setTagFields(copyFields(extractionResult.getFields()));
|
||||
metadata.setCover(buildCoverMetadata(extractionResult));
|
||||
metadata.setOriginalEncoding(detectOriginalEncoding(metadata));
|
||||
boolean repaired = attemptEncodingRepair(metadata);
|
||||
if (repaired) {
|
||||
metadata.setOriginalEncoding("UTF-8");
|
||||
}
|
||||
|
||||
String snapshotJson = buildSnapshotJson(file, metadata);
|
||||
metadata.setSnapshotJson(snapshotJson);
|
||||
persistSnapshot(file, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Map<String, List<String>> copyFields(Map<String, List<String>> sourceFields) {
|
||||
Map<String, List<String>> copied = new LinkedHashMap<>();
|
||||
sourceFields.forEach((key, values) -> copied.put(key, new ArrayList<>(values)));
|
||||
return copied;
|
||||
}
|
||||
|
||||
private CoverMetadata buildCoverMetadata(AudioTagExtractionResult extractionResult) {
|
||||
// Cover metadata is parsed here once so the validator can enforce size, aspect ratio,
|
||||
// and allowed format rules without reopening the image bytes.
|
||||
CoverMetadata coverMetadata = new CoverMetadata();
|
||||
byte[] coverData = extractionResult.getCoverData();
|
||||
if (coverData == null || coverData.length == 0) {
|
||||
coverMetadata.setPresent(false);
|
||||
return coverMetadata;
|
||||
}
|
||||
|
||||
coverMetadata.setPresent(true);
|
||||
coverMetadata.setBinaryData(coverData);
|
||||
coverMetadata.setSize((long) coverData.length);
|
||||
coverMetadata.setMimeType(extractionResult.getCoverMimeType());
|
||||
coverMetadata.setFormat(resolveImageFormat(extractionResult.getCoverMimeType()));
|
||||
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(coverData));
|
||||
if (image != null) {
|
||||
coverMetadata.setWidth(image.getWidth());
|
||||
coverMetadata.setHeight(image.getHeight());
|
||||
if (coverMetadata.getFormat() == null) {
|
||||
coverMetadata.setFormat(resolveImageFormat(image));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Validation will treat unreadable artwork as an invalid cover format later.
|
||||
}
|
||||
return coverMetadata;
|
||||
}
|
||||
|
||||
private String resolveImageFormat(BufferedImage image) {
|
||||
return image.getColorModel().hasAlpha() ? "PNG" : null;
|
||||
}
|
||||
|
||||
private String resolveImageFormat(String mimeType) {
|
||||
if (mimeType == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = mimeType.toLowerCase(Locale.ROOT);
|
||||
if (normalized.contains("jpeg") || normalized.contains("jpg")) {
|
||||
return "JPG";
|
||||
}
|
||||
if (normalized.contains("png")) {
|
||||
return "PNG";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeFormat(String extractedFormat, File file) {
|
||||
if (extractedFormat != null && !extractedFormat.isBlank()) {
|
||||
return extractedFormat.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
String name = file.getName();
|
||||
int dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex < 0 || dotIndex == name.length() - 1) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
return name.substring(dotIndex + 1).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String detectOriginalEncoding(AudioMetadata metadata) {
|
||||
// The document requires a strict UTF-8 gate. We therefore scan the core text fields and
|
||||
// return the first non-UTF-8 signal so the validator can short-circuit immediately.
|
||||
List<String> values = new ArrayList<>();
|
||||
values.add(metadata.getTitle());
|
||||
values.add(metadata.getArtist());
|
||||
values.add(metadata.getAlbumArtist());
|
||||
values.add(metadata.getAlbum());
|
||||
values.add(metadata.getTrack());
|
||||
values.add(metadata.getGenre());
|
||||
values.add(metadata.getLyrics());
|
||||
values.add(metadata.getComment());
|
||||
for (String value : values) {
|
||||
String detected = detectEncoding(value);
|
||||
if (!"UTF-8".equals(detected)) {
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
return "UTF-8";
|
||||
}
|
||||
|
||||
private boolean attemptEncodingRepair(AudioMetadata metadata) {
|
||||
String encoding = metadata.getOriginalEncoding();
|
||||
if (encoding == null || encoding.isBlank() || "UTF-8".equalsIgnoreCase(encoding)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String repairedTitle = attemptRepair(metadata.getTitle(), encoding);
|
||||
String repairedArtist = attemptRepair(metadata.getArtist(), encoding);
|
||||
String repairedAlbumArtist = attemptRepair(metadata.getAlbumArtist(), encoding);
|
||||
String repairedAlbum = attemptRepair(metadata.getAlbum(), encoding);
|
||||
String repairedGenre = attemptRepair(metadata.getGenre(), encoding);
|
||||
String repairedLyrics = attemptRepair(metadata.getLyrics(), encoding);
|
||||
String repairedComment = attemptRepair(metadata.getComment(), encoding);
|
||||
|
||||
if (repairSucceeded(metadata.getTitle(), repairedTitle)
|
||||
|| repairSucceeded(metadata.getArtist(), repairedArtist)
|
||||
|| repairSucceeded(metadata.getAlbumArtist(), repairedAlbumArtist)
|
||||
|| repairSucceeded(metadata.getAlbum(), repairedAlbum)
|
||||
|| repairSucceeded(metadata.getGenre(), repairedGenre)
|
||||
|| repairSucceeded(metadata.getLyrics(), repairedLyrics)
|
||||
|| repairSucceeded(metadata.getComment(), repairedComment)) {
|
||||
metadata.setTitle(repairedTitle);
|
||||
metadata.setArtist(repairedArtist);
|
||||
metadata.setAlbumArtist(repairedAlbumArtist);
|
||||
metadata.setAlbum(repairedAlbum);
|
||||
metadata.setGenre(repairedGenre);
|
||||
metadata.setLyrics(repairedLyrics);
|
||||
metadata.setComment(repairedComment);
|
||||
metadata.setTagFields(repairTagFields(metadata.getTagFields(), encoding));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Map<String, List<String>> repairTagFields(Map<String, List<String>> tagFields, String encoding) {
|
||||
Map<String, List<String>> repaired = new LinkedHashMap<>();
|
||||
tagFields.forEach((key, values) -> {
|
||||
List<String> repairedValues = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
repairedValues.add(attemptRepair(value, encoding));
|
||||
}
|
||||
repaired.put(key, repairedValues);
|
||||
});
|
||||
return repaired;
|
||||
}
|
||||
|
||||
private boolean repairSucceeded(String original, String repaired) {
|
||||
return original != null
|
||||
&& repaired != null
|
||||
&& !original.equals(repaired)
|
||||
&& isLikelyCleanText(repaired)
|
||||
&& scoreText(repaired) > scoreText(original);
|
||||
}
|
||||
|
||||
private String attemptRepair(String value, String encoding) {
|
||||
if (value == null || value.isBlank() || !isLikelyMojibake(value)) {
|
||||
return value;
|
||||
}
|
||||
boolean chineseMojibake = looksLikeMainlandChineseMojibake(value);
|
||||
Set<String> candidateCharsets = new java.util.LinkedHashSet<>();
|
||||
if (chineseMojibake) {
|
||||
candidateCharsets.add("GB18030");
|
||||
candidateCharsets.add("GBK");
|
||||
candidateCharsets.add(encoding);
|
||||
} else {
|
||||
candidateCharsets.add(encoding);
|
||||
candidateCharsets.add("BIG5");
|
||||
candidateCharsets.add("SHIFT_JIS");
|
||||
}
|
||||
|
||||
String bestCandidate = value;
|
||||
int bestScore = scoreText(value);
|
||||
try {
|
||||
byte[] originalBytes = value.getBytes(StandardCharsets.ISO_8859_1);
|
||||
for (String candidateCharset : candidateCharsets) {
|
||||
if (!Charset.isSupported(candidateCharset)) {
|
||||
continue;
|
||||
}
|
||||
String repaired = new String(originalBytes, Charset.forName(candidateCharset));
|
||||
int candidateScore = scoreText(repaired);
|
||||
if (candidateScore > bestScore) {
|
||||
bestScore = candidateScore;
|
||||
bestCandidate = repaired;
|
||||
}
|
||||
}
|
||||
if (chineseMojibake && countCjkCharacters(bestCandidate) == 0) {
|
||||
return value;
|
||||
}
|
||||
return bestCandidate;
|
||||
} catch (Exception ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectEncoding(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "UTF-8";
|
||||
}
|
||||
if (value.contains("\uFFFD") || value.contains("<EFBFBD>")) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
if (looksLikeMainlandChineseMojibake(value)) {
|
||||
return "GBK";
|
||||
}
|
||||
|
||||
byte[] candidateBytes = value.getBytes(StandardCharsets.ISO_8859_1);
|
||||
UniversalDetector detector = new UniversalDetector(null);
|
||||
detector.handleData(candidateBytes, 0, candidateBytes.length);
|
||||
detector.dataEnd();
|
||||
String detected = detector.getDetectedCharset();
|
||||
detector.reset();
|
||||
|
||||
if (detected == null) {
|
||||
return isLikelyCleanText(value) ? "UTF-8" : "UNKNOWN";
|
||||
}
|
||||
String normalized = detected.toUpperCase(Locale.ROOT);
|
||||
if (!isLikelyCleanText(value)) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
if ("US-ASCII".equals(normalized) || "ASCII".equals(normalized)) {
|
||||
return "UTF-8";
|
||||
}
|
||||
if (normalized.startsWith("UTF")) {
|
||||
return "UTF-8";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean isLikelyCleanText(String value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
String lowerCase = value.toLowerCase(Locale.ROOT);
|
||||
return !(lowerCase.indexOf('ã') >= 0 || lowerCase.indexOf('â') >= 0 || lowerCase.contains("<EFBFBD>") || hasHalfWidthKatakana(value));
|
||||
}
|
||||
|
||||
private int scoreText(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
int score = 0;
|
||||
for (char current : value.toCharArray()) {
|
||||
Character.UnicodeBlock block = Character.UnicodeBlock.of(current);
|
||||
if (isCjkBlock(block)) {
|
||||
score += 6;
|
||||
} else if (Character.isLetterOrDigit(current)) {
|
||||
score += 2;
|
||||
} else if (Character.isWhitespace(current) || isCommonPunctuation(current)) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (current == '<27>') {
|
||||
score -= 10;
|
||||
}
|
||||
if (block == Character.UnicodeBlock.BOX_DRAWING
|
||||
|| block == Character.UnicodeBlock.BLOCK_ELEMENTS
|
||||
|| block == Character.UnicodeBlock.GEOMETRIC_SHAPES) {
|
||||
score -= 6;
|
||||
}
|
||||
}
|
||||
if (!isLikelyCleanText(value)) {
|
||||
score -= 8;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
private boolean isCjkBlock(Character.UnicodeBlock block) {
|
||||
return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|
||||
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|
||||
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
|
||||
|| block == Character.UnicodeBlock.HIRAGANA
|
||||
|| block == Character.UnicodeBlock.KATAKANA
|
||||
|| block == Character.UnicodeBlock.HANGUL_SYLLABLES;
|
||||
}
|
||||
|
||||
private boolean isCommonPunctuation(char current) {
|
||||
return ",.;:!?()[]{}-_/\\'\"&+".indexOf(current) >= 0;
|
||||
}
|
||||
|
||||
private int countCjkCharacters(String value) {
|
||||
int count = 0;
|
||||
for (char current : value.toCharArray()) {
|
||||
if (isCjkBlock(Character.UnicodeBlock.of(current))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private boolean hasHalfWidthKatakana(String value) {
|
||||
for (char current : value.toCharArray()) {
|
||||
if (current >= '\uFF61' && current <= '\uFF9F') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isLikelyMojibake(String value) {
|
||||
boolean hasSuspiciousMarker = value.contains("Ã") || value.contains("â") || value.contains("Ö")
|
||||
|| value.contains("Î") || value.contains("Ê") || value.contains("<EFBFBD>") || looksLikeMainlandChineseMojibake(value);
|
||||
if (hasSuspiciousMarker) {
|
||||
return true;
|
||||
}
|
||||
for (char current : value.toCharArray()) {
|
||||
if (current > 0x00FF) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean looksLikeMainlandChineseMojibake(String value) {
|
||||
return value.contains("Ö") || value.contains("Ð") || value.contains("Î") || value.contains("Ä")
|
||||
|| value.contains("Ê") || value.contains("Ì") || value.contains("Æ") || value.contains("¨");
|
||||
}
|
||||
|
||||
private String buildSnapshotJson(File file, AudioMetadata metadata) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("fileName", file.getName());
|
||||
snapshot.put("filePath", file.getAbsolutePath());
|
||||
snapshot.put("fileFormat", metadata.getFileFormat());
|
||||
snapshot.put("originalEncoding", metadata.getOriginalEncoding());
|
||||
snapshot.put("fields", metadata.getTagFields());
|
||||
Map<String, Object> core = new LinkedHashMap<>();
|
||||
core.put("title", metadata.getTitle());
|
||||
core.put("artist", metadata.getArtist());
|
||||
core.put("albumArtist", metadata.getAlbumArtist());
|
||||
core.put("album", metadata.getAlbum());
|
||||
core.put("track", metadata.getTrack());
|
||||
core.put("year", metadata.getYear());
|
||||
core.put("genre", metadata.getGenre());
|
||||
core.put("lyrics", metadata.getLyrics());
|
||||
core.put("disc", metadata.getDisc());
|
||||
core.put("comment", metadata.getComment());
|
||||
snapshot.put("core", core);
|
||||
snapshot.put("cover", buildCoverSnapshot(metadata.getCover()));
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(snapshot);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BusinessException("Failed to serialize metadata snapshot: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildCoverSnapshot(CoverMetadata coverMetadata) {
|
||||
if (coverMetadata == null) {
|
||||
return Map.of("present", false);
|
||||
}
|
||||
Map<String, Object> cover = new LinkedHashMap<>();
|
||||
cover.put("present", coverMetadata.isPresent());
|
||||
cover.put("mimeType", coverMetadata.getMimeType());
|
||||
cover.put("format", coverMetadata.getFormat());
|
||||
cover.put("width", coverMetadata.getWidth());
|
||||
cover.put("height", coverMetadata.getHeight());
|
||||
cover.put("size", coverMetadata.getSize());
|
||||
return cover;
|
||||
}
|
||||
|
||||
private void persistSnapshot(File file, AudioMetadata metadata) {
|
||||
MetadataSnapshotEntity entity = new MetadataSnapshotEntity();
|
||||
entity.setSourceFilePath(file.getAbsolutePath());
|
||||
entity.setFileName(file.getName());
|
||||
entity.setFileFormat(metadata.getFileFormat());
|
||||
entity.setOriginalEncoding(metadata.getOriginalEncoding());
|
||||
entity.setSnapshotJson(metadata.getSnapshotJson());
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
metadataSnapshotService.create(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.music.metadata.infrastructure.entity.MetadataSnapshotEntity;
|
||||
import com.music.metadata.infrastructure.mapper.MetadataSnapshotMapper;
|
||||
import com.music.metadata.service.MetadataSnapshotService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MetadataSnapshotServiceImpl extends ServiceImpl<MetadataSnapshotMapper, MetadataSnapshotEntity>
|
||||
implements MetadataSnapshotService {
|
||||
|
||||
@Override
|
||||
public boolean create(MetadataSnapshotEntity entity) {
|
||||
return this.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.metadata.CoverMetadata;
|
||||
import com.music.metadata.domain.metadata.MetadataValidationResult;
|
||||
import com.music.metadata.domain.metadata.ValidationFailure;
|
||||
import com.music.metadata.domain.metadata.ValidationFailureType;
|
||||
import com.music.metadata.service.MetadataValidatorService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Implements the document-defined one-vote veto validation strategy.
|
||||
* Any core-field failure causes an immediate stop and returns exactly one failure reason.
|
||||
*/
|
||||
@Service
|
||||
public class MetadataValidatorServiceImpl implements MetadataValidatorService {
|
||||
|
||||
private static final Pattern TRACK_PATTERN = Pattern.compile("^(\\d{1,3})/(\\d{1,3})$");
|
||||
private static final Pattern VERSION_PATTERN = Pattern.compile("\\s*([\\[(](?i:(live|remix|instrumental|伴奏|karaoke|acoustic|demo))[\\w\\s.-]*[\\])])\\s*");
|
||||
private static final Pattern INVISIBLE_PATTERN = Pattern.compile("[\\p{Cntrl}&&[^\\r\\n\\t]]|[\\u200B-\\u200D\\uFEFF]");
|
||||
private static final Pattern INVALID_FILE_NAME_CHARS = Pattern.compile("[\\\\/:*?\"<>|]");
|
||||
private static final Pattern FEAT_SEPARATOR_PATTERN = Pattern.compile("(?i)\\s+(feat\\.?|ft\\.?)\\s+");
|
||||
private static final List<String> ARTICLE_WORDS = List.of("a", "an", "the");
|
||||
|
||||
@Override
|
||||
public MetadataValidationResult validate(AudioMetadata metadata) {
|
||||
AudioMetadata cleaned = deepCopy(metadata);
|
||||
// Clean first, then validate in the exact mandatory order from the document.
|
||||
// The first failure returns immediately to implement the one-vote veto rule.
|
||||
normalizeOptionalFields(cleaned);
|
||||
|
||||
ValidationFailure failure = validateEncoding(cleaned);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateRequiredTextField("title", cleaned.getTitle(), cleaned::setTitle);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateRequiredTextField("artist", cleaned.getArtist(), cleaned::setArtist);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateRequiredTextField("album_artist", cleaned.getAlbumArtist(), cleaned::setAlbumArtist);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateRequiredTextField("album", cleaned.getAlbum(), cleaned::setAlbum);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateTrack(cleaned);
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
failure = validateCover(cleaned.getCover());
|
||||
if (failure != null) {
|
||||
return MetadataValidationResult.failed(failure, cleaned);
|
||||
}
|
||||
|
||||
return MetadataValidationResult.passed(cleaned);
|
||||
}
|
||||
|
||||
private void normalizeOptionalFields(AudioMetadata metadata) {
|
||||
metadata.setTitle(cleanField(metadata.getTitle(), false));
|
||||
metadata.setArtist(cleanField(metadata.getArtist(), true));
|
||||
metadata.setAlbumArtist(cleanField(metadata.getAlbumArtist(), true));
|
||||
metadata.setAlbum(cleanField(metadata.getAlbum(), false));
|
||||
metadata.setGenre(cleanField(metadata.getGenre(), true));
|
||||
metadata.setLyrics(cleanField(metadata.getLyrics(), false));
|
||||
metadata.setDisc(cleanTrackLikeField(metadata.getDisc()));
|
||||
metadata.setComment(cleanField(metadata.getComment(), false));
|
||||
normalizeTitleVersionInfo(metadata);
|
||||
normalizeTagFields(metadata);
|
||||
}
|
||||
|
||||
private ValidationFailure validateEncoding(AudioMetadata metadata) {
|
||||
String encoding = metadata.getOriginalEncoding();
|
||||
if (encoding == null || encoding.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = encoding.toUpperCase(Locale.ROOT);
|
||||
if ("UTF-8".equals(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return new ValidationFailure(ValidationFailureType.ENCODING_ERROR, "encoding",
|
||||
"Metadata encoding must be UTF-8, but detected " + normalized);
|
||||
}
|
||||
|
||||
private ValidationFailure validateRequiredTextField(String fieldName,
|
||||
String value,
|
||||
java.util.function.Consumer<String> consumer) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
|
||||
fieldName + " is required");
|
||||
}
|
||||
|
||||
String cleanedValue = cleanField(value, "artist".equals(fieldName) || "album_artist".equals(fieldName));
|
||||
consumer.accept(cleanedValue);
|
||||
|
||||
if (cleanedValue.isBlank()) {
|
||||
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, fieldName,
|
||||
fieldName + " is required");
|
||||
}
|
||||
if (containsGarbledText(cleanedValue)) {
|
||||
return new ValidationFailure(ValidationFailureType.GARBLED_TEXT, fieldName,
|
||||
fieldName + " contains garbled characters");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ValidationFailure validateTrack(AudioMetadata metadata) {
|
||||
String track = cleanTrackLikeField(metadata.getTrack());
|
||||
metadata.setTrack(track);
|
||||
if (track == null || track.isBlank()) {
|
||||
return new ValidationFailure(ValidationFailureType.MISSING_FIELD, "track", "track is required");
|
||||
}
|
||||
|
||||
Matcher matcher = TRACK_PATTERN.matcher(track);
|
||||
if (!matcher.matches()) {
|
||||
return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
|
||||
"track must match '序号/总曲目数', e.g. 01/12");
|
||||
}
|
||||
|
||||
int current = Integer.parseInt(matcher.group(1));
|
||||
int total = Integer.parseInt(matcher.group(2));
|
||||
if (current <= 0 || total <= 0 || current > total) {
|
||||
return new ValidationFailure(ValidationFailureType.INVALID_FORMAT, "track",
|
||||
"track numbers must be positive and current track cannot exceed total tracks");
|
||||
}
|
||||
metadata.setTrack(String.format(Locale.ROOT, "%02d/%02d", current, total));
|
||||
return null;
|
||||
}
|
||||
|
||||
private ValidationFailure validateCover(CoverMetadata coverMetadata) {
|
||||
if (coverMetadata == null || !coverMetadata.isPresent() || coverMetadata.getBinaryData() == null
|
||||
|| coverMetadata.getBinaryData().length == 0) {
|
||||
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover", "embedded cover is required");
|
||||
}
|
||||
String format = coverMetadata.getFormat();
|
||||
if (format == null || !("JPG".equalsIgnoreCase(format) || "PNG".equalsIgnoreCase(format))) {
|
||||
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
|
||||
"cover format must be JPG or PNG");
|
||||
}
|
||||
Integer width = coverMetadata.getWidth();
|
||||
Integer height = coverMetadata.getHeight();
|
||||
if (width == null || height == null) {
|
||||
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
|
||||
"cover image metadata cannot be parsed");
|
||||
}
|
||||
if (width < 300 || height < 300) {
|
||||
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
|
||||
"cover dimensions must be at least 300x300");
|
||||
}
|
||||
if (!width.equals(height)) {
|
||||
return new ValidationFailure(ValidationFailureType.COVER_INVALID, "cover",
|
||||
"cover aspect ratio must be 1:1");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void normalizeTitleVersionInfo(AudioMetadata metadata) {
|
||||
String title = metadata.getTitle();
|
||||
if (title == null || title.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Matcher matcher = VERSION_PATTERN.matcher(title);
|
||||
if (!matcher.find()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String versionInfo = matcher.group(1).trim();
|
||||
String cleanedTitle = matcher.replaceAll(" ").trim();
|
||||
// Version labels should not pollute the canonical title because they break matching and
|
||||
// archive naming; they are preserved in comment to avoid losing the information.
|
||||
metadata.setTitle(cleanedTitle);
|
||||
if (metadata.getComment() == null || metadata.getComment().isBlank()) {
|
||||
metadata.setComment(versionInfo);
|
||||
} else if (!metadata.getComment().contains(versionInfo)) {
|
||||
metadata.setComment(metadata.getComment() + "; " + versionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private void normalizeTagFields(AudioMetadata metadata) {
|
||||
Map<String, List<String>> normalized = new LinkedHashMap<>();
|
||||
metadata.getTagFields().forEach((key, values) -> {
|
||||
java.util.ArrayList<String> cleanedValues = new java.util.ArrayList<>();
|
||||
for (String value : values) {
|
||||
String cleanedValue = cleanField(value, "artist".equalsIgnoreCase(key) || "album_artist".equalsIgnoreCase(key)
|
||||
|| "genre".equalsIgnoreCase(key));
|
||||
if (cleanedValue != null && !cleanedValue.isBlank()) {
|
||||
cleanedValues.add(cleanedValue);
|
||||
}
|
||||
}
|
||||
if (!cleanedValues.isEmpty()) {
|
||||
normalized.put(key, cleanedValues);
|
||||
}
|
||||
});
|
||||
metadata.setTagFields(normalized);
|
||||
}
|
||||
|
||||
private String cleanField(String value, boolean normalizeSeparators) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = value.strip();
|
||||
cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
|
||||
cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
|
||||
cleaned = INVALID_FILE_NAME_CHARS.matcher(cleaned).replaceAll("_");
|
||||
cleaned = cleaned.replaceAll("\\s+", " ").trim();
|
||||
if (normalizeSeparators) {
|
||||
cleaned = normalizeSeparators(cleaned);
|
||||
}
|
||||
cleaned = normalizeEnglishCase(cleaned);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String cleanTrackLikeField(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = value.strip();
|
||||
cleaned = cleaned.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
|
||||
cleaned = INVISIBLE_PATTERN.matcher(cleaned).replaceAll("");
|
||||
cleaned = cleaned.replaceAll("\\s+", "");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String normalizeSeparators(String value) {
|
||||
String normalized = FEAT_SEPARATOR_PATTERN.matcher(value).replaceAll("; ");
|
||||
normalized = normalized.replaceAll("\\s*&\\s*", "; ");
|
||||
normalized = normalized.replaceAll("\\s+/\\s+", "; ");
|
||||
normalized = normalized.replaceAll(";\\s*;", "; ");
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
private String normalizeEnglishCase(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
String[] words = value.split(" ");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < words.length; index++) {
|
||||
if (index > 0) {
|
||||
builder.append(' ');
|
||||
}
|
||||
builder.append(normalizeWord(words[index], index == 0));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String normalizeWord(String word, boolean firstWord) {
|
||||
if (!word.matches(".*[A-Za-z].*")) {
|
||||
return word;
|
||||
}
|
||||
|
||||
String[] tokens = word.split("(?=[/-])|(?<=[/-])");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String token : tokens) {
|
||||
if (token.equals("/") || token.equals("-")) {
|
||||
builder.append(token);
|
||||
continue;
|
||||
}
|
||||
String lower = token.toLowerCase(Locale.ROOT);
|
||||
if (!firstWord && ARTICLE_WORDS.contains(lower)) {
|
||||
builder.append(lower);
|
||||
} else {
|
||||
builder.append(Character.toUpperCase(lower.charAt(0))).append(lower.substring(1));
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private boolean containsGarbledText(String value) {
|
||||
return value.contains("<EFBFBD>") || value.contains("\uFFFD") || value.contains("Ã") || value.contains("â");
|
||||
}
|
||||
|
||||
private AudioMetadata deepCopy(AudioMetadata source) {
|
||||
AudioMetadata copy = new AudioMetadata();
|
||||
copy.setFileFormat(source.getFileFormat());
|
||||
copy.setOriginalEncoding(source.getOriginalEncoding());
|
||||
copy.setSnapshotJson(source.getSnapshotJson());
|
||||
copy.setTitle(source.getTitle());
|
||||
copy.setArtist(source.getArtist());
|
||||
copy.setAlbumArtist(source.getAlbumArtist());
|
||||
copy.setAlbum(source.getAlbum());
|
||||
copy.setTrack(source.getTrack());
|
||||
copy.setYear(source.getYear());
|
||||
copy.setGenre(source.getGenre());
|
||||
copy.setLyrics(source.getLyrics());
|
||||
copy.setDisc(source.getDisc());
|
||||
copy.setComment(source.getComment());
|
||||
copy.setTagFields(new LinkedHashMap<>(source.getTagFields()));
|
||||
|
||||
if (source.getCover() != null) {
|
||||
CoverMetadata coverCopy = new CoverMetadata();
|
||||
coverCopy.setPresent(source.getCover().isPresent());
|
||||
coverCopy.setMimeType(source.getCover().getMimeType());
|
||||
coverCopy.setFormat(source.getCover().getFormat());
|
||||
coverCopy.setWidth(source.getCover().getWidth());
|
||||
coverCopy.setHeight(source.getCover().getHeight());
|
||||
coverCopy.setSize(source.getCover().getSize());
|
||||
coverCopy.setBinaryData(source.getCover().getBinaryData());
|
||||
copy.setCover(coverCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import com.music.metadata.domain.metadata.AudioMetadata;
|
||||
import com.music.metadata.domain.metadata.MetadataValidationResult;
|
||||
import com.music.metadata.domain.scan.DuplicateType;
|
||||
import com.music.metadata.domain.scan.FeatureDeduplicationHit;
|
||||
import com.music.metadata.domain.scan.ScanProcessingOutcome;
|
||||
import com.music.metadata.infrastructure.entity.DuplicateFileEntity;
|
||||
import com.music.metadata.infrastructure.entity.FileProcessEntity;
|
||||
import com.music.metadata.infrastructure.entity.ProcessTaskEntity;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
import com.music.metadata.service.AudioDurationService;
|
||||
import com.music.metadata.service.DeduplicationService;
|
||||
import com.music.metadata.service.DuplicateFileService;
|
||||
import com.music.metadata.service.FileHashService;
|
||||
import com.music.metadata.service.FileProcessService;
|
||||
import com.music.metadata.service.MetadataReaderService;
|
||||
import com.music.metadata.service.MetadataValidatorService;
|
||||
import com.music.metadata.service.ProcessTaskService;
|
||||
import com.music.metadata.service.ScanFileProcessor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ScanFileProcessorImpl implements ScanFileProcessor {
|
||||
|
||||
private final FileHashService fileHashService;
|
||||
private final MetadataReaderService metadataReaderService;
|
||||
private final MetadataValidatorService metadataValidatorService;
|
||||
private final AudioDurationService audioDurationService;
|
||||
private final DeduplicationService deduplicationService;
|
||||
private final FileProcessService fileProcessService;
|
||||
private final DuplicateFileService duplicateFileService;
|
||||
private final ProcessTaskService processTaskService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ScanFileProcessorImpl(FileHashService fileHashService,
|
||||
MetadataReaderService metadataReaderService,
|
||||
MetadataValidatorService metadataValidatorService,
|
||||
AudioDurationService audioDurationService,
|
||||
DeduplicationService deduplicationService,
|
||||
FileProcessService fileProcessService,
|
||||
DuplicateFileService duplicateFileService,
|
||||
ProcessTaskService processTaskService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.fileHashService = fileHashService;
|
||||
this.metadataReaderService = metadataReaderService;
|
||||
this.metadataValidatorService = metadataValidatorService;
|
||||
this.audioDurationService = audioDurationService;
|
||||
this.deduplicationService = deduplicationService;
|
||||
this.fileProcessService = fileProcessService;
|
||||
this.duplicateFileService = duplicateFileService;
|
||||
this.processTaskService = processTaskService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScanProcessingOutcome process(Long taskId, ScanItemEntity scanItemEntity) {
|
||||
File file = new File(scanItemEntity.getSourceFilePath());
|
||||
String fileHash = fileHashService.sha256(file);
|
||||
|
||||
FileProcessEntity archived = deduplicationService.findArchivedByHash(fileHash);
|
||||
if (archived != null) {
|
||||
createDuplicateRecord(taskId, file, fileHash, DuplicateType.HASH_DUPLICATE, archived.getId(),
|
||||
Map.of("reason", "Archived file scanned again; skipped immediately"));
|
||||
incrementDuplicateCount(taskId);
|
||||
return ScanProcessingOutcome.HASH_DUPLICATE;
|
||||
}
|
||||
|
||||
AudioMetadata metadata = metadataReaderService.readMetadata(file);
|
||||
MetadataValidationResult validationResult = metadataValidatorService.validate(metadata);
|
||||
if (!validationResult.isPassed()) {
|
||||
createFailedProcessRecord(taskId, file, fileHash, metadata.getSnapshotJson(), validationResult.getFailures().get(0).getMessage());
|
||||
incrementFailCount(taskId);
|
||||
return ScanProcessingOutcome.FAILED;
|
||||
}
|
||||
|
||||
Integer durationSeconds = audioDurationService.getDurationSeconds(file);
|
||||
AudioMetadata cleanedMetadata = validationResult.getCleanedMetadata();
|
||||
FeatureDeduplicationHit level2Hit = deduplicationService.findLevel2Duplicate(cleanedMetadata, durationSeconds);
|
||||
if (level2Hit != null) {
|
||||
createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
|
||||
level2Hit.getDedupKey(), "SUSPECT_DUPLICATE");
|
||||
createDuplicateRecord(taskId, file, fileHash, DuplicateType.LEVEL2_SUSPECT, level2Hit.getCanonicalFile().getId(),
|
||||
Map.of(
|
||||
"reason", "Matched level-2 deduplication key",
|
||||
"dedupKey", level2Hit.getDedupKey(),
|
||||
"canonicalFileHash", level2Hit.getCanonicalFile().getFileHash()
|
||||
));
|
||||
incrementDuplicateCount(taskId);
|
||||
return ScanProcessingOutcome.LEVEL2_SUSPECT;
|
||||
}
|
||||
|
||||
createSuccessfulProcessRecord(taskId, file, fileHash, cleanedMetadata.getSnapshotJson(), durationSeconds,
|
||||
deduplicationService.buildDedupKey(cleanedMetadata, durationSeconds), "SUCCESS");
|
||||
incrementSuccessCount(taskId);
|
||||
return ScanProcessingOutcome.SUCCESS;
|
||||
}
|
||||
|
||||
private void createSuccessfulProcessRecord(Long taskId,
|
||||
File file,
|
||||
String fileHash,
|
||||
String rawMetadata,
|
||||
Integer durationSeconds,
|
||||
String dedupKey,
|
||||
String status) {
|
||||
FileProcessEntity entity = new FileProcessEntity();
|
||||
entity.setFileHash(fileHash);
|
||||
entity.setSourceFilePath(file.getAbsolutePath());
|
||||
entity.setSourceFileName(file.getName());
|
||||
entity.setFileExtension(resolveExtension(file));
|
||||
entity.setFileSize(file.length());
|
||||
entity.setAudioDuration(durationSeconds);
|
||||
entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
|
||||
entity.setProcessStatus(status);
|
||||
entity.setDedupKey(dedupKey);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
fileProcessService.create(entity);
|
||||
}
|
||||
|
||||
private void createFailedProcessRecord(Long taskId, File file, String fileHash, String rawMetadata, String failReason) {
|
||||
if (fileProcessService.getOne(new LambdaQueryWrapper<FileProcessEntity>()
|
||||
.eq(FileProcessEntity::getFileHash, fileHash)
|
||||
.last("LIMIT 1")) != null) {
|
||||
return;
|
||||
}
|
||||
FileProcessEntity entity = new FileProcessEntity();
|
||||
entity.setFileHash(fileHash);
|
||||
entity.setSourceFilePath(file.getAbsolutePath());
|
||||
entity.setSourceFileName(file.getName());
|
||||
entity.setFileExtension(resolveExtension(file));
|
||||
entity.setFileSize(file.length());
|
||||
entity.setRawMetadata(rawMetadata == null ? "{}" : rawMetadata);
|
||||
entity.setProcessStatus("FAIL");
|
||||
entity.setFailReason(failReason);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
fileProcessService.create(entity);
|
||||
}
|
||||
|
||||
private void createDuplicateRecord(Long taskId,
|
||||
File file,
|
||||
String fileHash,
|
||||
DuplicateType duplicateType,
|
||||
Long canonicalFileProcessId,
|
||||
Map<String, Object> detail) {
|
||||
DuplicateFileEntity entity = new DuplicateFileEntity();
|
||||
entity.setTaskId(taskId);
|
||||
entity.setFileHash(fileHash);
|
||||
entity.setSourceFilePath(file.getAbsolutePath());
|
||||
entity.setSourceFileName(file.getName());
|
||||
entity.setDuplicateType(duplicateType.name());
|
||||
entity.setCanonicalFileProcessId(canonicalFileProcessId);
|
||||
entity.setDetailJson(writeJson(detail));
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
duplicateFileService.create(entity);
|
||||
}
|
||||
|
||||
private void incrementSuccessCount(Long taskId) {
|
||||
ProcessTaskEntity task = loadTask(taskId);
|
||||
task.setSuccessCount(defaultZero(task.getSuccessCount()) + 1);
|
||||
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
|
||||
processTaskService.update(task);
|
||||
}
|
||||
|
||||
private void incrementFailCount(Long taskId) {
|
||||
ProcessTaskEntity task = loadTask(taskId);
|
||||
task.setFailCount(defaultZero(task.getFailCount()) + 1);
|
||||
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
|
||||
processTaskService.update(task);
|
||||
}
|
||||
|
||||
private void incrementDuplicateCount(Long taskId) {
|
||||
ProcessTaskEntity task = loadTask(taskId);
|
||||
task.setDuplicateCount(defaultZero(task.getDuplicateCount()) + 1);
|
||||
task.setProcessedCount(defaultZero(task.getProcessedCount()) + 1);
|
||||
processTaskService.update(task);
|
||||
}
|
||||
|
||||
private ProcessTaskEntity loadTask(Long taskId) {
|
||||
ProcessTaskEntity task = processTaskService.findById(taskId);
|
||||
if (task == null) {
|
||||
throw new BusinessException("Process task not found: " + taskId);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private int defaultZero(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private String writeJson(Map<String, Object> detail) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(detail);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("Failed to serialize duplicate detail: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveExtension(File file) {
|
||||
String fileName = file.getName();
|
||||
int index = fileName.lastIndexOf('.');
|
||||
return index < 0 ? "" : fileName.substring(index + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.music.metadata.infrastructure.entity.ScanItemEntity;
|
||||
import com.music.metadata.infrastructure.mapper.ScanItemMapper;
|
||||
import com.music.metadata.service.ScanItemService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ScanItemServiceImpl extends ServiceImpl<ScanItemMapper, ScanItemEntity> implements ScanItemService {
|
||||
|
||||
@Override
|
||||
public boolean create(ScanItemEntity entity) {
|
||||
return this.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(ScanItemEntity entity) {
|
||||
return this.updateById(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ScanItemEntity> findAll() {
|
||||
return this.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.music.metadata.service.impl;
|
||||
|
||||
import com.music.metadata.common.exception.BusinessException;
|
||||
import com.music.metadata.service.FileHashService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@Service
|
||||
public class Sha256FileHashServiceImpl implements FileHashService {
|
||||
|
||||
@Override
|
||||
public String sha256(File file) {
|
||||
try (InputStream inputStream = new FileInputStream(file)) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte value : digest.digest()) {
|
||||
builder.append(String.format("%02x", value));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("Failed to calculate file hash: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user