diff --git a/backend/filemanager.go b/backend/filemanager.go index 162713d..12f3b33 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -31,6 +31,7 @@ type AudioMetadata struct { DiscNumber int `json:"disc_number"` Year string `json:"year"` ISRC string `json:"isrc"` + UPC string `json:"upc"` } type RenamePreview struct { @@ -178,6 +179,10 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) { metadata.Year = value case "ISRC", "TSRC": metadata.ISRC = value + case "UPC": + assignPreferredUPC(&metadata.UPC, value, true) + case "BARCODE": + assignPreferredUPC(&metadata.UPC, value, false) } } } @@ -229,6 +234,22 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { metadata.ISRC = textFrame.Text } } + if frames := tag.GetFrames("TXXX"); len(frames) > 0 { + for _, frame := range frames { + userTextFrame, ok := frame.(id3v2.UserDefinedTextFrame) + if !ok { + continue + } + matched, preferred := classifyUPCDescription(userTextFrame.Description) + if !matched { + continue + } + assignPreferredUPC(&metadata.UPC, userTextFrame.Value, preferred) + if preferred && strings.TrimSpace(metadata.UPC) != "" { + break + } + } + } return metadata, nil } @@ -315,6 +336,8 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { } } + metadata.UPC = firstPreferredFFprobeUPCValue(allTags) + return metadata, nil } diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go index cf077ea..e032526 100644 --- a/backend/isrc_finder.go +++ b/backend/isrc_finder.go @@ -49,13 +49,28 @@ type spotifySecretsCache struct { } type spotifyTrackRawData struct { + Album struct { + GID string `json:"gid"` + } `json:"album"` ExternalID []struct { Type string `json:"type"` ID string `json:"id"` } `json:"external_id"` } -type spotFetchISRCResponse struct { +type spotifyAlbumRawData struct { + ExternalID []struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"external_id"` +} + +type SpotifyTrackIdentifiers struct { + ISRC string `json:"isrc,omitempty"` + UPC string `json:"upc,omitempty"` +} + +type spotFetchIdentifierResponse struct { Input string `json:"input"` TrackID string `json:"track_id"` GID string `json:"gid"` @@ -66,64 +81,102 @@ type spotFetchISRCResponse struct { ReleaseDate string `json:"release_date"` Label string `json:"label"` ISRC string `json:"isrc"` + UPC string `json:"upc"` } -func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { +func GetSpotifyTrackIdentifiersDirect(spotifyTrackID string) (SpotifyTrackIdentifiers, error) { normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) if err != nil { - return "", err + return SpotifyTrackIdentifiers{}, err } + identifiers := SpotifyTrackIdentifiers{} + cachedISRC, err := GetCachedISRC(normalizedTrackID) if err != nil { fmt.Printf("Warning: failed to read ISRC cache: %v\n", err) } else if cachedISRC != "" { fmt.Printf("Found ISRC in cache: %s\n", cachedISRC) - return cachedISRC, nil + identifiers.ISRC = cachedISRC } useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings() if useSpotFetchAPI { - isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL) - if err == nil && isrc != "" { - fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) - return isrc, nil - } - if err != nil { - fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err) + apiIdentifiers, resolvedTrackID, err := lookupSpotifyTrackIdentifiersViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL) + if err == nil { + mergeSpotifyTrackIdentifiers(&identifiers, apiIdentifiers) + if identifiers.ISRC != "" { + fmt.Printf("Found identifiers via SpotFetch API: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, identifiers.ISRC) + } + if identifiers.ISRC != "" && identifiers.UPC != "" { + return identifiers, nil + } + } else { + fmt.Printf("Warning: SpotFetch identifier lookup failed, falling back to Spotify metadata: %v\n", err) } } - payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID) + httpClient := &http.Client{Timeout: 30 * time.Second} + + payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID) if metadataErr == nil { - isrc, extractErr := extractSpotifyTrackISRC(payload) + metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload) if extractErr == nil { - fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc) - return isrc, nil + mergeSpotifyTrackIdentifiers(&identifiers, metadataIdentifiers) + if identifiers.ISRC != "" { + fmt.Printf("Found identifiers via Spotify metadata: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", identifiers.ISRC) + } + if identifiers.ISRC != "" && identifiers.UPC != "" { + return identifiers, nil + } } metadataErr = extractErr } if metadataErr != nil { - fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr) + fmt.Printf("Warning: Spotify metadata identifier lookup failed, falling back to Soundplate: %v\n", metadataErr) } - isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID) - if soundplateErr == nil && isrc != "" { - fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) - return isrc, nil + if identifiers.ISRC == "" { + client := NewSongLinkClient() + isrc, resolvedTrackID, soundplateErr := client.lookupSpotifyISRCViaSoundplate(normalizedTrackID) + if soundplateErr == nil && isrc != "" { + identifiers.ISRC = isrc + fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) + return identifiers, nil + } + + if metadataErr != nil && soundplateErr != nil { + return identifiers, fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) + } + if soundplateErr != nil && identifiers.UPC == "" { + return identifiers, soundplateErr + } } - if metadataErr != nil && soundplateErr != nil { - return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) + if identifiers.ISRC != "" || identifiers.UPC != "" { + return identifiers, nil } - if soundplateErr != nil { - return "", soundplateErr + if metadataErr != nil { + return identifiers, metadataErr } - return "", metadataErr + + return identifiers, fmt.Errorf("no Spotify identifiers found for track %s", normalizedTrackID) +} + +func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { + identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyTrackID) + if err != nil { + return "", err + } + if identifiers.ISRC == "" { + return "", fmt.Errorf("no Spotify ISRC found for track %s", strings.TrimSpace(spotifyTrackID)) + } + + return identifiers.ISRC, nil } func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) { @@ -138,19 +191,40 @@ func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc } func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) { + identifiers, resolvedTrackID, err := lookupSpotifyTrackIdentifiersViaSpotFetchAPI(spotifyTrackID, apiBaseURL) + if err != nil { + return "", "", err + } + if identifiers.ISRC == "" { + return "", "", fmt.Errorf("ISRC missing in SpotFetch identifier response") + } + + return identifiers.ISRC, resolvedTrackID, nil +} + +func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) { + if incoming.ISRC != "" { + target.ISRC = strings.TrimSpace(incoming.ISRC) + } + if incoming.UPC != "" { + target.UPC = strings.TrimSpace(incoming.UPC) + } +} + +func lookupSpotifyTrackIdentifiersViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (SpotifyTrackIdentifiers, string, error) { normalizedTrackID := strings.TrimSpace(spotifyTrackID) baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/") if normalizedTrackID == "" { - return "", "", fmt.Errorf("spotify track ID is required") + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("spotify track ID is required") } if baseURL == "" { - return "", "", fmt.Errorf("spotfetch api url is required") + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("spotfetch api url is required") } - requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID)) + requestURL := fmt.Sprintf("%s/identifier/%s", baseURL, url.PathEscape(normalizedTrackID)) req, err := http.NewRequest(http.MethodGet, requestURL, nil) if err != nil { - return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err) + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("failed to create SpotFetch identifier request: %w", err) } req.Header.Set("User-Agent", songLinkUserAgent) req.Header.Set("Accept", "application/json") @@ -158,26 +232,44 @@ func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { - return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err) + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("SpotFetch identifier request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) - return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("SpotFetch identifier returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) } - var payload spotFetchISRCResponse + var payload spotFetchIdentifierResponse if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err) + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("failed to decode SpotFetch identifier response: %w", err) } - isrc := firstISRCMatch(payload.ISRC) - if isrc == "" { - return "", "", fmt.Errorf("ISRC missing in SpotFetch response") + identifiers := SpotifyTrackIdentifiers{ + ISRC: firstISRCMatch(payload.ISRC), + UPC: strings.TrimSpace(payload.UPC), + } + if identifiers.ISRC == "" && identifiers.UPC == "" { + return SpotifyTrackIdentifiers{}, "", fmt.Errorf("identifiers missing in SpotFetch response") } - return isrc, strings.TrimSpace(payload.TrackID), nil + return identifiers, strings.TrimSpace(payload.TrackID), nil +} + +func lookupSpotifyAlbumUPC(albumID string) (string, error) { + normalizedAlbumID := strings.TrimSpace(albumID) + if normalizedAlbumID == "" { + return "", fmt.Errorf("spotify album ID is required") + } + + httpClient := &http.Client{Timeout: 30 * time.Second} + payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID) + if err != nil { + return "", err + } + + return extractSpotifyAlbumUPC(payload) } func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) { @@ -504,14 +596,18 @@ func extractSpotifyTrackID(value string) (string, error) { } func spotifyTrackIDToGID(trackID string) (string, error) { - if trackID == "" { - return "", errors.New("track ID is empty") + return spotifyEntityIDToGID(trackID) +} + +func spotifyEntityIDToGID(entityID string) (string, error) { + if entityID == "" { + return "", errors.New("entity ID is empty") } value := big.NewInt(0) base := big.NewInt(62) - for _, char := range trackID { + for _, char := range entityID { index := strings.IndexRune(spotifyBase62Alphabet, char) if index < 0 { return "", fmt.Errorf("invalid base62 character: %q", string(char)) @@ -530,43 +626,99 @@ func spotifyTrackIDToGID(trackID string) (string, error) { } func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) { - accessToken, err := requestSpotifyAnonymousAccessToken(client) + gid, err := spotifyTrackIDToGID(trackID) if err != nil { return nil, err } - gid, err := spotifyTrackIDToGID(trackID) + return fetchSpotifyRawMetadataByGID(client, "track", gid) +} + +func fetchSpotifyAlbumRawData(client *http.Client, albumID string) ([]byte, error) { + gid, err := spotifyEntityIDToGID(albumID) + if err != nil { + return nil, err + } + + return fetchSpotifyRawMetadataByGID(client, "album", gid) +} + +func fetchSpotifyRawMetadataByGID(client *http.Client, entityType string, gid string) ([]byte, error) { + accessToken, err := requestSpotifyAnonymousAccessToken(client) if err != nil { return nil, err } return requestSpotifyBytes( client, - fmt.Sprintf(spotifyGIDMetadataURL, "track", gid), + fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid), map[string]string{ "authorization": "Bearer " + accessToken, "accept": "application/json", + "user-agent": songLinkUserAgent, }, ) } -func extractSpotifyTrackISRC(payload []byte) (string, error) { +func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) { var track spotifyTrackRawData if err := json.Unmarshal(payload, &track); err != nil { - return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err) + return SpotifyTrackIdentifiers{}, fmt.Errorf("failed to decode Spotify track metadata: %w", err) } + identifiers := SpotifyTrackIdentifiers{} for _, externalID := range track.ExternalID { if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") { if isrc := firstISRCMatch(externalID.ID); isrc != "" { - return isrc, nil + identifiers.ISRC = isrc + break } } } - if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" { - return fallbackISRC, nil + if identifiers.ISRC == "" { + identifiers.ISRC = firstISRCMatch(string(payload)) + } + + albumGID := strings.TrimSpace(track.Album.GID) + if client != nil && albumGID != "" { + albumPayload, err := fetchSpotifyRawMetadataByGID(client, "album", albumGID) + if err == nil { + if upc, upcErr := extractSpotifyAlbumUPC(albumPayload); upcErr == nil { + identifiers.UPC = upc + } + } + } + + return identifiers, nil +} + +func extractSpotifyTrackISRC(payload []byte) (string, error) { + identifiers, err := extractSpotifyTrackIdentifiers(nil, payload) + if err != nil { + return "", err + } + if identifiers.ISRC != "" { + return identifiers.ISRC, nil } return "", fmt.Errorf("ISRC not found in Spotify track metadata") } + +func extractSpotifyAlbumUPC(payload []byte) (string, error) { + var album spotifyAlbumRawData + if err := json.Unmarshal(payload, &album); err != nil { + return "", fmt.Errorf("failed to decode Spotify album metadata: %w", err) + } + + for _, externalID := range album.ExternalID { + if strings.EqualFold(strings.TrimSpace(externalID.Type), "upc") { + upc := strings.TrimSpace(externalID.ID) + if upc != "" { + return upc, nil + } + } + } + + return "", fmt.Errorf("UPC not found in Spotify album metadata") +} diff --git a/backend/metadata.go b/backend/metadata.go index 46b1b98..f438338 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -36,6 +36,7 @@ type Metadata struct { Lyrics string Description string ISRC string + UPC string Genre string } @@ -167,6 +168,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.ISRC != "" { _ = cmt.Add("ISRC", metadata.ISRC) } + if metadata.UPC != "" { + _ = cmt.Add(preferredUPCTagKey, metadata.UPC) + } if genreValues := SplitMetadataValues(metadata.Genre, separator); len(genreValues) > 0 { addVorbisTagValues(cmt, "GENRE", genreValues) @@ -980,6 +984,8 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { metadata.Genre = value case "url": metadata.URL = value + case "isrc", "tsrc": + metadata.ISRC = value case "comment", "comments": if metadata.Comment == "" { metadata.Comment = value @@ -991,6 +997,8 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { } } + metadata.UPC = firstPreferredFFprobeUPCValue(allTags) + return metadata, nil } @@ -1082,6 +1090,13 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er if metadata.ISRC != "" { addMP3TextFrame(tag, "TSRC", metadata.ISRC) } + if metadata.UPC != "" { + tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ + Encoding: id3v2.EncodingUTF8, + Description: "UPC", + Value: metadata.UPC, + }) + } if comment := resolveMetadataComment(metadata); comment != "" { tag.DeleteFrames(tag.CommonID("Comments")) @@ -1201,6 +1216,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.ISRC != "" { args = append(args, "-metadata", "isrc="+metadata.ISRC) } + if metadata.UPC != "" { + args = append(args, "-metadata", "upc="+metadata.UPC) + } genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, false) if genreText == "" { genreText = strings.TrimSpace(metadata.Genre) diff --git a/backend/qobuz.go b/backend/qobuz.go index de7229a..c5f9e7a 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -513,6 +513,14 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena trackNumberToEmbed = 1 } + upc := "" + if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { + if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { + isrc = strings.TrimSpace(identifiers.ISRC) + } + upc = strings.TrimSpace(identifiers.UPC) + } + metadata := Metadata{ Title: trackTitle, Artist: artists, @@ -531,6 +539,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena Separator: metadataSeparator, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, + UPC: upc, Genre: mbMeta.Genre, } diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go index db484c0..cc91039 100644 --- a/backend/spotfetch_api.go +++ b/backend/spotfetch_api.go @@ -87,6 +87,17 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, if err := json.Unmarshal(bodyBytes, &trackResp); err != nil { return nil, fmt.Errorf("failed to decode track response: %w", err) } + trackID := strings.TrimSpace(trackResp.Track.SpotifyID) + if trackID == "" { + trackID = strings.TrimSpace(id) + } + if trackID != "" { + if identifiers, _, err := lookupSpotifyTrackIdentifiersViaSpotFetchAPI(trackID, apiBaseURL); err == nil { + if identifiers.UPC != "" { + trackResp.Track.UPC = identifiers.UPC + } + } + } data = trackResp case "album": var albumResp AlbumResponsePayload @@ -156,6 +167,7 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, DiscNumber: t.DiscNumber, TotalDiscs: t.TotalDiscs, ExternalURL: t.ExternalURL, + UPC: t.UPC, Plays: t.Plays, PreviewURL: t.PreviewURL, IsExplicit: t.IsExplicit, diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 184c867..35129ef 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -51,6 +51,7 @@ type TrackMetadata struct { ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` Composer string `json:"composer,omitempty"` @@ -85,6 +86,7 @@ type AlbumTrackMetadata struct { ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` Plays string `json:"plays,omitempty"` Status string `json:"status,omitempty"` PreviewURL string `json:"preview_url,omitempty"` @@ -101,6 +103,7 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + UPC string `json:"upc,omitempty"` Batch string `json:"batch,omitempty"` ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` @@ -189,6 +192,7 @@ type apiTrackResponse struct { Name string `json:"name"` Artists string `json:"artists"` ArtistIds []string `json:"artistIds,omitempty"` + UPC string `json:"upc,omitempty"` Duration string `json:"duration"` Track int `json:"track"` Disc int `json:"disc"` @@ -219,6 +223,7 @@ type apiAlbumResponse struct { Artists string `json:"artists"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` + UPC string `json:"upc,omitempty"` Count int `json:"count"` Label string `json:"label"` Discs struct { @@ -231,6 +236,7 @@ type apiAlbumResponse struct { ArtistIds []string `json:"artistIds"` Duration string `json:"duration"` Plays string `json:"plays"` + UPC string `json:"upc,omitempty"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` } `json:"tracks"` @@ -258,6 +264,7 @@ type apiPlaylistResponse struct { Album string `json:"album"` AlbumArtist string `json:"albumArtist"` AlbumID string `json:"albumId"` + UPC string `json:"upc,omitempty"` Duration string `json:"duration"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` @@ -513,6 +520,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) return nil, fmt.Errorf("failed to unmarshal to apiTrackResponse: %w", err) } + if result.ID != "" { + if identifiers, err := GetSpotifyTrackIdentifiersDirect(result.ID); err == nil || identifiers.UPC != "" { + if identifiers.UPC != "" { + result.UPC = identifiers.UPC + } + } + } + return &result, nil } @@ -702,6 +717,17 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client return nil, fmt.Errorf("failed to unmarshal to apiAlbumResponse: %w", err) } + if result.ID != "" { + if upc, err := lookupSpotifyAlbumUPC(result.ID); err == nil && strings.TrimSpace(upc) != "" { + result.UPC = upc + for i := range result.Tracks { + if strings.TrimSpace(result.Tracks[i].UPC) == "" { + result.Tracks[i].UPC = upc + } + } + } + } + return &result, nil } @@ -1050,6 +1076,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: raw.UPC, Copyright: raw.Copyright, Publisher: raw.Album.Label, Composer: raw.Composer, @@ -1083,6 +1110,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ReleaseDate: raw.ReleaseDate, Artists: raw.Artists, Images: raw.Cover, + UPC: raw.UPC, ArtistID: artistID, ArtistURL: artistURL, } @@ -1098,6 +1126,10 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback for idx, item := range raw.Tracks { durationMS := parseDuration(item.Duration) trackNumber := idx + 1 + trackUPC := strings.TrimSpace(item.UPC) + if trackUPC == "" { + trackUPC = strings.TrimSpace(raw.UPC) + } var artistID, artistURL string if len(item.ArtistIds) > 0 { @@ -1133,6 +1165,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: trackUPC, Plays: item.Plays, IsExplicit: item.IsExplicit, }) @@ -1203,6 +1236,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, cal ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: item.UPC, Plays: item.Plays, Status: item.Status, IsExplicit: item.IsExplicit, @@ -1329,6 +1363,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, TrackNumber: trackNumber, TotalTracks: albumData.Count, DiscNumber: tr.DiscNumber, + UPC: tr.UPC, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), AlbumID: albumID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), diff --git a/backend/upc_tags.go b/backend/upc_tags.go new file mode 100644 index 0000000..14a638f --- /dev/null +++ b/backend/upc_tags.go @@ -0,0 +1,50 @@ +package backend + +import "strings" + +const preferredUPCTagKey = "UPC" + +var ffprobeUPCTagKeys = []string{ + "upc", + "barcode", + "wm/upc", + "txxx:upc", + "txxx:barcode", + "txxx/upc", + "txxx/barcode", + "----:com.apple.itunes:upc", + "----:com.apple.itunes:barcode", +} + +func assignPreferredUPC(current *string, incoming string, preferred bool) { + incoming = strings.TrimSpace(incoming) + if incoming == "" { + return + } + + if preferred || strings.TrimSpace(*current) == "" { + *current = incoming + } +} + +func classifyUPCDescription(description string) (matched bool, preferred bool) { + switch strings.ToUpper(strings.TrimSpace(description)) { + case preferredUPCTagKey: + return true, true + case "BARCODE": + return true, false + default: + return false, false + } +} + +func firstPreferredFFprobeUPCValue(tags map[string]string) string { + for _, key := range ffprobeUPCTagKeys { + value := strings.TrimSpace(tags[key]) + if value != "" { + return value + } + } + + return "" +} diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx index 37b511f..5deba40 100644 --- a/frontend/src/components/FileManagerPage.tsx +++ b/frontend/src/components/FileManagerPage.tsx @@ -36,6 +36,7 @@ interface FileMetadata { track_number: number; disc_number: number; year: string; + upc?: string; isrc?: string; } type TabType = "track" | "lyric" | "cover"; @@ -661,6 +662,7 @@ export function FileManagerPage() {
Track{metadataInfo.track_number || "-"}
Disc{metadataInfo.disc_number || "-"}
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
+
UPC{metadataInfo.upc || "-"}
ISRC{metadataInfo.isrc || "-"}
) : (
No metadata available
)} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 3811142..e46b03c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -24,6 +24,7 @@ export interface TrackMetadata { artist_url?: string; artists_data?: ArtistSimple[]; isrc?: string; + upc?: string; copyright?: string; publisher?: string; plays?: string; @@ -39,6 +40,7 @@ export interface AlbumInfo { release_date: string; artists: string; images: string; + upc?: string; batch?: string; } export interface AlbumResponse { @@ -281,5 +283,6 @@ export interface AudioMetadata { track_number: number; disc_number: number; year: string; + upc?: string; isrc?: string; }