package gobackend import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) const ( deezerBaseURL = "https://api.deezer.com/2.0" deezerSearchURL = deezerBaseURL + "/search" deezerTrackURL = deezerBaseURL + "/track/%s" deezerAlbumURL = deezerBaseURL + "/album/%s" deezerArtistURL = deezerBaseURL + "/artist/%s" deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related" deezerPlaylistURL = deezerBaseURL + "/playlist/%s" deezerCacheTTL = 10 * time.Minute deezerMaxParallelISRC = 10 // Deezer API timeout and retry configuration for mobile networks deezerAPITimeoutMobile = 25 * time.Second deezerMaxRetries = 2 deezerRetryDelay = 500 * time.Millisecond deezerMaxSearchCacheEntries = 300 deezerMaxAlbumCacheEntries = 200 deezerMaxArtistCacheEntries = 200 deezerMaxISRCCacheEntries = 4000 deezerCacheCleanupInterval = 5 * time.Minute ) type DeezerClient struct { httpClient *http.Client searchCache map[string]*cacheEntry albumCache map[string]*cacheEntry artistCache map[string]*cacheEntry isrcCache map[string]string cacheMu sync.RWMutex lastCacheCleanup time.Time cacheCleanupInterval time.Duration } var ( deezerClient *DeezerClient deezerClientOnce sync.Once ) func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile), searchCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry), isrcCache: make(map[string]string), cacheCleanupInterval: deezerCacheCleanupInterval, } }) return deezerClient } func (c *DeezerClient) pruneExpiredCacheEntriesLocked( cache map[string]*cacheEntry, now time.Time, ) { for key, entry := range cache { if entry == nil || now.After(entry.expiresAt) { delete(cache, key) } } } func (c *DeezerClient) trimCacheEntriesLocked( cache map[string]*cacheEntry, maxEntries int, ) { if maxEntries <= 0 || len(cache) <= maxEntries { return } for len(cache) > maxEntries { var oldestKey string var oldestExpiry time.Time first := true for key, entry := range cache { expiry := time.Time{} if entry != nil { expiry = entry.expiresAt } if first || expiry.Before(oldestExpiry) { first = false oldestKey = key oldestExpiry = expiry } } if oldestKey == "" { return } delete(cache, oldestKey) } } func (c *DeezerClient) trimStringCacheEntriesLocked( cache map[string]string, maxEntries int, ) { if maxEntries <= 0 || len(cache) <= maxEntries { return } toRemove := len(cache) - maxEntries for key := range cache { delete(cache, key) toRemove-- if toRemove <= 0 { return } } } func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) { periodicCleanupDue := c.cacheCleanupInterval > 0 && (c.lastCacheCleanup.IsZero() || now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval) if periodicCleanupDue { c.pruneExpiredCacheEntriesLocked(c.searchCache, now) c.pruneExpiredCacheEntriesLocked(c.albumCache, now) c.pruneExpiredCacheEntriesLocked(c.artistCache, now) c.lastCacheCleanup = now } if len(c.searchCache) > deezerMaxSearchCacheEntries { if !periodicCleanupDue { c.pruneExpiredCacheEntriesLocked(c.searchCache, now) } c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries) } if len(c.albumCache) > deezerMaxAlbumCacheEntries { if !periodicCleanupDue { c.pruneExpiredCacheEntriesLocked(c.albumCache, now) } c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries) } if len(c.artistCache) > deezerMaxArtistCacheEntries { if !periodicCleanupDue { c.pruneExpiredCacheEntriesLocked(c.artistCache, now) } c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries) } if len(c.isrcCache) > deezerMaxISRCCacheEntries { c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries) } } type deezerTrack struct { ID int64 `json:"id"` Title string `json:"title"` Duration int `json:"duration"` TrackPosition int `json:"track_position"` DiskNumber int `json:"disk_number"` ISRC string `json:"isrc"` Link string `json:"link"` ReleaseDate string `json:"release_date"` Artist deezerArtist `json:"artist"` Album deezerAlbumSimple `json:"album"` Contributors []deezerArtist `json:"contributors"` } type deezerArtist struct { ID int64 `json:"id"` Name string `json:"name"` Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` NbFan int `json:"nb_fan"` } type deezerAlbumSimple struct { ID int64 `json:"id"` Title string `json:"title"` Cover string `json:"cover"` CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` RecordType string `json:"record_type"` } func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { artistName := track.Artist.Name if len(track.Contributors) > 0 { names := make([]string, len(track.Contributors)) for i, a := range track.Contributors { names[i] = a.Name } artistName = strings.Join(names, ", ") } albumImage := track.Album.CoverXL if albumImage == "" { albumImage = track.Album.CoverBig } if albumImage == "" { albumImage = track.Album.CoverMedium } if albumImage == "" { albumImage = track.Album.Cover } releaseDate := track.ReleaseDate if releaseDate == "" { releaseDate = track.Album.ReleaseDate } return TrackMetadata{ SpotifyID: fmt.Sprintf("deezer:%d", track.ID), Artists: artistName, Name: track.Title, AlbumName: track.Album.Title, AlbumArtist: track.Artist.Name, DurationMS: track.Duration * 1000, Images: albumImage, ReleaseDate: releaseDate, TrackNumber: track.TrackPosition, DiscNumber: track.DiskNumber, ExternalURL: track.Link, ISRC: track.ISRC, AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID), ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID), } } type deezerGenre struct { ID int `json:"id"` Name string `json:"name"` } type deezerAlbumFull struct { ID int64 `json:"id"` Title string `json:"title"` Cover string `json:"cover"` CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` RecordType string `json:"record_type"` Label string `json:"label"` Copyright string `json:"copyright"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` Tracks struct { Data []deezerTrack `json:"data"` } `json:"tracks"` } type deezerArtistFull struct { ID int64 `json:"id"` Name string `json:"name"` Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` NbFan int `json:"nb_fan"` NbAlbum int `json:"nb_album"` } type deezerPlaylistFull struct { ID int64 `json:"id"` Title string `json:"title"` Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` NbTracks int `json:"nb_tracks"` Creator struct { Name string `json:"name"` } `json:"creator"` Tracks struct { Data []deezerTrack `json:"data"` } `json:"tracks"` } func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) albumLimit := 5 playlistLimit := 5 if filter != "" { switch filter { case "track": trackLimit = 50 artistLimit = 0 albumLimit = 0 playlistLimit = 0 case "artist": trackLimit = 0 artistLimit = 20 albumLimit = 0 playlistLimit = 0 case "album": trackLimit = 0 artistLimit = 0 albumLimit = 20 playlistLimit = 0 case "playlist": trackLimit = 0 artistLimit = 0 albumLimit = 0 playlistLimit = 20 } } cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter) c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { c.cacheMu.RUnlock() GoLog("[Deezer] SearchAll: returning cached result\n") return entry.data.(*SearchAllResult), nil } c.cacheMu.RUnlock() result := &SearchAllResult{ Tracks: make([]TrackMetadata, 0, trackLimit), Artists: make([]SearchArtistResult, 0, artistLimit), Albums: make([]SearchAlbumResult, 0, albumLimit), Playlists: make([]SearchPlaylistResult, 0, playlistLimit), } if trackLimit > 0 { trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) var trackResp struct { Data []deezerTrack `json:"data"` Error *struct { Type string `json:"type"` Message string `json:"message"` Code int `json:"code"` } `json:"error"` } if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { GoLog("[Deezer] Track search failed: %v\n", err) return nil, fmt.Errorf("deezer track search failed: %w", err) } if trackResp.Error != nil { GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message) return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code) } GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) for _, track := range trackResp.Data { result.Tracks = append(result.Tracks, c.convertTrack(track)) } } if artistLimit > 0 { artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) GoLog("[Deezer] Fetching artists from: %s\n", artistURL) var artistResp struct { Data []deezerArtist `json:"data"` Error *struct { Type string `json:"type"` Message string `json:"message"` Code int `json:"code"` } `json:"error"` } if err := c.getJSON(ctx, artistURL, &artistResp); err == nil { if artistResp.Error != nil { GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message) } else { GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data)) for _, artist := range artistResp.Data { result.Artists = append(result.Artists, SearchArtistResult{ ID: fmt.Sprintf("deezer:%d", artist.ID), Name: artist.Name, Images: c.getBestArtistImage(artist), Followers: artist.NbFan, Popularity: 0, }) } } } else { GoLog("[Deezer] Artist search failed: %v\n", err) } } if albumLimit > 0 { albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit) GoLog("[Deezer] Fetching albums from: %s\n", albumURL) var albumResp struct { Data []struct { ID int64 `json:"id"` Title string `json:"title"` Cover string `json:"cover"` CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` NbTracks int `json:"nb_tracks"` ReleaseDate string `json:"release_date"` RecordType string `json:"record_type"` Artist deezerArtist `json:"artist"` } `json:"data"` Error *struct { Type string `json:"type"` Message string `json:"message"` Code int `json:"code"` } `json:"error"` } if err := c.getJSON(ctx, albumURL, &albumResp); err == nil { if albumResp.Error != nil { GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message) } else { GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data)) for _, album := range albumResp.Data { coverURL := album.CoverXL if coverURL == "" { coverURL = album.CoverBig } if coverURL == "" { coverURL = album.CoverMedium } if coverURL == "" { coverURL = album.Cover } albumType := album.RecordType if albumType == "compile" { albumType = "compilation" } result.Albums = append(result.Albums, SearchAlbumResult{ ID: fmt.Sprintf("deezer:%d", album.ID), Name: album.Title, Artists: album.Artist.Name, Images: coverURL, ReleaseDate: album.ReleaseDate, TotalTracks: album.NbTracks, AlbumType: albumType, }) } } } else { GoLog("[Deezer] Album search failed: %v\n", err) } } if playlistLimit > 0 { playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit) GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL) var playlistResp struct { Data []struct { ID int64 `json:"id"` Title string `json:"title"` Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` NbTracks int `json:"nb_tracks"` User struct { Name string `json:"name"` } `json:"user"` } `json:"data"` Error *struct { Type string `json:"type"` Message string `json:"message"` Code int `json:"code"` } `json:"error"` } if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil { if playlistResp.Error != nil { GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message) } else { GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data)) for _, playlist := range playlistResp.Data { pictureURL := playlist.PictureXL if pictureURL == "" { pictureURL = playlist.PictureBig } if pictureURL == "" { pictureURL = playlist.PictureMedium } if pictureURL == "" { pictureURL = playlist.Picture } result.Playlists = append(result.Playlists, SearchPlaylistResult{ ID: fmt.Sprintf("deezer:%d", playlist.ID), Name: playlist.Title, Owner: playlist.User.Name, Images: pictureURL, TotalTracks: playlist.NbTracks, }) } } } else { GoLog("[Deezer] Playlist search failed: %v\n", err) } } GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists)) c.cacheMu.Lock() now := time.Now() c.searchCache[cacheKey] = &cacheEntry{ data: result, expiresAt: now.Add(deezerCacheTTL), } c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil } func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) var track deezerTrack if err := c.getJSON(ctx, trackURL, &track); err != nil { return nil, err } return &TrackResponse{ Track: c.convertTrack(track), }, nil } func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { c.cacheMu.RLock() if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { c.cacheMu.RUnlock() return entry.data.(*AlbumResponsePayload), nil } c.cacheMu.RUnlock() albumURL := fmt.Sprintf(deezerAlbumURL, albumID) var album deezerAlbumFull if err := c.getJSON(ctx, albumURL, &album); err != nil { return nil, err } albumImage := c.getBestAlbumImage(album) artistName := album.Artist.Name if len(album.Contributors) > 0 { names := make([]string, len(album.Contributors)) for i, a := range album.Contributors { names[i] = a.Name } artistName = strings.Join(names, ", ") } var genres []string for _, g := range album.Genres.Data { if g.Name != "" { genres = append(genres, g.Name) } } genreStr := strings.Join(genres, ", ") info := AlbumInfoMetadata{ TotalTracks: album.NbTracks, Name: album.Title, ReleaseDate: album.ReleaseDate, Artists: artistName, ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID), Images: albumImage, Genre: genreStr, Label: album.Label, } allTracks := album.Tracks.Data if album.NbTracks > len(allTracks) { GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks)) tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks)) for len(allTracks) < album.NbTracks { var tracksResp struct { Data []deezerTrack `json:"data"` Next string `json:"next"` } if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil { GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err) break } if len(tracksResp.Data) == 0 { break } allTracks = append(allTracks, tracksResp.Data...) if tracksResp.Next == "" { break } tracksURL = tracksResp.Next } GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks)) } isrcMap := c.fetchISRCsParallel(ctx, allTracks) tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) albumType := album.RecordType if albumType == "compile" { albumType = "compilation" } for i, track := range allTracks { trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] trackNum := track.TrackPosition if trackNum == 0 { trackNum = i + 1 } tracks = append(tracks, AlbumTrackMetadata{ SpotifyID: fmt.Sprintf("deezer:%d", track.ID), Artists: track.Artist.Name, Name: track.Title, AlbumName: album.Title, AlbumArtist: artistName, DurationMS: track.Duration * 1000, Images: albumImage, ReleaseDate: album.ReleaseDate, TrackNumber: trackNum, TotalTracks: album.NbTracks, DiscNumber: track.DiskNumber, ExternalURL: track.Link, ISRC: isrc, AlbumID: fmt.Sprintf("deezer:%d", album.ID), AlbumType: albumType, }) } result := &AlbumResponsePayload{ AlbumInfo: info, TrackList: tracks, } c.cacheMu.Lock() now := time.Now() c.albumCache[albumID] = &cacheEntry{ data: result, expiresAt: now.Add(deezerCacheTTL), } c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil } func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { c.cacheMu.RLock() if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { c.cacheMu.RUnlock() return entry.data.(*ArtistResponsePayload), nil } c.cacheMu.RUnlock() artistURL := fmt.Sprintf(deezerArtistURL, artistID) var artist deezerArtistFull if err := c.getJSON(ctx, artistURL, &artist); err != nil { return nil, err } artistInfo := ArtistInfoMetadata{ ID: fmt.Sprintf("deezer:%d", artist.ID), Name: artist.Name, Images: c.getBestArtistImageFull(artist), Followers: artist.NbFan, Popularity: 0, } albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) var albumsResp struct { Data []struct { ID int64 `json:"id"` Title string `json:"title"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` Cover string `json:"cover"` CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` RecordType string `json:"record_type"` } `json:"data"` } albums := make([]ArtistAlbumMetadata, 0) if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil { for _, album := range albumsResp.Data { albumType := album.RecordType if albumType == "compile" { albumType = "compilation" } coverURL := album.CoverXL if coverURL == "" { coverURL = album.CoverBig } if coverURL == "" { coverURL = album.CoverMedium } if coverURL == "" { coverURL = album.Cover } albums = append(albums, ArtistAlbumMetadata{ ID: fmt.Sprintf("deezer:%d", album.ID), Name: album.Title, ReleaseDate: album.ReleaseDate, TotalTracks: album.NbTracks, Images: coverURL, AlbumType: albumType, Artists: artist.Name, }) } } result := &ArtistResponsePayload{ ArtistInfo: artistInfo, Albums: albums, } c.cacheMu.Lock() now := time.Now() c.artistCache[artistID] = &cacheEntry{ data: result, expiresAt: now.Add(deezerCacheTTL), } c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil } func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:")) if normalizedArtistID == "" { return nil, fmt.Errorf("invalid Deezer artist ID") } effectiveLimit := limit if effectiveLimit <= 0 { effectiveLimit = 12 } relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit) var relatedResp struct { Data []struct { ID int64 `json:"id"` Name string `json:"name"` Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` NbFan int `json:"nb_fan"` } `json:"data"` Error *struct { Type string `json:"type"` Message string `json:"message"` Code int `json:"code"` } `json:"error,omitempty"` } if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil { return nil, err } if relatedResp.Error != nil { return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message) } result := make([]SearchArtistResult, 0, len(relatedResp.Data)) for _, artist := range relatedResp.Data { imageURL := artist.PictureXL if imageURL == "" { imageURL = artist.PictureBig } if imageURL == "" { imageURL = artist.PictureMedium } if imageURL == "" { imageURL = artist.Picture } result = append(result, SearchArtistResult{ ID: fmt.Sprintf("deezer:%d", artist.ID), Name: artist.Name, Images: imageURL, Followers: artist.NbFan, Popularity: 0, }) } return result, nil } func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) var playlist deezerPlaylistFull if err := c.getJSON(ctx, playlistURL, &playlist); err != nil { return nil, err } playlistImage := playlist.PictureXL if playlistImage == "" { playlistImage = playlist.PictureBig } if playlistImage == "" { playlistImage = playlist.PictureMedium } var info PlaylistInfoMetadata info.Tracks.Total = playlist.NbTracks info.Owner.DisplayName = playlist.Creator.Name info.Owner.Name = playlist.Title info.Owner.Images = playlistImage allTracks := playlist.Tracks.Data if playlist.NbTracks > len(allTracks) { GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks)) tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks)) for len(allTracks) < playlist.NbTracks { var tracksResp struct { Data []deezerTrack `json:"data"` Next string `json:"next"` } if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil { GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err) break } if len(tracksResp.Data) == 0 { break } allTracks = append(allTracks, tracksResp.Data...) if tracksResp.Next == "" { break } tracksURL = tracksResp.Next } GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks)) } isrcMap := c.fetchISRCsParallel(ctx, allTracks) tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) for _, track := range allTracks { albumImage := track.Album.CoverXL if albumImage == "" { albumImage = track.Album.CoverBig } if albumImage == "" { albumImage = track.Album.CoverMedium } trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] tracks = append(tracks, AlbumTrackMetadata{ SpotifyID: fmt.Sprintf("deezer:%d", track.ID), Artists: track.Artist.Name, Name: track.Title, AlbumName: track.Album.Title, AlbumArtist: track.Artist.Name, DurationMS: track.Duration * 1000, Images: albumImage, ReleaseDate: "", TrackNumber: track.TrackPosition, DiscNumber: track.DiskNumber, ExternalURL: track.Link, ISRC: isrc, AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID), }) } return &PlaylistResponsePayload{ PlaylistInfo: info, TrackList: tracks, }, nil } func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) var track deezerTrack if err := c.getJSON(ctx, directURL, &track); err != nil { searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) var resp struct { Data []deezerTrack `json:"data"` } if err := c.getJSON(ctx, searchURL, &resp); err != nil { return nil, err } if len(resp.Data) == 0 { return nil, fmt.Errorf("no track found for ISRC: %s", isrc) } result := c.convertTrack(resp.Data[0]) return &result, nil } if track.ID == 0 { return nil, fmt.Errorf("no track found for ISRC: %s", isrc) } result := c.convertTrack(track) return &result, nil } func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) var track deezerTrack if err := c.getJSON(ctx, trackURL, &track); err != nil { return nil, err } return &track, nil } func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { result := make(map[string]string, len(tracks)) var resultMu sync.Mutex var tracksToFetch []deezerTrack var directISRCs map[string]string c.cacheMu.RLock() for _, track := range tracks { trackIDStr := fmt.Sprintf("%d", track.ID) if track.ISRC != "" { result[trackIDStr] = track.ISRC if _, ok := c.isrcCache[trackIDStr]; !ok { if directISRCs == nil { directISRCs = make(map[string]string) } directISRCs[trackIDStr] = track.ISRC } continue } if isrc, ok := c.isrcCache[trackIDStr]; ok { result[trackIDStr] = isrc } else { tracksToFetch = append(tracksToFetch, track) } } c.cacheMu.RUnlock() if len(directISRCs) > 0 { c.cacheMu.Lock() for trackIDStr, isrc := range directISRCs { c.isrcCache[trackIDStr] = isrc } c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() } if len(tracksToFetch) == 0 { return result } sem := make(chan struct{}, deezerMaxParallelISRC) var wg sync.WaitGroup for _, track := range tracksToFetch { wg.Add(1) go func(t deezerTrack) { defer wg.Done() select { case sem <- struct{}{}: defer func() { <-sem }() case <-ctx.Done(): return } trackIDStr := fmt.Sprintf("%d", t.ID) fullTrack, err := c.fetchFullTrack(ctx, trackIDStr) if err != nil || fullTrack == nil { return } resultMu.Lock() result[trackIDStr] = fullTrack.ISRC resultMu.Unlock() c.cacheMu.Lock() c.isrcCache[trackIDStr] = fullTrack.ISRC c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() }(track) } wg.Wait() return result } func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { c.cacheMu.RLock() if isrc, ok := c.isrcCache[trackID]; ok { c.cacheMu.RUnlock() return isrc, nil } c.cacheMu.RUnlock() fullTrack, err := c.fetchFullTrack(ctx, trackID) if err != nil { return "", err } c.cacheMu.Lock() c.isrcCache[trackID] = fullTrack.ISRC c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() return fullTrack.ISRC, nil } func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string { if artist.PictureXL != "" { return artist.PictureXL } if artist.PictureBig != "" { return artist.PictureBig } if artist.PictureMedium != "" { return artist.PictureMedium } return artist.Picture } func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string { if artist.PictureXL != "" { return artist.PictureXL } if artist.PictureBig != "" { return artist.PictureBig } if artist.PictureMedium != "" { return artist.PictureMedium } return artist.Picture } func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { if album.CoverXL != "" { return album.CoverXL } if album.CoverBig != "" { return album.CoverBig } if album.CoverMedium != "" { return album.CoverMedium } return album.Cover } type AlbumExtendedMetadata struct { Genre string Label string Copyright string } func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { if albumID == "" { return nil, fmt.Errorf("empty album ID") } cacheKey := fmt.Sprintf("album_meta:%s", albumID) c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { c.cacheMu.RUnlock() return entry.data.(*AlbumExtendedMetadata), nil } c.cacheMu.RUnlock() albumURL := fmt.Sprintf(deezerAlbumURL, albumID) var album deezerAlbumFull if err := c.getJSON(ctx, albumURL, &album); err != nil { return nil, fmt.Errorf("failed to fetch album: %w", err) } var genres []string for _, g := range album.Genres.Data { if g.Name != "" { genres = append(genres, g.Name) } } result := &AlbumExtendedMetadata{ Genre: strings.Join(genres, ", "), Label: album.Label, Copyright: album.Copyright, } c.cacheMu.Lock() now := time.Now() c.searchCache[cacheKey] = &cacheEntry{ data: result, expiresAt: now.Add(deezerCacheTTL), } c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright) return result, nil } func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) var track deezerTrack if err := c.getJSON(ctx, trackURL, &track); err != nil { return "", err } return fmt.Sprintf("%d", track.Album.ID), nil } func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { albumID, err := c.GetTrackAlbumID(ctx, trackID) if err != nil { return nil, fmt.Errorf("failed to get album ID: %w", err) } return c.GetAlbumExtendedMetadata(ctx, albumID) } func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { if isrc == "" { return nil, fmt.Errorf("empty ISRC") } track, err := c.SearchByISRC(ctx, isrc) if err != nil { return nil, fmt.Errorf("failed to find track by ISRC: %w", err) } deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:") if deezerID == "" { return nil, fmt.Errorf("track found but no Deezer ID") } return c.GetExtendedMetadataByTrackID(ctx, deezerID) } func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { var lastErr error for attempt := 0; attempt <= deezerMaxRetries; attempt++ { if attempt > 0 { delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) time.Sleep(delay) } err := c.doGetJSON(ctx, endpoint, dst) if err == nil { return nil } lastErr = err errStr := err.Error() isRetryable := strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "EOF") || strings.Contains(errStr, "status 5") || strings.Contains(errStr, "status 429") if !isRetryable { return err } GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err) } return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr) } func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body)) } return json.Unmarshal(body, dst) } func parseDeezerURL(input string) (string, string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return "", "", fmt.Errorf("empty URL") } parsed, err := url.Parse(trimmed) if err != nil { return "", "", err } if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" { return "", "", fmt.Errorf("not a Deezer URL") } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(parts) > 0 && len(parts[0]) == 2 { parts = parts[1:] } if len(parts) < 2 { return "", "", fmt.Errorf("invalid Deezer URL format") } resourceType := parts[0] resourceID := parts[1] switch resourceType { case "track", "album", "artist", "playlist": return resourceType, resourceID, nil default: return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) } }