.upc metadata
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+203
-51
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">UPC</span><span>{metadataInfo.upc || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">ISRC</span><span>{metadataInfo.isrc || "-"}</span></div>
|
||||
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
|
||||
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user