package main import ( "crypto/md5" "encoding/hex" "encoding/json" "fmt" "net/url" "regexp" "strings" "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) // hashKey returns a hex-encoded MD5 hash of s, for use as a cache key suffix. func hashKey(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) } const ( spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later) ) // listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses. // The API returns spotify_track_ids as an array of strings. type listenBrainzResult struct { SpotifyTrackIDs []string `json:"spotify_track_ids"` } // spotifySearchURL builds a Spotify search URL from one or more terms. // Empty terms are ignored. Returns "" if all terms are empty. func spotifySearchURL(terms ...string) string { query := strings.TrimSpace(strings.Join(terms, " ")) if query == "" { return "" } return "https://open.spotify.com/search/" + url.PathEscape(query) } // spotifyCacheKey returns a deterministic cache key for a track's Spotify URL. func spotifyCacheKey(artist, title, album string) string { return "spotify.url." + hashKey(strings.ToLower(artist)+"\x00"+strings.ToLower(title)+"\x00"+strings.ToLower(album)) } // trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint. func trySpotifyFromMBID(mbid string) string { body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid) req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json") req.SetHeader("Content-Type", "application/json") req.SetBody([]byte(body)) resp := req.Send() status := resp.Status() if status < 200 || status >= 300 { pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) return "" } id := parseSpotifyID(resp.Body()) if id == "" { pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body()))) } return id } // trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint. func trySpotifyFromMetadata(artist, title, album string) string { payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album) req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json") req.SetHeader("Content-Type", "application/json") req.SetBody([]byte(payload)) pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload)) resp := req.Send() status := resp.Status() if status < 200 || status >= 300 { pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", status, string(resp.Body()))) return "" } pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, string(resp.Body()))) id := parseSpotifyID(resp.Body()) if id == "" { pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title)) } return id } // parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response. // The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID. func parseSpotifyID(body []byte) string { var results []listenBrainzResult if err := json.Unmarshal(body, &results); err != nil { return "" } for _, r := range results { for _, id := range r.SpotifyTrackIDs { if isValidSpotifyID(id) { return id } } } return "" } // isValidSpotifyID checks that a Spotify track ID contains only base-62 characters. var spotifyIDRegex = regexp.MustCompile(`^[0-9A-Za-z]+$`) func isValidSpotifyID(id string) bool { return spotifyIDRegex.MatchString(id) } // resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs, // falling back to a search URL. Results are cached. func resolveSpotifyURL(track scrobbler.TrackInfo) string { primary, _ := parsePrimaryArtist(track.Artist) if primary == "" && len(track.Artists) > 0 { primary = track.Artists[0].Name } cacheKey := spotifyCacheKey(primary, track.Title, track.Album) if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists { pdk.Log(pdk.LogDebug, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached)) return cached } pdk.Log(pdk.LogDebug, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID)) // 1. Try MBID lookup (most accurate) if track.MBZRecordingID != "" { if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" { directURL := "https://open.spotify.com/track/" + trackID _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL)) return directURL } pdk.Log(pdk.LogDebug, "MBID lookup did not return a Spotify ID, trying metadata…") } else { pdk.Log(pdk.LogDebug, "No MBZRecordingID available, skipping MBID lookup") } // 2. Try metadata lookup if primary != "" && track.Title != "" { if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" { directURL := "https://open.spotify.com/track/" + trackID _ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit) pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL)) return directURL } } // 3. Fallback to search URL searchURL := spotifySearchURL(track.Artist, track.Title) _ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss) pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL)) return searchURL } // parsePrimaryArtist returns the primary artist (before "Feat." / "Ft." / "Featuring") // and the optional feat suffix. For artist resolution, only the primary artist is used; // co-artists identified by "Feat.", "Ft.", "Featuring", "&", or "/" are stripped. func parsePrimaryArtist(artist string) (primary, featSuffix string) { artist = strings.TrimSpace(artist) if artist == "" { return "", "" } lower := strings.ToLower(artist) for _, sep := range []string{" feat. ", " ft. ", " featuring "} { if i := strings.Index(lower, sep); i >= 0 { primary = strings.TrimSpace(artist[:i]) featSuffix = strings.TrimSpace(artist[i:]) return primary, featSuffix } } // Split on co-artist separators; take only the first artist. for _, sep := range []string{" & ", " / "} { if i := strings.Index(artist, sep); i >= 0 { return strings.TrimSpace(artist[:i]), "" } } return artist, "" }