diff --git a/go_backend/ape_tags.go b/go_backend/ape_tags.go index a8e449ad..fbc06e03 100644 --- a/go_backend/ape_tags.go +++ b/go_backend/ape_tags.go @@ -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", diff --git a/go_backend/ape_tags_supplement_test.go b/go_backend/ape_tags_supplement_test.go index f9f0721d..971d8e18 100644 --- a/go_backend/ape_tags_supplement_test.go +++ b/go_backend/ape_tags_supplement_test.go @@ -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 { diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 5d6565cf..8106e9ed 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -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 } } diff --git a/go_backend/audio_metadata_ogg.go b/go_backend/audio_metadata_ogg.go index 581ca0dc..fdf95f7a 100644 --- a/go_backend/audio_metadata_ogg.go +++ b/go_backend/audio_metadata_ogg.go @@ -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": diff --git a/go_backend/audio_metadata_supplement_test.go b/go_backend/audio_metadata_supplement_test.go index c752f01f..c39add38 100644 --- a/go_backend/audio_metadata_supplement_test.go +++ b/go_backend/audio_metadata_supplement_test.go @@ -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) diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 97b2204e..5625cad8 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -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 diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 619d6929..083ab456 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -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"` } diff --git a/go_backend/library_scan_formats.go b/go_backend/library_scan_formats.go index 23e539bc..cec26e59 100644 --- a/go_backend/library_scan_formats.go +++ b/go_backend/library_scan_formats.go @@ -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 { diff --git a/go_backend/library_scan_single_pass_test.go b/go_backend/library_scan_single_pass_test.go index 3a517141..62ea8af0 100644 --- a/go_backend/library_scan_single_pass_test.go +++ b/go_backend/library_scan_single_pass_test.go @@ -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") } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 95da9c61..89274001 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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") diff --git a/go_backend/metadata_m4a.go b/go_backend/metadata_m4a.go index f6af58d0..39492c69 100644 --- a/go_backend/metadata_m4a.go +++ b/go_backend/metadata_m4a.go @@ -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 == "" { diff --git a/go_backend/wav_aiff.go b/go_backend/wav_aiff.go index cbd02992..65d7c1a7 100644 --- a/go_backend/wav_aiff.go +++ b/go_backend/wav_aiff.go @@ -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 diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9e9a6d55..ee90fd36 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1b723cab..0bfd1668 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6624c88a..47568ee4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index b15e013a..9170226d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 89f02eb5..863b004e 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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é'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 82a42f06..e0efff99 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index f13f34ad..d4d5d7c6 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f72c5dd1..1a58fc8a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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 => '고급'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 3c3c278d..6ac01499 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8af80d38..05900e79 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 0fb42420..1b9d278d 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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ş'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 3eb8775d..f8b8bfff 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a6b36935..1e4c2214 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 4752da11..33149945 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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.", diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index e3e4d523..0cdfcac3 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -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() ?? '', }; diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index d2785c6c..34a8896e 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -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!), ]; diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index eed04e01..9c0a3e12 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -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 = {}; 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? 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 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, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index aac47279..e84b159f 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 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 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 fileHasISRC || fileHasLabel || fileHasCopyright || + fileHasAlbumType || + fileHasUPC || + fileHasComment || + fileHasExplicit || isPlaceholderQualityLabel(_quality)) && mounted) { setState(() { @@ -467,6 +480,10 @@ class _TrackMetadataScreenState extends ConsumerState 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, }; }); } diff --git a/lib/screens/track_metadata_screen_cover.dart b/lib/screens/track_metadata_screen_cover.dart index 9b4c08e8..56678098 100644 --- a/lib/screens/track_metadata_screen_cover.dart +++ b/lib/screens/track_metadata_screen_cover.dart @@ -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); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 185ebd5b..0beaddb9 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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 _writeM4AReleaseIdentityTags( + String m4aPath, + Map metadata, + ) async { + final fields = {}; + 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.) diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 0bd44c94..7b26739a 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -31,6 +31,10 @@ Map 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; diff --git a/lib/utils/retail_barcode.dart b/lib/utils/retail_barcode.dart new file mode 100644 index 00000000..d7a90770 --- /dev/null +++ b/lib/utils/retail_barcode.dart @@ -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 = [ + '0001101', + '0011001', + '0010011', + '0111101', + '0100011', + '0110001', + '0101111', + '0111011', + '0110111', + '0001011', +]; + +const _eanLeftEven = [ + '0100111', + '0110011', + '0011011', + '0100001', + '0011101', + '0111001', + '0000101', + '0010001', + '0001001', + '0010111', +]; + +const _eanRight = [ + '1110010', + '1100110', + '1101100', + '1000010', + '1011100', + '1001110', + '1010000', + '1000100', + '1001000', + '1110100', +]; + +const _ean13Parity = [ + '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 = [ + '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 = [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(); +} diff --git a/lib/widgets/metadata_barcode.dart b/lib/widgets/metadata_barcode.dart new file mode 100644 index 00000000..57939caf --- /dev/null +++ b/lib/widgets/metadata_barcode.dart @@ -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; + } +} diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index c0564cc5..75c2e8e7 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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'); }); diff --git a/test/retail_barcode_test.dart b/test/retail_barcode_test.dart new file mode 100644 index 00000000..3ed493cb --- /dev/null +++ b/test/retail_barcode_test.dart @@ -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); + }); +}