feat(tagging): write release identity tags

This commit is contained in:
zarzet
2026-08-18 19:04:26 +07:00
parent 48883d51d4
commit 290923e88f
21 changed files with 400 additions and 11 deletions
@@ -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)
+5 -1
View File
@@ -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" } }
+22
View File
@@ -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),
+1
View File
@@ -60,6 +60,7 @@ func normalizeExtensionTrackMetadataMap(
"audio_quality": track.AudioQuality,
"audio_modes": track.AudioModes,
"explicit": track.Explicit,
"upc": track.UPC,
}
}
+13
View File
@@ -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 {
+3
View File
@@ -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
+4
View File
@@ -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)
}
+4
View File
@@ -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"`
+1
View File
@@ -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,
}
}
+48
View File
@@ -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"} {
+23
View File
@@ -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)
+19
View File
@@ -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 {
+138
View File
@@ -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)...)
+7
View File
@@ -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,
);
}
+2
View File
@@ -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,
+16
View File
@@ -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,