diff --git a/app.go b/app.go index 05c24d7..425f519 100644 --- a/app.go +++ b/app.go @@ -161,6 +161,7 @@ type DownloadRequest struct { SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` + Composer string `json:"composer,omitempty"` PlaylistName string `json:"playlist_name,omitempty"` PlaylistOwner string `json:"playlist_owner,omitempty"` AllowFallback bool `json:"allow_fallback"` @@ -384,25 +385,26 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) } - if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { + metadataSeparator := req.Separator + if metadataSeparator == "" { + metadataSeparator = ", " + metadataSettings, _ := a.LoadSettings() + if metadataSettings != nil { + if sep, ok := metadataSettings["separator"].(string); ok { + if sep == "semicolon" { + metadataSeparator = "; " + } else if sep == "comma" { + metadataSeparator = ", " + } + } + } + } + + if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.Composer == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) - metadataSeparator := req.Separator - if metadataSeparator == "" { - metadataSeparator = ", " - metadataSettings, _ := a.LoadSettings() - if metadataSettings != nil { - if sep, ok := metadataSettings["separator"].(string); ok { - if sep == "semicolon" { - metadataSeparator = "; " - } else if sep == "comma" { - metadataSeparator = ", " - } - } - } - } trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil) if err == nil { @@ -410,6 +412,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Track struct { Copyright string `json:"copyright"` Publisher string `json:"publisher"` + Composer string `json:"composer"` TotalDiscs int `json:"total_discs"` TotalTracks int `json:"total_tracks"` TrackNumber int `json:"track_number"` @@ -425,6 +428,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.Publisher == "" && trackResp.Track.Publisher != "" { req.Publisher = trackResp.Track.Publisher } + if req.Composer == "" && trackResp.Track.Composer != "" { + req.Composer = trackResp.Track.Composer + } if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 { req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs } @@ -500,25 +506,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { - filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } else { downloader := backend.NewTidalDownloader(req.ApiURL) if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } @@ -531,7 +537,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if quality == "" { quality = "6" } - filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) default: return DownloadResponse{ diff --git a/backend/amazon.go b/backend/amazon.go index a3484c5..4beac37 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -204,7 +204,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -390,6 +390,8 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, + Composer: spotifyComposer, + Separator: metadataSeparator, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, @@ -418,7 +420,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return filePath, nil } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool, ) (string, error) { @@ -427,5 +429,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) } diff --git a/backend/artist_format.go b/backend/artist_format.go new file mode 100644 index 0000000..29c4f26 --- /dev/null +++ b/backend/artist_format.go @@ -0,0 +1,90 @@ +package backend + +import "strings" + +func normalizeArtistSeparator(separator string) string { + separator = strings.TrimSpace(separator) + if separator == "," || separator == ";" { + return separator + } + return "" +} + +func splitArtistSegment(segment string, separator string) []string { + segment = strings.TrimSpace(segment) + if segment == "" { + return nil + } + + if strings.Contains(segment, "|||SEP|||") { + return strings.Split(segment, "|||SEP|||") + } + + parts := []string{segment} + + if separator = normalizeArtistSeparator(separator); separator != "" { + var separated []string + for _, part := range parts { + for _, item := range strings.Split(part, separator) { + separated = append(separated, item) + } + } + parts = separated + } else if strings.Contains(segment, ";") { + var separated []string + for _, part := range parts { + for _, item := range strings.Split(part, ";") { + separated = append(separated, item) + } + } + parts = separated + } + + return parts +} + +func SplitArtistCredits(artistStr, separator string) []string { + rawParts := splitArtistSegment(artistStr, separator) + if len(rawParts) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(rawParts)) + result := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + + return result +} + +func SplitMetadataValues(value, separator string) []string { + rawParts := splitArtistSegment(value, separator) + if len(rawParts) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(rawParts)) + result := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + + return result +} diff --git a/backend/metadata.go b/backend/metadata.go index 50cd52d..46b1b98 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -21,6 +21,7 @@ type Metadata struct { Artist string Album string AlbumArtist string + Separator string Date string ReleaseDate string TrackNumber int @@ -31,12 +32,72 @@ type Metadata struct { Comment string Copyright string Publisher string + Composer string Lyrics string Description string ISRC string Genre string } +func resolveMetadataSeparator(separator string) string { + if normalized := normalizeArtistSeparator(separator); normalized != "" { + return normalized + } + + return normalizeArtistSeparator(GetSeparator()) +} + +func displayMetadataSeparator(separator string) string { + if resolved := resolveMetadataSeparator(separator); resolved != "" { + return resolved + " " + } + + return "; " +} + +func addVorbisTagValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string, values []string) { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + _ = cmt.Add(key, value) + } +} + +func addMP3TextFrame(tag *id3v2.Tag, frameID string, value string) { + tag.DeleteFrames(frameID) + value = strings.TrimSpace(value) + if value == "" { + return + } + + tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value) +} + +func joinMultiValueText(values []string, separator string, nullSeparated bool) string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + cleaned = append(cleaned, value) + } + } + + if len(cleaned) == 0 { + return "" + } + if len(cleaned) == 1 { + return cleaned[0] + } + if nullSeparated { + return strings.Join(cleaned, "\x00") + } + + return strings.Join(cleaned, displayMetadataSeparator(separator)) +} + func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { f, err := flac.ParseFile(filepath) if err != nil { @@ -52,17 +113,22 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { } cmt := flacvorbis.New() + separator := resolveMetadataSeparator(metadata.Separator) if metadata.Title != "" { _ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title) } - if metadata.Artist != "" { + if artistValues := SplitArtistCredits(metadata.Artist, separator); len(artistValues) > 0 { + addVorbisTagValues(cmt, flacvorbis.FIELD_ARTIST, artistValues) + } else if metadata.Artist != "" { _ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist) } if metadata.Album != "" { _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album) } - if metadata.AlbumArtist != "" { + if albumArtistValues := SplitArtistCredits(metadata.AlbumArtist, separator); len(albumArtistValues) > 0 { + addVorbisTagValues(cmt, "ALBUMARTIST", albumArtistValues) + } else if metadata.AlbumArtist != "" { _ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist) } if metadata.Date != "" { @@ -86,6 +152,11 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.Publisher != "" { _ = cmt.Add("PUBLISHER", metadata.Publisher) } + if composerValues := SplitArtistCredits(metadata.Composer, separator); len(composerValues) > 0 { + addVorbisTagValues(cmt, "COMPOSER", composerValues) + } else if metadata.Composer != "" { + _ = cmt.Add("COMPOSER", metadata.Composer) + } if metadata.Description != "" { _ = cmt.Add("DESCRIPTION", metadata.Description) } @@ -97,7 +168,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { _ = cmt.Add("ISRC", metadata.ISRC) } - if metadata.Genre != "" { + if genreValues := SplitMetadataValues(metadata.Genre, separator); len(genreValues) > 0 { + addVorbisTagValues(cmt, "GENRE", genreValues) + } else if metadata.Genre != "" { _ = cmt.Add("GENRE", metadata.Genre) } @@ -901,6 +974,10 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { metadata.Copyright = value case "publisher", "tpub", "label": metadata.Publisher = value + case "composer", "writer", "wm/composer", "©wrt": + metadata.Composer = value + case "genre", "tcon": + metadata.Genre = value case "url": metadata.URL = value case "comment", "comments": @@ -940,15 +1017,13 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er return fmt.Errorf("failed to open MP3 file: %w", err) } defer tag.Close() + separator := resolveMetadataSeparator(metadata.Separator) tag.DeleteFrames("TXXX") if metadata.Title != "" { tag.SetTitle(metadata.Title) } - if metadata.Artist != "" { - tag.SetArtist(metadata.Artist) - } if metadata.Album != "" { tag.SetAlbum(metadata.Album) } @@ -960,10 +1035,17 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er tag.SetYear(year) } - if metadata.AlbumArtist != "" { - tag.DeleteFrames("TPE2") - tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist) + artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, true) + if artistText == "" { + artistText = strings.TrimSpace(metadata.Artist) } + addMP3TextFrame(tag, "TPE1", artistText) + + albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, true) + if albumArtistText == "" { + albumArtistText = strings.TrimSpace(metadata.AlbumArtist) + } + addMP3TextFrame(tag, "TPE2", albumArtistText) if metadata.TrackNumber > 0 { tag.DeleteFrames(tag.CommonID("Track number/Position in set")) @@ -984,18 +1066,21 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er } if metadata.Copyright != "" { - tag.DeleteFrames("TCOP") - tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright) + addMP3TextFrame(tag, "TCOP", metadata.Copyright) } if metadata.Publisher != "" { - tag.DeleteFrames("TPUB") - tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher) + addMP3TextFrame(tag, "TPUB", metadata.Publisher) } + composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, true) + if composerText == "" { + composerText = strings.TrimSpace(metadata.Composer) + } + addMP3TextFrame(tag, "TCOM", composerText) + if metadata.ISRC != "" { - tag.DeleteFrames("TSRC") - tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC) + addMP3TextFrame(tag, "TSRC", metadata.ISRC) } if comment := resolveMetadataComment(metadata); comment != "" { @@ -1027,6 +1112,12 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er } } + genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, true) + if genreText == "" { + genreText = strings.TrimSpace(metadata.Genre) + } + addMP3TextFrame(tag, "TCON", genreText) + if err := tag.Save(); err != nil { return fmt.Errorf("failed to save MP3 tags: %w", err) } @@ -1048,6 +1139,7 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er "-i", filePath, "-y", } + separator := resolveMetadataSeparator(metadata.Separator) if coverPath != "" && fileExists(coverPath) { args = append(args, "-i", coverPath) @@ -1059,14 +1151,22 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.Title != "" { args = append(args, "-metadata", "title="+metadata.Title) } - if metadata.Artist != "" { - args = append(args, "-metadata", "artist="+metadata.Artist) + artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, false) + if artistText == "" { + artistText = strings.TrimSpace(metadata.Artist) + } + if artistText != "" { + args = append(args, "-metadata", "artist="+artistText) } if metadata.Album != "" { args = append(args, "-metadata", "album="+metadata.Album) } - if metadata.AlbumArtist != "" { - args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist) + albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, false) + if albumArtistText == "" { + albumArtistText = strings.TrimSpace(metadata.AlbumArtist) + } + if albumArtistText != "" { + args = append(args, "-metadata", "album_artist="+albumArtistText) } if metadata.Date != "" { args = append(args, "-metadata", "date="+metadata.Date) @@ -1091,9 +1191,23 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.Publisher != "" { args = append(args, "-metadata", "publisher="+metadata.Publisher) } + composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, false) + if composerText == "" { + composerText = strings.TrimSpace(metadata.Composer) + } + if composerText != "" { + args = append(args, "-metadata", "composer="+composerText) + } if metadata.ISRC != "" { args = append(args, "-metadata", "isrc="+metadata.ISRC) } + genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, false) + if genreText == "" { + genreText = strings.TrimSpace(metadata.Genre) + } + if genreText != "" { + args = append(args, "-metadata", "genre="+genreText) + } if comment := resolveMetadataComment(metadata); comment != "" { args = append(args, "-metadata", "comment="+comment) } diff --git a/backend/qobuz.go b/backend/qobuz.go index b5273ab..9d84682 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -381,7 +381,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { var isrc string if spotifyID != "" { linkClient := NewSongLinkClient() @@ -394,10 +394,10 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF return "", fmt.Errorf("spotify ID is required for Qobuz download") } - return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) + return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } -func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", isrc) metaChan := make(chan Metadata, 1) @@ -522,6 +522,8 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, + Composer: spotifyComposer, + Separator: metadataSeparator, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 4ab8a90..184c867 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -53,6 +53,7 @@ type TrackMetadata struct { ArtistsData []ArtistSimple `json:"artists_data,omitempty"` Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` + Composer string `json:"composer,omitempty"` Plays string `json:"plays,omitempty"` PreviewURL string `json:"preview_url,omitempty"` IsExplicit bool `json:"is_explicit,omitempty"` @@ -193,6 +194,7 @@ type apiTrackResponse struct { Disc int `json:"disc"` Discs int `json:"discs"` Copyright string `json:"copyright"` + Composer string `json:"composer,omitempty"` Plays string `json:"plays"` Album struct { ID string `json:"id"` @@ -496,6 +498,10 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) } filteredData := FilterTrack(data, c.Separator, albumFetchData) + composer, composerErr := c.fetchTrackComposerWithClient(ctx, client, trackID) + if composerErr == nil && composer != "" { + filteredData["composer"] = composer + } jsonData, err := json.Marshal(filteredData) if err != nil { @@ -510,6 +516,89 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) return &result, nil } +func collectTrackCreditNamesByRole(items []interface{}, role string) []string { + role = strings.TrimSpace(role) + if role == "" || len(items) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(items)) + names := make([]string, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + if !strings.EqualFold(strings.TrimSpace(getString(itemMap, "role")), role) { + continue + } + + name := strings.TrimSpace(getString(itemMap, "name")) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + return names +} + +func (c *SpotifyMetadataClient) fetchTrackComposerWithClient(ctx context.Context, client *SpotifyClient, trackID string) (string, error) { + _ = ctx + + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "trackUri": fmt.Sprintf("spotify:track:%s", trackID), + "contributorsLimit": 100, + "contributorsOffset": 0, + }, + "operationName": "queryTrackCreditsModal", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "e2ca40d46cf1fde36562261ccec754f23fb31b561877252e9fe0d6834aabb84b", + }, + }, + } + + data, err := client.Query(payload) + if err != nil { + return "", fmt.Errorf("failed to query track credits: %w", err) + } + + creditItems := getSlice( + getMap( + getMap( + getMap( + getMap(data, "data"), + "trackUnion", + ), + "creditsTrait", + ), + "contributors", + ), + "items", + ) + + composerNames := collectTrackCreditNamesByRole(creditItems, "Composer") + if len(composerNames) == 0 { + return "", nil + } + + separator := strings.TrimSpace(c.Separator) + if separator == "" { + separator = ", " + } + + return strings.Join(composerNames, separator), nil +} + func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { @@ -963,6 +1052,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp ArtistsData: artistsData, Copyright: raw.Copyright, Publisher: raw.Album.Label, + Composer: raw.Composer, Plays: raw.Plays, IsExplicit: raw.IsExplicit, } diff --git a/backend/tidal.go b/backend/tidal.go index 402c81e..b8d9428 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -416,7 +416,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -554,6 +554,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, + Composer: spotifyComposer, + Separator: metadataSeparator, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, @@ -570,7 +572,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -714,6 +716,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, + Composer: spotifyComposer, + Separator: metadataSeparator, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, @@ -730,14 +734,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } type SegmentTemplate struct {