diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt index f922882c..a3eef59b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -286,6 +286,16 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: val comment = resultString(input, "comment").ifBlank { trackString(input, "comment", requestString(input, "comment")) } + val albumType = resultString(input, "album_type").ifBlank { + trackString(input, "albumType", requestString(input, "album_type")) + } + val upc = resultString(input, "upc").ifBlank { + trackString(input, "upc", requestString(input, "upc")) + } + val isExplicit = input.result.optBoolean("explicit", false) || + input.track.optBoolean("explicit", false) || + input.request.optBoolean("explicit", false) + val isCompilation = albumType.equals("compilation", ignoreCase = true) val lyricsMode = input.request.optString("lyrics_mode", "embed") val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) && (lyricsMode == "embed" || lyricsMode == "both") @@ -316,6 +326,10 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString()) if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString()) if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString()) + if (isExplicit) fields.put("explicit", "1") + if (albumType.isNotBlank()) fields.put("album_type", albumType) + if (upc.isNotBlank()) fields.put("upc", upc) + if (isCompilation) fields.put("compilation", "1") if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath) if (shouldEmbedLyrics) { fields.put("lyrics", lyrics) @@ -361,9 +375,17 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: labelKey to label, "copyright" to copyright, "comment" to comment, + "ITUNESADVISORY" to if (isExplicit) "1" else "", + "RELEASETYPE" to albumType.lowercase(), + "BARCODE" to upc, + "COMPILATION" to if (isCompilation) "1" else "", "lyrics" to if (shouldEmbedLyrics) lyrics else "", "unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "", ) + if (isM4a) { + metadataPairs.add("rtng" to if (isExplicit) "1" else "") + metadataPairs.add("cpil" to if (isCompilation) "1" else "") + } if (isOpus && coverFile != null) { createMetadataBlockPicture(coverFile)?.let { metadataPairs.add("METADATA_BLOCK_PICTURE" to it) diff --git a/go_backend/coverage_test_helpers_test.go b/go_backend/coverage_test_helpers_test.go index a6ab6b4c..8eafb2cd 100644 --- a/go_backend/coverage_test_helpers_test.go +++ b/go_backend/coverage_test_helpers_test.go @@ -75,7 +75,8 @@ function track(id) { comment: "https://example.test/album/1", audioQuality: "FLAC 24-bit", audioModes: "DOLBY_ATMOS", - explicit: true + explicit: true, + upc: "0012345678901" }; } @@ -167,6 +168,9 @@ registerExtension({ copyright: "Copyright", composer: "Composer", comment: "https://example.test/album/1", + explicit: true, + albumType: "compilation", + upc: "0012345678901", lyricsLrc: "[00:00.00]Hello", decryptionKey: "001122", decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } } diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 26652c5f..bd2b6eeb 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -46,6 +46,9 @@ type DownloadRequest struct { Copyright string `json:"copyright,omitempty"` Composer string `json:"composer,omitempty"` Comment string `json:"comment,omitempty"` + Explicit bool `json:"explicit,omitempty"` + AlbumType string `json:"album_type,omitempty"` + UPC string `json:"upc,omitempty"` TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"` @@ -91,6 +94,9 @@ type DownloadResponse struct { Copyright string `json:"copyright,omitempty"` Composer string `json:"composer,omitempty"` Comment string `json:"comment,omitempty"` + Explicit bool `json:"explicit,omitempty"` + AlbumType string `json:"album_type,omitempty"` + UPC string `json:"upc,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"` @@ -117,6 +123,9 @@ type DownloadResult struct { Copyright string Composer string Comment string + Explicit bool + AlbumType string + UPC string LyricsLRC string DecryptionKey string Decryption *DownloadDecryptionInfo @@ -183,6 +192,16 @@ func buildDownloadSuccessResponse( comment = req.Comment } + albumType := result.AlbumType + if albumType == "" { + albumType = req.AlbumType + } + + upc := result.UPC + if upc == "" { + upc = req.UPC + } + coverURL := strings.TrimSpace(result.CoverURL) if coverURL == "" { coverURL = strings.TrimSpace(req.CoverURL) @@ -218,6 +237,9 @@ func buildDownloadSuccessResponse( Copyright: copyright, Composer: composer, Comment: comment, + Explicit: result.Explicit || req.Explicit, + AlbumType: albumType, + UPC: upc, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go index c04435b8..cf899722 100644 --- a/go_backend/exports_extensions.go +++ b/go_backend/exports_extensions.go @@ -60,6 +60,7 @@ func normalizeExtensionTrackMetadataMap( "audio_quality": track.AudioQuality, "audio_modes": track.AudioModes, "explicit": track.Explicit, + "upc": track.UPC, } } diff --git a/go_backend/extension_fallback_helpers.go b/go_backend/extension_fallback_helpers.go index 88611e44..9becd48f 100644 --- a/go_backend/extension_fallback_helpers.go +++ b/go_backend/extension_fallback_helpers.go @@ -192,6 +192,9 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult Copyright: result.Copyright, Composer: result.Composer, Comment: result.Comment, + Explicit: result.Explicit, + AlbumType: result.AlbumType, + UPC: result.UPC, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), @@ -263,6 +266,11 @@ func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloa overlayStrTrim(&resp.Copyright, result.Copyright) overlayStrTrim(&resp.Composer, result.Composer) overlayStrTrim(&resp.Comment, result.Comment) + overlayStrTrim(&resp.AlbumType, result.AlbumType) + overlayStrTrim(&resp.UPC, result.UPC) + if result.Explicit { + resp.Explicit = true + } if result.LyricsLRC != "" { resp.LyricsLRC = result.LyricsLRC } @@ -298,6 +306,11 @@ func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) overlayInt(&resp.TotalDiscs, req.TotalDiscs, "") overlayStr(&resp.CoverURL, req.CoverURL, "") overlayStr(&resp.Comment, req.Comment, "") + overlayStr(&resp.AlbumType, req.AlbumType, "") + overlayStr(&resp.UPC, req.UPC, "") + if req.Explicit { + resp.Explicit = true + } } func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { diff --git a/go_backend/extension_fallback_output.go b/go_backend/extension_fallback_output.go index 72bbe697..cc59e381 100644 --- a/go_backend/extension_fallback_output.go +++ b/go_backend/extension_fallback_output.go @@ -171,6 +171,9 @@ func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright), Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer), Comment: firstNonEmptyTrimmed(resp.Comment, req.Comment), + Explicit: resp.Explicit || req.Explicit, + AlbumType: firstNonEmptyTrimmed(resp.AlbumType, req.AlbumType), + UPC: firstNonEmptyTrimmed(resp.UPC, req.UPC), } if req.EmbedLyrics { metadata.Lyrics = resp.LyricsLRC diff --git a/go_backend/extension_goja_convert.go b/go_backend/extension_goja_convert.go index 2295a718..b0e8340c 100644 --- a/go_backend/extension_goja_convert.go +++ b/go_backend/extension_goja_convert.go @@ -175,6 +175,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada ItemType: gojaObjectString(obj, "item_type", "itemType"), AlbumType: gojaObjectString(obj, "album_type", "albumType"), Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"), + UPC: gojaObjectString(obj, "upc", "barcode"), TidalID: gojaObjectString(obj, "tidal_id", "tidalId"), QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"), DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"), @@ -506,6 +507,9 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo Copyright: gojaObjectString(obj, "copyright"), Composer: gojaObjectString(obj, "composer"), Comment: gojaObjectString(obj, "comment", "comments"), + Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"), + AlbumType: gojaObjectString(obj, "album_type", "albumType"), + UPC: gojaObjectString(obj, "upc", "barcode"), LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), diff --git a/go_backend/extension_provider_supplement_test.go b/go_backend/extension_provider_supplement_test.go index 160391af..9e3137f0 100644 --- a/go_backend/extension_provider_supplement_test.go +++ b/go_backend/extension_provider_supplement_test.go @@ -21,7 +21,7 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) { if err != nil { t.Fatalf("GetTrack: %v", err) } - if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" || track.Comment != "https://example.test/album/1" { + if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" || track.Comment != "https://example.test/album/1" || !track.Explicit || track.UPC != "0012345678901" { t.Fatalf("track = %#v", track) } @@ -72,7 +72,7 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) { if err != nil { t.Fatalf("Download: %v", err) } - if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || download.Comment != "https://example.test/album/1" || len(progress) != 1 || progress[0] != 100 { + if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || download.Comment != "https://example.test/album/1" || !download.Explicit || download.AlbumType != "compilation" || download.UPC != "0012345678901" || len(progress) != 1 || progress[0] != 100 { t.Fatalf("download = %#v progress=%v", download, progress) } diff --git a/go_backend/extension_provider_types.go b/go_backend/extension_provider_types.go index 5ee5003f..e77ca193 100644 --- a/go_backend/extension_provider_types.go +++ b/go_backend/extension_provider_types.go @@ -27,6 +27,7 @@ type ExtTrackMetadata struct { ItemType string `json:"item_type,omitempty"` AlbumType string `json:"album_type,omitempty"` Explicit bool `json:"explicit,omitempty"` + UPC string `json:"upc,omitempty"` TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` @@ -129,6 +130,9 @@ type ExtDownloadResult struct { Copyright string `json:"copyright,omitempty"` Composer string `json:"composer,omitempty"` Comment string `json:"comment,omitempty"` + Explicit bool `json:"explicit,omitempty"` + AlbumType string `json:"album_type,omitempty"` + UPC string `json:"upc,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"` Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index 84363fdb..62c3bf3f 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -261,6 +261,7 @@ func extensionTrackInput(track *ExtTrackMetadata) map[string]any { "composer": track.Composer, "audio_quality": track.AudioQuality, "audio_modes": track.AudioModes, + "upc": track.UPC, } } diff --git a/go_backend/m4a_edit.go b/go_backend/m4a_edit.go index b8cc734a..13716e40 100644 --- a/go_backend/m4a_edit.go +++ b/go_backend/m4a_edit.go @@ -57,6 +57,23 @@ func buildM4ACoverAtom(coverData []byte) []byte { return buildM4AAtom("covr", buildM4ADataAtom(dataType, coverData)) } +// buildM4AFlagAtom writes an iTunes boolean atom (cpil-style, data type 22) +// whose payload is 1 when set. +func buildM4AFlagAtom(typ string, set bool) []byte { + payload := []byte{0} + if set { + payload[0] = 1 + } + return buildM4AAtom(typ, buildM4ADataAtom(22, payload)) +} + +// buildM4AInt8Atom writes an iTunes 8-bit integer atom (rtng-style, data +// type 21). +func buildM4AInt8Atom(typ string, value int) []byte { + payload := []byte{byte(value)} + return buildM4AAtom(typ, buildM4ADataAtom(21, payload)) +} + type m4aIlstLocation struct { moov mp4Box ilst mp4Box @@ -190,6 +207,15 @@ func m4aIndexPairInBuf(data []byte, box mp4Box) (int, int) { return 0, 0 } +// isTruthyTagValue reports whether a fields-map flag value means set/true. +func isTruthyTagValue(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "explicit": + return true + } + return false +} + // EditM4AFields updates only the ilst entries whose keys are explicitly // present in the fields map (set-or-clear semantics, mirroring EditFlacFields) // while preserving every other atom. Standard atoms, freeform ISRC/LABEL, and @@ -249,6 +275,28 @@ func EditM4AFields(filePath string, fields map[string]string) error { removeFreeform["ORGANIZATION"] = struct{}{} freeformTags = append(freeformTags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])}) } + if v, ok := fields["album_type"]; ok { + removeFreeform["RELEASETYPE"] = struct{}{} + freeformTags = append(freeformTags, m4aFreeformTag{name: "RELEASETYPE", value: strings.TrimSpace(v)}) + } + if v, ok := fields["upc"]; ok { + removeFreeform["BARCODE"] = struct{}{} + freeformTags = append(freeformTags, m4aFreeformTag{name: "BARCODE", value: strings.TrimSpace(v)}) + } + // Content advisory (rtng) and compilation (cpil) are integer/boolean + // atoms rather than text. + if v, ok := fields["explicit"]; ok { + dropStandard["rtng"] = true + if isTruthyTagValue(v) { + appended = append(appended, buildM4AInt8Atom("rtng", 1)...) + } + } + if v, ok := fields["compilation"]; ok { + dropStandard["cpil"] = true + if isTruthyTagValue(v) { + appended = append(appended, buildM4AFlagAtom("cpil", true)...) + } + } replayGain := collectM4AReplayGainFields(fields) if len(replayGain) > 0 { for _, key := range []string{"replaygain_track_gain", "replaygain_track_peak", "replaygain_album_gain", "replaygain_album_peak"} { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 962bd875..95da9c61 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -188,6 +188,9 @@ type Metadata struct { Copyright string Composer string Comment string + Explicit bool + AlbumType string + UPC string // ReplayGain fields (stored as Vorbis Comments in FLAC) ReplayGainTrackGain string // e.g. "-6.50 dB" @@ -438,6 +441,11 @@ func applyVorbisFieldEdits(cmt *flacvorbis.MetaDataBlockVorbisComment, fields ma "copyright": "COPYRIGHT", "composer": "COMPOSER", "comment": "COMMENT", + "explicit": "ITUNESADVISORY", + "album_type": "RELEASETYPE", + "upc": "BARCODE", + "barcode": "BARCODE", + "compilation": "COMPILATION", "replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN", "replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK", "replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN", @@ -581,6 +589,21 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me setComment(cmt, "COMMENT", metadata.Comment) } + if metadata.Explicit { + setComment(cmt, "ITUNESADVISORY", "1") + } + + if metadata.AlbumType != "" { + setComment(cmt, "RELEASETYPE", strings.ToLower(metadata.AlbumType)) + if strings.EqualFold(metadata.AlbumType, "compilation") { + setComment(cmt, "COMPILATION", "1") + } + } + + if metadata.UPC != "" { + setComment(cmt, "BARCODE", metadata.UPC) + } + setComment(cmt, "REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain) setComment(cmt, "REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak) setComment(cmt, "REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain) diff --git a/go_backend/mp3_id3_write.go b/go_backend/mp3_id3_write.go index aea7d4fd..5b398abc 100644 --- a/go_backend/mp3_id3_write.go +++ b/go_backend/mp3_id3_write.go @@ -322,6 +322,25 @@ func EditMP3Fields(filePath string, fields map[string]string) error { } } + // Release identity (advisory/type/barcode) also lives in TXXX frames so + // any tagger can read it back; compilation uses the iTunes TCMP frame. + txxxDescriptions := map[string]string{ + "explicit": "ITUNESADVISORY", + "album_type": "RELEASETYPE", + "upc": "BARCODE", + } + for fieldKey, desc := range txxxDescriptions { + if v, ok := fields[fieldKey]; ok { + dropTXXXDesc[desc] = true + if strings.TrimSpace(v) != "" { + added = append(added, id3RawFrame{id: "TXXX", payload: id3TXXXPayload(desc, v)}) + } + } + } + if v, ok := fields["compilation"]; ok { + setOrClear("TCMP", v) + } + coverPath := strings.TrimSpace(fields["cover_path"]) if coverPath != "" { if coverData, err := os.ReadFile(coverPath); err == nil && len(coverData) > 0 { diff --git a/go_backend/native_tag_editors_test.go b/go_backend/native_tag_editors_test.go index 368f5ce8..94af5c1d 100644 --- a/go_backend/native_tag_editors_test.go +++ b/go_backend/native_tag_editors_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/go-flac/flacvorbis/v2" ) // --- MP3 ------------------------------------------------------------------- @@ -104,6 +106,56 @@ func TestEditMP3FieldsClearsAndWithoutTag(t *testing.T) { } } +func TestEditMP3FieldsWritesReleaseIdentityTags(t *testing.T) { + dir := t.TempDir() + path, audio := writeTestMP3(t, dir, id3TextFrame("TIT2", "Song")) + + if err := EditMP3Fields(path, map[string]string{ + "explicit": "1", + "album_type": "compilation", + "upc": "0012345678901", + "compilation": "1", + }); err != nil { + t.Fatalf("EditMP3Fields: %v", err) + } + + raw := mustReadFile(t, path) + for desc := range map[string]string{ + "ITUNESADVISORY": "1", + "RELEASETYPE": "compilation", + "BARCODE": "0012345678901", + } { + if !bytes.Contains(raw, []byte(desc)) { + t.Errorf("missing TXXX description %s", desc) + } + } + if !bytes.Contains(raw, []byte("TCMP")) { + t.Error("missing TCMP compilation frame") + } + if !bytes.HasSuffix(raw, audio) { + t.Error("audio bytes were modified") + } + + // Clearing removes the tags without touching the rest. + if err := EditMP3Fields(path, map[string]string{ + "explicit": "", + "album_type": "", + "upc": "", + "compilation": "", + }); err != nil { + t.Fatalf("clear release tags: %v", err) + } + raw = mustReadFile(t, path) + for _, desc := range []string{"ITUNESADVISORY", "RELEASETYPE", "BARCODE", "TCMP"} { + if bytes.Contains(raw, []byte(desc)) { + t.Errorf("cleared %s still present", desc) + } + } + if !bytes.Contains(raw, []byte("TIT2")) { + t.Error("untouched title lost") + } +} + // --- M4A ------------------------------------------------------------------- // buildTestM4A assembles ftyp + moov(trak stub with stco + udta>meta>ilst) + mdat @@ -210,6 +262,59 @@ func TestEditM4AFieldsPreservesAtomsAndShiftsChunkOffsets(t *testing.T) { } } +func TestEditM4AFieldsWritesReleaseIdentityAtoms(t *testing.T) { + dir := t.TempDir() + file, oldOffset := buildTestM4A(t, nil, []byte("DATA")) + _ = oldOffset + path := filepath.Join(dir, "release.m4a") + if err := os.WriteFile(path, file, 0o644); err != nil { + t.Fatal(err) + } + + if err := EditM4AFields(path, map[string]string{ + "title": "Song", + "explicit": "1", + "compilation": "1", + "album_type": "compilation", + "upc": "0012345678901", + }); err != nil { + t.Fatalf("EditM4AFields: %v", err) + } + + updated := mustReadFile(t, path) + if !bytes.Contains(updated, []byte("rtng")) { + t.Error("rtng advisory atom missing") + } + if !bytes.Contains(updated, []byte("cpil")) { + t.Error("cpil compilation atom missing") + } + if !bytes.Contains(updated, []byte("RELEASETYPE")) { + t.Error("RELEASETYPE freeform atom missing") + } + if !bytes.Contains(updated, []byte("0012345678901")) { + t.Error("BARCODE freeform atom missing") + } + + // Clearing drops the atoms again while the title survives. + if err := EditM4AFields(path, map[string]string{ + "explicit": "", + "compilation": "", + "album_type": "", + "upc": "", + }); err != nil { + t.Fatalf("clear release atoms: %v", err) + } + updated = mustReadFile(t, path) + for _, atom := range []string{"rtng", "cpil", "RELEASETYPE", "BARCODE"} { + if bytes.Contains(updated, []byte(atom)) { + t.Errorf("cleared %s still present", atom) + } + } + if got := readTestM4ATitle(t, updated); got != "Song" { + t.Errorf("title = %q, want Song", got) + } +} + func TestEditM4AFieldsCreatesMissingChain(t *testing.T) { dir := t.TempDir() // moov with only a trak stub — no udta/meta/ilst. @@ -235,6 +340,39 @@ func TestEditM4AFieldsCreatesMissingChain(t *testing.T) { // --- Ogg/Opus --------------------------------------------------------------- +func TestApplyVorbisFieldEditsReleaseTags(t *testing.T) { + cmt := flacvorbis.New() + applyVorbisFieldEdits(cmt, map[string]string{ + "explicit": "1", + "album_type": "compilation", + "upc": "0012345678901", + "compilation": "1", + }) + + for key, want := range map[string]string{ + "ITUNESADVISORY": "1", + "RELEASETYPE": "compilation", + "BARCODE": "0012345678901", + "COMPILATION": "1", + } { + if got := getComment(cmt, key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + + applyVorbisFieldEdits(cmt, map[string]string{ + "explicit": "", + "album_type": "", + "upc": "", + "compilation": "", + }) + for _, key := range []string{"ITUNESADVISORY", "RELEASETYPE", "BARCODE", "COMPILATION"} { + if got := getComment(cmt, key); got != "" { + t.Errorf("cleared %s = %q, want empty", key, got) + } + } +} + func buildTestOpus(t *testing.T, path string, comments []string, audioPages int) { t.Helper() head := append([]byte("OpusHead"), make([]byte, 11)...) diff --git a/lib/models/track.dart b/lib/models/track.dart index eae78c95..3fbede99 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -35,6 +35,7 @@ class Track { final String? audioQuality; final String? audioModes; final bool? explicit; + final String? upc; const Track({ required this.id, @@ -66,6 +67,7 @@ class Track { this.audioQuality, this.audioModes, this.explicit, + this.upc, }); bool get isSingle { @@ -145,6 +147,9 @@ class Track { audioModes: data['audio_modes']?.toString(), previewUrl: data['preview_url']?.toString(), explicit: parseExplicitFlag(data['explicit']), + upc: normalizeOptionalString( + (data['upc'] ?? data['barcode'])?.toString(), + ), ); } @@ -178,6 +183,7 @@ class Track { String? audioQuality, String? audioModes, bool? explicit, + String? upc, }) { return Track( id: id ?? this.id, @@ -209,6 +215,7 @@ class Track { audioQuality: audioQuality ?? this.audioQuality, audioModes: audioModes ?? this.audioModes, explicit: explicit ?? this.explicit, + upc: upc ?? this.upc, ); } diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 651d262b..2bb8d0f5 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -40,6 +40,7 @@ Track _$TrackFromJson(Map json) => Track( audioQuality: json['audioQuality'] as String?, audioModes: json['audioModes'] as String?, explicit: json['explicit'] as bool?, + upc: json['upc'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -72,6 +73,7 @@ Map _$TrackToJson(Track instance) => { 'audioQuality': instance.audioQuality, 'audioModes': instance.audioModes, 'explicit': instance.explicit, + 'upc': instance.upc, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3dacf45c..5c3b3d88 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -726,6 +726,9 @@ class DownloadQueueNotifier extends Notifier { copyright: copyright ?? track.copyright ?? '', composer: track.composer ?? '', comment: track.comment ?? '', + explicit: track.explicit == true, + albumType: track.albumType ?? '', + upc: track.upc ?? '', qobuzId: payloadQobuzId, tidalId: payloadTidalId, deezerId: deezerTrackId ?? '', diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 1f6ade5a..c28e3b09 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -510,6 +510,12 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { final sourceIsrc = normalizeOptionalString(baseTrack.isrc); final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate); final sourceComposer = normalizeOptionalString(baseTrack.composer); + final sourceAlbumType = normalizeOptionalString(baseTrack.albumType); + final sourceGenre = normalizeOptionalString(baseTrack.genre); + final sourceLabel = normalizeOptionalString(baseTrack.label); + final sourceCopyright = normalizeOptionalString(baseTrack.copyright); + final sourceComment = normalizeOptionalString(baseTrack.comment); + final sourceUpc = normalizeOptionalString(baseTrack.upc); final backendGenre = normalizeOptionalString( backendResult['genre']?.toString(), ); @@ -522,6 +528,13 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { final backendComment = normalizeOptionalString( backendResult['comment']?.toString(), ); + final backendAlbumType = normalizeOptionalString( + backendResult['album_type']?.toString(), + ); + final backendUpc = normalizeOptionalString( + (backendResult['upc'] ?? backendResult['barcode'])?.toString(), + ); + final backendExplicit = backendResult['explicit'] == true; final resolvedTotalTracks = _resolvePositiveMetadataInt( baseTrack.totalTracks, backendTotalTracks, @@ -554,7 +567,14 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { (sourceAlbumArtist == null && resolvedAlbumArtist == null && backendAlbumArtist != null) || - (sourceComposer == null && backendComposer != null); + (sourceComposer == null && backendComposer != null) || + (sourceAlbumType == null && backendAlbumType != null) || + (sourceGenre == null && backendGenre != null) || + (sourceLabel == null && backendLabel != null) || + (sourceCopyright == null && backendCopyright != null) || + (sourceComment == null && backendComment != null) || + (baseTrack.explicit != true && backendExplicit) || + (sourceUpc == null && backendUpc != null); if (!hasOverrides) { return baseTrack; @@ -578,18 +598,21 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { releaseDate: sourceReleaseDate ?? backendYear, deezerId: baseTrack.deezerId, availability: baseTrack.availability, - albumType: baseTrack.albumType, + albumType: sourceAlbumType ?? backendAlbumType, totalTracks: resolvedTotalTracks, composer: sourceComposer ?? backendComposer, - genre: baseTrack.genre ?? backendGenre, - label: baseTrack.label ?? backendLabel, - copyright: baseTrack.copyright ?? backendCopyright, - comment: baseTrack.comment ?? backendComment, + genre: sourceGenre ?? backendGenre, + label: sourceLabel ?? backendLabel, + copyright: sourceCopyright ?? backendCopyright, + comment: sourceComment ?? backendComment, source: baseTrack.source, itemType: baseTrack.itemType, audioQuality: baseTrack.audioQuality, audioModes: baseTrack.audioModes, - explicit: baseTrack.explicit, + explicit: baseTrack.explicit == true || backendExplicit + ? true + : baseTrack.explicit, + upc: sourceUpc ?? backendUpc, ); } @@ -690,6 +713,19 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { if (track.composer != null && track.composer!.isNotEmpty) { metadata['COMPOSER'] = track.composer!; } + if (track.isExplicit) { + metadata['ITUNESADVISORY'] = '1'; + } + final resolvedAlbumType = track.albumType; + if (resolvedAlbumType != null && resolvedAlbumType.isNotEmpty) { + metadata['RELEASETYPE'] = resolvedAlbumType.toLowerCase(); + if (resolvedAlbumType.toLowerCase() == 'compilation') { + metadata['COMPILATION'] = '1'; + } + } + if (track.upc != null && track.upc!.isNotEmpty) { + metadata['BARCODE'] = track.upc!; + } final lyricsMode = settings.lyricsMode; final extensionState = ref.read(extensionProvider); @@ -857,6 +893,12 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { 'disc_number': track.discNumber!.toString(), if (track.totalDiscs != null && track.totalDiscs! > 0) 'disc_total': track.totalDiscs!.toString(), + if (track.isExplicit) 'explicit': '1', + if (track.albumType != null && track.albumType!.isNotEmpty) + 'album_type': track.albumType!, + if (track.upc != null && track.upc!.isNotEmpty) 'upc': track.upc!, + if (track.albumType?.toLowerCase() == 'compilation') + 'compilation': '1', 'cover_path': ?validCover, if (shouldEmbedLyrics && lrcContent != null) ...{ 'lyrics': lrcContent, diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index a0516f66..508ef3e2 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -362,7 +362,12 @@ class _DownloadRun { itemType: trackToDownload.itemType, audioQuality: trackToDownload.audioQuality, audioModes: trackToDownload.audioModes, - explicit: trackToDownload.explicit, + explicit: + parseExplicitFlag(data['explicit']) ?? + trackToDownload.explicit, + upc: + (data['upc'] ?? data['barcode'])?.toString() ?? + trackToDownload.upc, ); _log.d( 'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}', diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 94f8216a..4fef1350 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -39,6 +39,9 @@ class DownloadRequestPayload { final String copyright; final String composer; final String comment; + final bool explicit; + final String albumType; + final String upc; final String tidalId; final String qobuzId; final String deezerId; @@ -98,6 +101,9 @@ class DownloadRequestPayload { this.copyright = '', this.composer = '', this.comment = '', + this.explicit = false, + this.albumType = '', + this.upc = '', this.tidalId = '', this.qobuzId = '', this.deezerId = '', @@ -159,6 +165,9 @@ class DownloadRequestPayload { 'copyright': copyright, 'composer': composer, 'comment': comment, + 'explicit': explicit, + 'album_type': albumType, + 'upc': upc, 'tidal_id': tidalId, 'qobuz_id': qobuzId, 'deezer_id': deezerId, @@ -224,6 +233,9 @@ class DownloadRequestPayload { copyright: copyright, composer: composer, comment: comment, + explicit: explicit, + albumType: albumType, + upc: upc, tidalId: tidalId, qobuzId: qobuzId, deezerId: deezerId, diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index d032ef5f..c0564cc5 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -455,6 +455,9 @@ void main() { 'copyright': 'Copyright', 'composer': 'Composer', 'comment': 'https://example.test/album/1', + 'explicit': true, + 'album_type': 'compilation', + 'upc': '0012345678901', }); expect(track.genre, 'Pop'); @@ -463,6 +466,10 @@ void main() { expect(track.composer, 'Composer'); expect(track.comment, 'https://example.test/album/1'); expect(track.toJson()['comment'], 'https://example.test/album/1'); + expect(track.explicit, isTrue); + expect(track.albumType, 'compilation'); + expect(track.upc, '0012345678901'); + expect(track.toJson()['upc'], '0012345678901'); }); test('does not treat a playlist container name as a track album', () { @@ -878,6 +885,9 @@ void main() { copyright: 'Copyright', composer: 'Composer', comment: 'https://example.test/album/1', + explicit: true, + albumType: 'compilation', + upc: '0012345678901', tidalId: 'tidal-1', qobuzId: 'qobuz-1', deezerId: 'deezer-1', @@ -935,6 +945,9 @@ void main() { 'copyright': 'Copyright', 'composer': 'Composer', 'comment': 'https://example.test/album/1', + 'explicit': true, + 'album_type': 'compilation', + 'upc': '0012345678901', 'tidal_id': 'tidal-1', 'qobuz_id': 'qobuz-1', 'deezer_id': 'deezer-1', @@ -981,6 +994,9 @@ void main() { expect(updated.autoConvertDownloads, payload.autoConvertDownloads); expect(updated.autoConvertFormat, payload.autoConvertFormat); expect(updated.autoConvertBitrate, payload.autoConvertBitrate); + expect(updated.explicit, payload.explicit); + expect(updated.albumType, payload.albumType); + expect(updated.upc, payload.upc); expect( updated.qualityVariantCollisionOnly, payload.qualityVariantCollisionOnly,