mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 21:30:23 +02:00
feat(tagging): write release identity tags
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -60,6 +60,7 @@ func normalizeExtensionTrackMetadataMap(
|
||||
"audio_quality": track.AudioQuality,
|
||||
"audio_modes": track.AudioModes,
|
||||
"explicit": track.Explicit,
|
||||
"upc": track.UPC,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
audioQuality: json['audioQuality'] as String?,
|
||||
audioModes: json['audioModes'] as String?,
|
||||
explicit: json['explicit'] as bool?,
|
||||
upc: json['upc'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -72,6 +73,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'audioQuality': instance.audioQuality,
|
||||
'audioModes': instance.audioModes,
|
||||
'explicit': instance.explicit,
|
||||
'upc': instance.upc,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -726,6 +726,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 ?? '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user