feat(metadata): edit release tags and render UPC barcodes

This commit is contained in:
zarzet
2026-08-18 19:29:12 +07:00
parent 290923e88f
commit a7423b7f89
37 changed files with 1162 additions and 32 deletions
+22
View File
@@ -379,6 +379,16 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
metadata.Composer = value
case "COMMENT":
metadata.Comment = value
case "ITUNESADVISORY":
metadata.Explicit = isTruthyTagValue(value)
case "RELEASETYPE":
metadata.AlbumType = value
case "BARCODE", "UPC":
metadata.UPC = value
case "COMPILATION":
if isTruthyTagValue(value) && metadata.AlbumType == "" {
metadata.AlbumType = "compilation"
}
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
@@ -428,6 +438,14 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
addItem("Copyright", metadata.Copyright)
addItem("Composer", metadata.Composer)
addItem("Comment", metadata.Comment)
if metadata.Explicit {
addItem("ITUNESADVISORY", "1")
}
addItem("RELEASETYPE", metadata.AlbumType)
addItem("BARCODE", metadata.UPC)
if strings.EqualFold(strings.TrimSpace(metadata.AlbumType), "compilation") {
addItem("COMPILATION", "1")
}
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
@@ -455,6 +473,10 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
"copyright": "COPYRIGHT",
"composer": "COMPOSER",
"comment": "COMMENT",
"explicit": "ITUNESADVISORY",
"album_type": "RELEASETYPE",
"upc": "BARCODE",
"compilation": "COMPILATION",
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
+6
View File
@@ -31,6 +31,9 @@ func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
Copyright: "Copyright",
Composer: "Composer",
Comment: "Comment",
AlbumType: "compilation",
Explicit: true,
UPC: "4006381333931",
ReplayGainTrackGain: "-6.50 dB",
ReplayGainTrackPeak: "0.98",
ReplayGainAlbumGain: "-5.00 dB",
@@ -57,6 +60,9 @@ func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
t.Fatalf("metadata = %#v", readMetadata)
}
if !readMetadata.Explicit || readMetadata.AlbumType != "compilation" || readMetadata.UPC != "4006381333931" {
t.Fatalf("release identity = %#v", readMetadata)
}
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
if err != nil {
+21
View File
@@ -28,6 +28,9 @@ type AudioMetadata struct {
Copyright string
Composer string
Comment string
AlbumType string
Explicit bool
UPC string
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
ReplayGainTrackGain string
ReplayGainTrackPeak string
@@ -191,6 +194,14 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
switch strings.ToUpper(strings.TrimSpace(desc)) {
case "ITUNESADVISORY":
metadata.Explicit = isTruthyTagValue(userValue)
case "RELEASETYPE":
metadata.AlbumType = userValue
case "BARCODE", "UPC":
metadata.UPC = userValue
}
}
pos += 6 + frameSize
@@ -303,6 +314,10 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "TCMP":
if isTruthyTagValue(value) && metadata.AlbumType == "" {
metadata.AlbumType = "compilation"
}
case "COMM":
if v := extractLangTextFrame(frameData); v != "" {
metadata.Comment = v
@@ -326,6 +341,12 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.ReplayGainAlbumGain = userValue
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = userValue
case "ITUNESADVISORY":
metadata.Explicit = isTruthyTagValue(userValue)
case "RELEASETYPE":
metadata.AlbumType = userValue
case "BARCODE", "UPC":
metadata.UPC = userValue
}
}
+10
View File
@@ -268,6 +268,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "ITUNESADVISORY":
metadata.Explicit = isTruthyTagValue(value)
case "RELEASETYPE":
metadata.AlbumType = value
case "BARCODE", "UPC":
metadata.UPC = value
case "COMPILATION":
if isTruthyTagValue(value) && metadata.AlbumType == "" {
metadata.AlbumType = "compilation"
}
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
@@ -30,6 +30,9 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
id3CommentFrame("USLT", "Lyrics"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
id3UserTextFrame("TXXX", "ITUNESADVISORY", "1"),
id3UserTextFrame("TXXX", "RELEASETYPE", "album"),
id3UserTextFrame("TXXX", "BARCODE", "4006381333931"),
)
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
t.Fatalf("write ID3v2: %v", err)
@@ -45,6 +48,9 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
}
if !meta.Explicit || meta.AlbumType != "album" || meta.UPC != "4006381333931" {
t.Fatalf("metadata release identity = %#v", meta)
}
id3v1Path := filepath.Join(dir, "id3v1.mp3")
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
@@ -201,6 +207,9 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("RELEASETYPE", "single")...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("BARCODE", "4006381333931")...)
ilstPayload = append(ilstPayload, buildM4AInt8Atom("rtng", 1)...)
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
fileData := buildM4AFileWithIlst(ilstPayload, true)
@@ -215,6 +224,9 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
t.Fatalf("M4A metadata = %#v", meta)
}
if !meta.Explicit || meta.AlbumType != "single" || meta.UPC != "4006381333931" {
t.Fatalf("M4A release identity = %#v", meta)
}
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
}
@@ -381,6 +393,9 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
"TRACKNUMBER=2/9",
"DISCNUMBER=1/2",
"LYRICS=[00:00.00]Ogg Lyrics",
"ITUNESADVISORY=1",
"RELEASETYPE=ep",
"BARCODE=4006381333931",
}
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
for _, entry := range entries {
@@ -401,6 +416,9 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
}
if !meta.Explicit || meta.AlbumType != "ep" || meta.UPC != "4006381333931" {
t.Fatalf("Ogg release identity = %#v", meta)
}
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
+10 -17
View File
@@ -29,6 +29,9 @@ func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["album_type"] = meta.AlbumType
result["explicit"] = meta.Explicit
result["upc"] = meta.UPC
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
@@ -68,6 +71,9 @@ func ReadFileMetadata(filePath string) (string, error) {
"copyright": "",
"composer": "",
"comment": "",
"album_type": "",
"explicit": false,
"upc": "",
"duration": 0,
"format": "",
"audio_codec": "",
@@ -83,23 +89,7 @@ func ReadFileMetadata(filePath string) (string, error) {
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["total_tracks"] = oggMeta.TotalTracks
result["disc_number"] = oggMeta.DiscNumber
result["total_discs"] = oggMeta.TotalDiscs
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
applyAudioMetadataToResult(result, oggMeta)
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
@@ -130,6 +120,9 @@ func ReadFileMetadata(filePath string) (string, error) {
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
result["album_type"] = metadata.AlbumType
result["explicit"] = metadata.Explicit
result["upc"] = metadata.UPC
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
+4
View File
@@ -36,6 +36,10 @@ type LibraryScanResult struct {
Composer string `json:"composer,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Comment string `json:"comment,omitempty"`
AlbumType string `json:"albumType,omitempty"`
Explicit bool `json:"explicit,omitempty"`
UPC string `json:"upc,omitempty"`
Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
}
+5 -14
View File
@@ -141,6 +141,10 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
result.Comment = metadata.Comment
result.AlbumType = metadata.AlbumType
result.Explicit = metadata.Explicit
result.UPC = metadata.UPC
quality, err := audioQualityFromParsedFlac(f)
if err == nil {
@@ -289,20 +293,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyAudioMetadataToScan(metadata, result)
quality, err := GetOggQuality(filePath)
if err == nil {
@@ -30,6 +30,9 @@ func writeSinglePassTestFlac(t testing.TB, path string, cover []byte) {
comments := flacvorbis.New()
setComment(comments, "TITLE", "Single Pass")
setComment(comments, "ARTIST", "Artist")
setComment(comments, "ITUNESADVISORY", "1")
setComment(comments, "RELEASETYPE", "album")
setComment(comments, "BARCODE", "4006381333931")
commentBlock := comments.Marshal()
pictureBlock := (&flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
@@ -63,6 +66,9 @@ func TestScanFLACSinglePassReadsMetadataQualityAndCover(t *testing.T) {
if result.TrackName != "Single Pass" || result.ArtistName != "Artist" || result.SampleRate != 44100 || result.BitDepth != 16 || result.Duration != 10 {
t.Fatalf("scan result = %#v", result)
}
if !result.Explicit || result.AlbumType != "album" || result.UPC != "4006381333931" {
t.Fatalf("release identity = %#v", result)
}
if result.CoverPath == "" {
t.Fatal("cover was not cached")
}
+9
View File
@@ -391,6 +391,15 @@ func metadataFromParsedFlac(f *flac.File) *Metadata {
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
metadata.Explicit = isTruthyTagValue(getComment(cmt, "ITUNESADVISORY"))
metadata.AlbumType = getComment(cmt, "RELEASETYPE")
if metadata.AlbumType == "" && isTruthyTagValue(getComment(cmt, "COMPILATION")) {
metadata.AlbumType = "compilation"
}
metadata.UPC = getComment(cmt, "BARCODE")
if metadata.UPC == "" {
metadata.UPC = getComment(cmt, "UPC")
}
metadata.ReplayGainTrackGain = getComment(cmt, "REPLAYGAIN_TRACK_GAIN")
metadata.ReplayGainTrackPeak = getComment(cmt, "REPLAYGAIN_TRACK_PEAK")
+25
View File
@@ -73,6 +73,14 @@ func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMet
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize)
case "disk":
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize)
case "rtng":
if value, valueErr := readM4AByteValue(f, header, fileSize); valueErr == nil {
metadata.Explicit = value == 1
}
case "cpil":
if value, valueErr := readM4AByteValue(f, header, fileSize); valueErr == nil && value != 0 && metadata.AlbumType == "" {
metadata.AlbumType = "compilation"
}
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fileSize)
if freeformErr == nil {
@@ -105,6 +113,12 @@ func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMet
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
case "ITUNESADVISORY":
metadata.Explicit = isTruthyTagValue(value)
case "RELEASETYPE":
metadata.AlbumType = value
case "BARCODE", "UPC":
metadata.UPC = value
}
}
}
@@ -286,6 +300,17 @@ func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int,
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
}
func readM4AByteValue(f *os.File, parent atomHeader, fileSize int64) (byte, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, err
}
if len(payload) == 0 {
return 0, fmt.Errorf("integer payload is empty in %s", parent.typ)
}
return payload[len(payload)-1], nil
}
func parsePositiveInt(value string) int {
value = strings.TrimSpace(value)
if value == "" {
+20
View File
@@ -606,6 +606,14 @@ func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []b
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
if meta.Explicit {
writeTXXX("ITUNESADVISORY", "1")
}
writeTXXX("RELEASETYPE", meta.AlbumType)
writeTXXX("BARCODE", meta.UPC)
if strings.EqualFold(strings.TrimSpace(meta.AlbumType), "compilation") {
writeText("TCMP", "1")
}
if len(coverData) > 0 {
if strings.TrimSpace(coverMIME) == "" {
@@ -802,6 +810,9 @@ func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
AlbumType: fields["album_type"],
Explicit: isTruthyTagValue(fields["explicit"]),
UPC: fields["upc"],
ReplayGainTrackGain: fields["replaygain_track_gain"],
ReplayGainTrackPeak: fields["replaygain_track_peak"],
ReplayGainAlbumGain: fields["replaygain_album_gain"],
@@ -835,6 +846,11 @@ func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]stri
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
meta.Comment = keep("comment", meta.Comment, existing.Comment)
meta.AlbumType = keep("album_type", meta.AlbumType, existing.AlbumType)
meta.UPC = keep("upc", meta.UPC, existing.UPC)
if _, ok := fields["explicit"]; !ok {
meta.Explicit = existing.Explicit
}
meta.Date = keep("date", meta.Date, existing.Date)
if _, ok := fields["track_number"]; !ok {
meta.TrackNumber = existing.TrackNumber
@@ -957,6 +973,10 @@ func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
result.Comment = metadata.Comment
result.AlbumType = metadata.AlbumType
result.Explicit = metadata.Explicit
result.UPC = metadata.UPC
}
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
+42
View File
@@ -6976,6 +6976,48 @@ abstract class AppLocalizations {
/// **'Comment'**
String get editMetadataFieldComment;
/// Label for an album or release type metadata value
///
/// In en, this message translates to:
/// **'Release Type'**
String get trackAlbumType;
/// Hint for the release type metadata field
///
/// In en, this message translates to:
/// **'Album, single, EP, compilation...'**
String get editMetadataFieldAlbumTypeHint;
/// Label for the explicit content metadata field
///
/// In en, this message translates to:
/// **'Explicit'**
String get editMetadataFieldExplicit;
/// Description for the explicit content metadata switch
///
/// In en, this message translates to:
/// **'Mark this track as containing explicit content'**
String get editMetadataFieldExplicitHint;
/// Displayed value when a track is marked explicit
///
/// In en, this message translates to:
/// **'Explicit'**
String get metadataExplicitValue;
/// Label for the release barcode metadata field
///
/// In en, this message translates to:
/// **'UPC / Barcode'**
String get editMetadataFieldUpc;
/// Hint for the release barcode metadata field
///
/// In en, this message translates to:
/// **'Numeric UPC, EAN, or GTIN'**
String get editMetadataFieldUpcHint;
/// Expandable section label for advanced metadata fields
///
/// In en, this message translates to:
+23
View File
@@ -4255,6 +4255,29 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Kommentar';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Erweitert';
+23
View File
@@ -4209,6 +4209,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4203,6 +4203,29 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4316,6 +4316,29 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Commentaire';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Avancé';
+23
View File
@@ -4206,6 +4206,29 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Jenis Rilisan';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, singel, EP, kompilasi...';
@override
String get editMetadataFieldExplicit => 'Eksplisit';
@override
String get editMetadataFieldExplicitHint =>
'Tandai lagu ini sebagai konten eksplisit';
@override
String get metadataExplicitValue => 'Eksplisit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'UPC, EAN, atau GTIN numerik';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4198,6 +4198,29 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4087,6 +4087,29 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get editMetadataFieldComment => '주석';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => '고급';
+23
View File
@@ -4203,6 +4203,29 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4240,6 +4240,29 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+23
View File
@@ -4239,6 +4239,29 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Yorum';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Gelişmiş';
+23
View File
@@ -4257,6 +4257,29 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get editMetadataFieldComment => 'Comment';
@override
String get trackAlbumType => 'Release Type';
@override
String get editMetadataFieldAlbumTypeHint =>
'Album, single, EP, compilation...';
@override
String get editMetadataFieldExplicit => 'Explicit';
@override
String get editMetadataFieldExplicitHint =>
'Mark this track as containing explicit content';
@override
String get metadataExplicitValue => 'Explicit';
@override
String get editMetadataFieldUpc => 'UPC / Barcode';
@override
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
@override
String get editMetadataAdvanced => 'Advanced';
+28
View File
@@ -5464,6 +5464,34 @@
"@editMetadataFieldComment": {
"description": "Label for comment field in the edit metadata sheet"
},
"trackAlbumType": "Release Type",
"@trackAlbumType": {
"description": "Label for an album or release type metadata value"
},
"editMetadataFieldAlbumTypeHint": "Album, single, EP, compilation...",
"@editMetadataFieldAlbumTypeHint": {
"description": "Hint for the release type metadata field"
},
"editMetadataFieldExplicit": "Explicit",
"@editMetadataFieldExplicit": {
"description": "Label for the explicit content metadata field"
},
"editMetadataFieldExplicitHint": "Mark this track as containing explicit content",
"@editMetadataFieldExplicitHint": {
"description": "Description for the explicit content metadata switch"
},
"metadataExplicitValue": "Explicit",
"@metadataExplicitValue": {
"description": "Displayed value when a track is marked explicit"
},
"editMetadataFieldUpc": "UPC / Barcode",
"@editMetadataFieldUpc": {
"description": "Label for the release barcode metadata field"
},
"editMetadataFieldUpcHint": "Numeric UPC, EAN, or GTIN",
"@editMetadataFieldUpcHint": {
"description": "Hint for the release barcode metadata field"
},
"editMetadataAdvanced": "Advanced",
"@editMetadataAdvanced": {
"description": "Expandable section label for advanced metadata fields"
+7
View File
@@ -5945,6 +5945,13 @@
"storeEmptyNoResults": "Tidak ada ekstensi ditemukan",
"logIssueTrackNotFoundDescription": "Some tracks could not be found on download services",
"editMetadataFieldComment": "Comment",
"trackAlbumType": "Jenis Rilisan",
"editMetadataFieldAlbumTypeHint": "Album, singel, EP, kompilasi...",
"editMetadataFieldExplicit": "Eksplisit",
"editMetadataFieldExplicitHint": "Tandai lagu ini sebagai konten eksplisit",
"metadataExplicitValue": "Eksplisit",
"editMetadataFieldUpc": "UPC / Barcode",
"editMetadataFieldUpcHint": "UPC, EAN, atau GTIN numerik",
"settingsMetadata": "Metadata",
"settingsCache": "Storage & Cache",
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi yang diaktifkan dengan kemampuan penyedia unduhan yang tercantum di sini.",
+5
View File
@@ -41,6 +41,11 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
'copyright': val('copyright', copyright),
'composer': val('composer', composer),
'comment': fileMetadata?['comment']?.toString() ?? '',
'album_type': val('album_type', albumType),
'explicit': (parseExplicitFlag(fileMetadata?['explicit']) ?? isExplicit)
? '1'
: '0',
'upc': val('upc', upc),
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
};
+18
View File
@@ -302,6 +302,15 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
_buildMetadataGrid(context, colorScheme),
if (upc case final barcodeValue?) ...[
const SizedBox(height: 16),
MetadataBarcode(
value: barcodeValue,
label: context.l10n.editMetadataFieldUpc,
onCopy: () => _copyToClipboard(context, barcodeValue),
),
],
if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
Builder(
@@ -455,6 +464,15 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
_MetadataItem(context.l10n.trackCopyright, copyright!),
if (composer != null && composer!.isNotEmpty)
_MetadataItem(context.l10n.editMetadataFieldComposer, composer!),
if (albumType != null)
_MetadataItem(context.l10n.trackAlbumType, albumType!),
if (isExplicit)
_MetadataItem(
context.l10n.editMetadataFieldExplicit,
context.l10n.metadataExplicitValue,
),
if (comment != null)
_MetadataItem(context.l10n.editMetadataFieldComment, comment!),
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
];
+107 -1
View File
@@ -504,6 +504,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'label': 'label',
'copyright': 'copyright',
'composer': 'composer',
'comment': 'comment',
'album_type': 'album_type',
'explicit': 'explicit',
'upc': 'upc',
'cover': 'cover',
};
@@ -523,6 +527,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
late final TextEditingController _albumTypeCtrl;
late final TextEditingController _upcCtrl;
bool _explicit = false;
bool _fetchingMusicBrainz = false;
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
@@ -815,6 +822,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return l10n.editMetadataFieldCopyright;
case 'composer':
return l10n.editMetadataFieldComposer;
case 'comment':
return l10n.editMetadataFieldComment;
case 'album_type':
return l10n.trackAlbumType;
case 'explicit':
return l10n.editMetadataFieldExplicit;
case 'upc':
return l10n.editMetadataFieldUpc;
case 'cover':
return l10n.editMetadataFieldCover;
default:
@@ -854,6 +869,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return _copyrightCtrl;
case 'composer':
return _composerCtrl;
case 'comment':
return _commentCtrl;
case 'album_type':
return _albumTypeCtrl;
case 'upc':
return _upcCtrl;
default:
return null;
}
@@ -871,6 +892,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_invalidateAutoFillPreview();
_autoFillFields.clear();
for (final key in _fieldDefs.keys) {
if (key == 'explicit') continue;
if (key == 'cover') {
if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) {
_autoFillFields.add(key);
@@ -1036,6 +1058,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
put('label', track['label']);
put('copyright', track['copyright']);
put('composer', track['composer']);
put('comment', track['comment']);
put('album_type', track['album_type'] ?? track['albumType']);
put('explicit', track['explicit'] ?? track['is_explicit']);
put('upc', track['upc'] ?? track['barcode']);
}
int _metadataMatchScore(
@@ -1627,7 +1653,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final availableValues = <String, String>{};
for (final entry in enriched.entries) {
final value = entry.value.trim();
if (value.isNotEmpty && value != '0' && value != 'null') {
final isExplicitValue = entry.key == 'explicit';
if (value.isNotEmpty &&
(isExplicitValue || value != '0') &&
value != 'null') {
availableValues[entry.key] = value;
}
}
@@ -1699,6 +1728,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
for (final key in _autoFillFields) {
if (key == 'cover') continue;
final value = preview.values[key];
if (key == 'explicit' && value != null) {
final parsed = parseExplicitFlag(value);
if (parsed != null) {
_explicit = parsed;
filledCount++;
}
continue;
}
final ctrl = _controllerForKey(key);
if (value != null && ctrl != null) {
ctrl.text = value;
@@ -1788,6 +1825,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
_albumTypeCtrl = TextEditingController(text: v['album_type'] ?? '');
_upcCtrl = TextEditingController(text: v['upc'] ?? '');
_explicit = parseExplicitFlag(v['explicit']) == true;
_loadCurrentCoverPreview();
}
@@ -1812,6 +1852,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_copyrightCtrl.dispose();
_composerCtrl.dispose();
_commentCtrl.dispose();
_albumTypeCtrl.dispose();
_upcCtrl.dispose();
super.dispose();
}
@@ -1862,6 +1904,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'album_type': _albumTypeCtrl.text,
'explicit': _explicit ? '1' : '0',
'upc': _upcCtrl.text,
'compilation': _albumTypeCtrl.text.trim().toLowerCase() == 'compilation'
? '1'
: '0',
'cover_path': coverPathForSave ?? '',
'artist_tag_mode': widget.artistTagMode,
};
@@ -1929,6 +1977,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '',
'COMMENT': metadata['comment'] ?? '',
'ITUNESADVISORY': metadata['explicit'] ?? '',
'RELEASETYPE': metadata['album_type'] ?? '',
'BARCODE': metadata['upc'] ?? '',
'COMPILATION': metadata['compilation'] ?? '',
};
try {
final existingMetadata = await PlatformBridge.readFileMetadata(
@@ -2229,6 +2281,29 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
context.l10n.editMetadataFieldComposer,
_composerCtrl,
),
_field(
context.l10n.trackAlbumType,
_albumTypeCtrl,
hint: context.l10n.editMetadataFieldAlbumTypeHint,
),
_switchField(
label: context.l10n.editMetadataFieldExplicit,
subtitle:
context.l10n.editMetadataFieldExplicitHint,
value: _explicit,
onChanged: (value) =>
setState(() => _explicit = value),
),
_field(
context.l10n.editMetadataFieldUpc,
_upcCtrl,
hint: context.l10n.editMetadataFieldUpcHint,
keyboard: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(18),
],
),
_field(
context.l10n.editMetadataFieldComment,
_commentCtrl,
@@ -2788,6 +2863,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
String? hint,
TextInputType? keyboard,
int maxLines = 1,
List<TextInputFormatter>? inputFormatters,
}) {
final cs = widget.colorScheme;
final radius = BorderRadius.circular(14);
@@ -2810,6 +2886,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
TextField(
controller: controller,
keyboardType: keyboard,
inputFormatters: inputFormatters,
maxLines: maxLines,
cursorColor: cs.primary,
style: Theme.of(context).textTheme.bodyLarge,
@@ -2841,6 +2918,35 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
);
}
Widget _switchField({
required String label,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
final cs = widget.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Material(
color: _fieldFill(cs),
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: SwitchListTile.adaptive(
value: value,
onChanged: onChanged,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(label, style: Theme.of(context).textTheme.bodyLarge),
subtitle: Text(
subtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
),
),
);
}
Widget _selectionField({
required String label,
required String value,
+17
View File
@@ -45,6 +45,7 @@ import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart';
import 'package:spotiflac_android/widgets/metadata_barcode.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/constants/music_services.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
@@ -354,6 +355,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
final resolvedLabel = metadata['label']?.toString();
final resolvedCopyright = metadata['copyright']?.toString();
final resolvedISRC = metadata['isrc']?.toString();
final resolvedAlbumType = metadata['album_type']?.toString();
final resolvedUPC = (metadata['upc'] ?? metadata['barcode'])?.toString();
final resolvedComment = metadata['comment']?.toString();
final resolvedExplicit = parseExplicitFlag(metadata['explicit']);
final needsTrackNumber =
resolvedTrackNumber != null &&
resolvedTrackNumber > 0 &&
@@ -390,6 +395,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
final fileHasCopyright = present(resolvedCopyright);
final fileHasISRC = present(resolvedISRC);
final fileHasLabel = present(resolvedLabel);
final fileHasAlbumType = present(resolvedAlbumType);
final fileHasUPC = present(resolvedUPC);
final fileHasComment = present(resolvedComment);
final fileHasExplicit = resolvedExplicit == true;
final fileHasTrackNumber =
resolvedTrackNumber != null && resolvedTrackNumber > 0;
final fileHasTotalTracks =
@@ -442,6 +451,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
fileHasISRC ||
fileHasLabel ||
fileHasCopyright ||
fileHasAlbumType ||
fileHasUPC ||
fileHasComment ||
fileHasExplicit ||
isPlaceholderQualityLabel(_quality)) &&
mounted) {
setState(() {
@@ -467,6 +480,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
if (fileHasISRC) 'isrc': resolvedISRC,
if (fileHasLabel) 'label': resolvedLabel,
if (fileHasCopyright) 'copyright': resolvedCopyright,
if (fileHasAlbumType) 'album_type': resolvedAlbumType,
if (fileHasUPC) 'upc': resolvedUPC,
if (fileHasComment) 'comment': resolvedComment,
'explicit': ?resolvedExplicit,
};
});
}
@@ -297,6 +297,15 @@ extension _TrackMetadataCover on _TrackMetadataScreenState {
String? get composer =>
_editedMetadata?['composer']?.toString() ??
(_isLocalItem ? _localLibraryItem!.composer : null);
String? get albumType =>
normalizeOptionalString(_editedMetadata?['album_type']?.toString());
String? get upc => normalizeOptionalString(
(_editedMetadata?['upc'] ?? _editedMetadata?['barcode'])?.toString(),
);
String? get comment =>
normalizeOptionalString(_editedMetadata?['comment']?.toString());
bool get isExplicit =>
parseExplicitFlag(_editedMetadata?['explicit']) == true;
int? get duration =>
readPositiveInt(_editedMetadata?['duration']) ??
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
+37
View File
@@ -2040,6 +2040,7 @@ class FFmpegService {
// touched (an empty value clears the tag).
if (metadata != null) {
await _writeM4AFreeformTags(m4aPath, metadata);
await _writeM4AReleaseIdentityTags(m4aPath, metadata);
}
_log.d('M4A metadata embedded successfully');
@@ -2561,6 +2562,42 @@ class FFmpegService {
}
}
/// Restores the iTunes atoms that FFmpeg does not reliably map from generic
/// metadata keys. This runs after a successful remux, when the container is
/// canonical enough for the native editor even if the original was not.
static Future<void> _writeM4AReleaseIdentityTags(
String m4aPath,
Map<String, String> metadata,
) async {
final fields = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
switch (key) {
case 'ITUNESADVISORY':
fields['explicit'] = entry.value;
case 'RELEASETYPE':
fields['album_type'] = entry.value;
case 'BARCODE':
case 'UPC':
fields['upc'] = entry.value;
case 'COMPILATION':
fields['compilation'] = entry.value;
}
}
if (fields.isEmpty) return;
try {
final result = await PlatformBridge.editFileMetadata(m4aPath, fields);
if (result['error'] != null || result['method'] != 'native_m4a') {
_log.w(
'Native M4A release identity write was not completed for $m4aPath',
);
}
} catch (e) {
_log.w('M4A release identity write failed for $m4aPath: $e');
}
}
/// Split a CUE+audio file into individual track files using FFmpeg.
/// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded.
/// [audioPath] is the source audio file (FLAC, WAV, etc.)
+4
View File
@@ -31,6 +31,10 @@ Map<String, dynamic> normalizeScannedAudioMetadata(
normalized['total_discs'] = firstValue('total_discs', 'totalDiscs');
normalized['bit_depth'] = firstValue('bit_depth', 'bitDepth');
normalized['sample_rate'] = firstValue('sample_rate', 'sampleRate');
normalized['album_type'] = firstValue('album_type', 'albumType');
normalized['upc'] = firstValue('upc', 'barcode');
normalized['comment'] = metadata['comment'];
normalized['explicit'] = metadata['explicit'];
normalized['audio_codec'] =
firstValue('audio_codec', 'audioCodec') ?? metadata['format'];
return normalized;
+260
View File
@@ -0,0 +1,260 @@
class RetailBarcodeData {
final String value;
final String symbology;
final String modules;
final int quietZoneModules;
const RetailBarcodeData({
required this.value,
required this.symbology,
required this.modules,
required this.quietZoneModules,
});
}
const _eanLeftOdd = <String>[
'0001101',
'0011001',
'0010011',
'0111101',
'0100011',
'0110001',
'0101111',
'0111011',
'0110111',
'0001011',
];
const _eanLeftEven = <String>[
'0100111',
'0110011',
'0011011',
'0100001',
'0011101',
'0111001',
'0000101',
'0010001',
'0001001',
'0010111',
];
const _eanRight = <String>[
'1110010',
'1100110',
'1101100',
'1000010',
'1011100',
'1001110',
'1010000',
'1000100',
'1001000',
'1110100',
];
const _ean13Parity = <String>[
'LLLLLL',
'LLGLGG',
'LLGGLG',
'LLGGGL',
'LGLLGG',
'LGGLLG',
'LGGGLL',
'LGLGLG',
'LGLGGL',
'LGGLGL',
];
// Code 128 module widths, including start/check/stop symbols. It is used only
// as an exact-value fallback when a numeric identifier is not a valid UPC-A or
// EAN-13. Valid retail identifiers always use their native symbology.
const _code128Patterns = <String>[
'212222',
'222122',
'222221',
'121223',
'121322',
'131222',
'122213',
'122312',
'132212',
'221213',
'221312',
'231212',
'112232',
'122132',
'122231',
'113222',
'123122',
'123221',
'223211',
'221132',
'221231',
'213212',
'223112',
'312131',
'311222',
'321122',
'321221',
'312212',
'322112',
'322211',
'212123',
'212321',
'232121',
'111323',
'131123',
'131321',
'112313',
'132113',
'132311',
'211313',
'231113',
'231311',
'112133',
'112331',
'132131',
'113123',
'113321',
'133121',
'313121',
'211331',
'231131',
'213113',
'213311',
'213131',
'311123',
'311321',
'331121',
'312113',
'312311',
'332111',
'314111',
'221411',
'431111',
'111224',
'111422',
'121124',
'121421',
'141122',
'141221',
'112214',
'112412',
'122114',
'122411',
'142112',
'142211',
'241211',
'221114',
'413111',
'241112',
'134111',
'111242',
'121142',
'121241',
'114212',
'124112',
'124211',
'411212',
'421112',
'421211',
'212141',
'214121',
'412121',
'111143',
'111341',
'131141',
'114113',
'114311',
'411113',
'411311',
'113141',
'114131',
'311141',
'411131',
'211412',
'211214',
'211232',
'2331112',
];
RetailBarcodeData? encodeRetailBarcode(String rawValue) {
final value = rawValue.replaceAll(RegExp(r'[\s-]+'), '');
if (value.isEmpty || !RegExp(r'^\d+$').hasMatch(value)) return null;
if (value.length == 12 && hasValidGtinCheckDigit(value)) {
return RetailBarcodeData(
value: value,
symbology: 'UPC-A',
modules: _encodeEan13('0$value'),
quietZoneModules: 11,
);
}
if (value.length == 13 && hasValidGtinCheckDigit(value)) {
return RetailBarcodeData(
value: value,
symbology: 'EAN-13',
modules: _encodeEan13(value),
quietZoneModules: 11,
);
}
return RetailBarcodeData(
value: value,
symbology: 'CODE 128',
modules: _encodeCode128B(value),
quietZoneModules: 10,
);
}
bool hasValidGtinCheckDigit(String value) {
if (value.length < 2 || !RegExp(r'^\d+$').hasMatch(value)) return false;
var sum = 0;
var useThree = true;
for (var i = value.length - 2; i >= 0; i--) {
final digit = value.codeUnitAt(i) - 48;
sum += digit * (useThree ? 3 : 1);
useThree = !useThree;
}
final expected = (10 - (sum % 10)) % 10;
return expected == value.codeUnitAt(value.length - 1) - 48;
}
String _encodeEan13(String value) {
final first = value.codeUnitAt(0) - 48;
final parity = _ean13Parity[first];
final out = StringBuffer('101');
for (var i = 1; i <= 6; i++) {
final digit = value.codeUnitAt(i) - 48;
out.write(parity[i - 1] == 'L' ? _eanLeftOdd[digit] : _eanLeftEven[digit]);
}
out.write('01010');
for (var i = 7; i <= 12; i++) {
out.write(_eanRight[value.codeUnitAt(i) - 48]);
}
out.write('101');
return out.toString();
}
String _encodeCode128B(String value) {
const startB = 104;
final codes = <int>[startB];
var checksum = startB;
for (var i = 0; i < value.length; i++) {
final code = value.codeUnitAt(i) - 32;
codes.add(code);
checksum += code * (i + 1);
}
codes
..add(checksum % 103)
..add(106);
final out = StringBuffer();
for (final code in codes) {
final widths = _code128Patterns[code];
for (var i = 0; i < widths.length; i++) {
final width = widths.codeUnitAt(i) - 48;
out.write(List.filled(width, i.isEven ? '1' : '0').join());
}
}
return out.toString();
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/retail_barcode.dart';
class MetadataBarcode extends StatelessWidget {
final String value;
final String label;
final VoidCallback? onCopy;
const MetadataBarcode({
super.key,
required this.value,
required this.label,
this.onCopy,
});
@override
Widget build(BuildContext context) {
final barcode = encodeRetailBarcode(value);
if (barcode == null) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
return Semantics(
label: '$label ${barcode.value}',
button: onCopy != null,
child: Material(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onCopy,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.qr_code_2_rounded,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Text(
barcode.symbology,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (onCopy != null) ...[
const SizedBox(width: 8),
Icon(
Icons.copy_rounded,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
],
),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(10, 12, 10, 9),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
RepaintBoundary(
child: SizedBox(
width: double.infinity,
height: 76,
child: CustomPaint(painter: _BarcodePainter(barcode)),
),
),
const SizedBox(height: 7),
Text(
barcode.value,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: const TextStyle(
color: Colors.black,
fontFamily: 'monospace',
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 2.2,
),
),
],
),
),
],
),
),
),
),
);
}
}
class _BarcodePainter extends CustomPainter {
final RetailBarcodeData barcode;
const _BarcodePainter(this.barcode);
@override
void paint(Canvas canvas, Size size) {
final totalModules =
barcode.modules.length + (barcode.quietZoneModules * 2);
final moduleWidth = size.width / totalModules;
final paint = Paint()
..color = Colors.black
..style = PaintingStyle.fill
..isAntiAlias = false;
for (var i = 0; i < barcode.modules.length; i++) {
if (barcode.modules.codeUnitAt(i) != 49) continue;
final left = (barcode.quietZoneModules + i) * moduleWidth;
canvas.drawRect(
Rect.fromLTWH(left, 0, moduleWidth + 0.01, size.height),
paint,
);
}
}
@override
bool shouldRepaint(covariant _BarcodePainter oldDelegate) {
return barcode.modules != oldDelegate.barcode.modules ||
barcode.quietZoneModules != oldDelegate.barcode.quietZoneModules;
}
}
+8
View File
@@ -1036,6 +1036,10 @@ void main() {
'bitDepth': 24,
'sampleRate': 96000,
'bitrate': 1840,
'albumType': 'album',
'explicit': true,
'upc': '4006381333931',
'comment': 'https://listen.tidal.com/album/1',
'format': 'flac',
});
@@ -1051,6 +1055,10 @@ void main() {
expect(normalized['bit_depth'], 24);
expect(normalized['sample_rate'], 96000);
expect(normalized['bitrate'], 1840);
expect(normalized['album_type'], 'album');
expect(normalized['explicit'], isTrue);
expect(normalized['upc'], '4006381333931');
expect(normalized['comment'], 'https://listen.tidal.com/album/1');
expect(normalized['audio_codec'], 'flac');
});
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/retail_barcode.dart';
import 'package:spotiflac_android/widgets/metadata_barcode.dart';
void main() {
test('encodes a valid UPC-A with its native retail symbology', () {
final barcode = encodeRetailBarcode('036000291452');
expect(barcode, isNotNull);
expect(barcode!.symbology, 'UPC-A');
expect(barcode.value, '036000291452');
expect(barcode.modules, hasLength(95));
expect(barcode.modules, startsWith('101'));
expect(barcode.modules, endsWith('101'));
});
test('encodes a valid EAN-13 with its native retail symbology', () {
final barcode = encodeRetailBarcode('4006381333931');
expect(barcode, isNotNull);
expect(barcode!.symbology, 'EAN-13');
expect(barcode.modules, hasLength(95));
expect(hasValidGtinCheckDigit(barcode.value), isTrue);
});
test('uses an exact scannable fallback for a malformed provider value', () {
final barcode = encodeRetailBarcode('0012345678901');
expect(barcode, isNotNull);
expect(barcode!.symbology, 'CODE 128');
expect(barcode.value, '0012345678901');
expect(barcode.modules, isNotEmpty);
});
test('normalizes separators but rejects non-numeric values', () {
expect(encodeRetailBarcode('0 36000-29145 2')?.value, '036000291452');
expect(encodeRetailBarcode('not-a-upc'), isNull);
});
testWidgets('metadata barcode stays usable in a narrow metadata card', (
tester,
) async {
var copied = false;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(colorSchemeSeed: Colors.teal),
home: Scaffold(
body: Center(
child: SizedBox(
width: 260,
child: MetadataBarcode(
value: '036000291452',
label: 'UPC / Barcode',
onCopy: () => copied = true,
),
),
),
),
),
);
expect(find.text('UPC-A'), findsOneWidget);
expect(find.text('036000291452'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byType(MetadataBarcode));
expect(copied, isTrue);
});
}